feat: migrate from MySQL to Postgres with pgschema

Replace MySQL 8.0 with Postgres 17 across the entire stack:

- Add declarative schema/schema.sql for pgschema (merged from 21 migrations)
- Delete migrations/ directory (sqlx migrations no longer used)
- Update all sprout-db queries: positional $N params, ON CONFLICT,
  string_agg, array_position, encode(), pg_catalog partitions
- Update sprout-audit: pg_advisory_lock replaces GET_LOCK
- Update docker-compose, .env.example, justfile, CI, and scripts
- Cargo.toml: sqlx mysql feature → postgres

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ai-assisted: true
This commit is contained in:
Alec Thomas
2026-03-19 14:32:57 +11:00
parent 97b8a732c4
commit 688846e9bb
65 changed files with 1573 additions and 2195 deletions

View File

@@ -7,7 +7,7 @@
# All defaults here work with `docker compose up` out of the box.
#
# Service ports (defaults):
# MySQL → localhost:3306
# Postgres → localhost:5432
# Redis → localhost:6379
# Typesense → localhost:8108
# Adminer → localhost:8082 (DB browser UI)
@@ -16,13 +16,14 @@
# =============================================================================
# -----------------------------------------------------------------------------
# Database (MySQL 8.0)
# Database (Postgres 17)
# -----------------------------------------------------------------------------
DATABASE_URL=mysql://sprout:sprout_dev@localhost:3306/sprout
MYSQL_ROOT_PASSWORD=sprout_dev
MYSQL_USER=sprout
MYSQL_PASSWORD=sprout_dev
MYSQL_DATABASE=sprout
DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout
PGHOST=localhost
PGPORT=5432
PGUSER=sprout
PGPASSWORD=sprout_dev
PGDATABASE=sprout
# -----------------------------------------------------------------------------
# Redis 7
@@ -85,11 +86,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu
# OTLP tracing endpoint (optional — leave unset to disable)
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
# -----------------------------------------------------------------------------
# sqlx (offline mode for Docker builds — set to true in CI/Docker)
# -----------------------------------------------------------------------------
SQLX_OFFLINE=false
# -----------------------------------------------------------------------------
# Huddle (LiveKit integration)
# -----------------------------------------------------------------------------

View File

@@ -130,7 +130,7 @@ jobs:
docker logs "${container}" || true
return 1
}
wait_healthy "MySQL" "sprout-mysql"
wait_healthy "Postgres" "sprout-postgres"
wait_healthy "Redis" "sprout-redis"
wait_healthy "Typesense" "sprout-typesense"
- name: Build relay
@@ -138,7 +138,7 @@ jobs:
- name: Start relay
run: |
nohup env \
DATABASE_URL=mysql://sprout:sprout_dev@localhost:3306/sprout \
DATABASE_URL=postgres://sprout:sprout_dev@localhost:5432/sprout \
REDIS_URL=redis://localhost:6379 \
TYPESENSE_URL=http://localhost:8108 \
TYPESENSE_API_KEY=sprout_dev_key \

View File

@@ -38,7 +38,7 @@ tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip"
# Database
sqlx = { version = "0.8", features = [
"runtime-tokio-rustls", "mysql", "uuid", "chrono", "json"
"runtime-tokio-rustls", "postgres", "uuid", "chrono", "json"
] }
# Redis

1
bin/.biome-2.4.7.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/.pgschema-1.7.4.pkg Symbolic link
View File

@@ -0,0 +1 @@
hermit

1
bin/biome Symbolic link
View File

@@ -0,0 +1 @@
.biome-2.4.7.pkg

View File

@@ -1,4 +1 @@
manage-git = false
github-token-auth {
}
manage-git = true

1
bin/pgschema Symbolic link
View File

@@ -0,0 +1 @@
.pgschema-1.7.4.pkg

View File

@@ -1,7 +1,7 @@
#![deny(unsafe_code)]
#![warn(missing_docs)]
//! Tamper-evident hash-chain audit log. Each entry chains to the previous via
//! SHA-256. Single-writer via MySQL `GET_LOCK`. AUTH events (kind 22242)
//! SHA-256. Single-writer via Postgres `pg_advisory_lock`. AUTH events (kind 22242)
//! are rejected — they carry bearer tokens.
/// Audit action types recorded in the log.

View File

@@ -1,34 +1,18 @@
/// DDL for the `audit_log` table. Passed to [`sqlx::raw_sql`] on startup.
///
/// Note: `CREATE TABLE IF NOT EXISTS` does not alter existing tables. If the
/// live database has `event_kind SMALLINT` from an earlier schema, run
/// [`AUDIT_MIGRATE_SQL`] once to widen the column to `INT`.
pub const AUDIT_SCHEMA_SQL: &str = r#"
CREATE TABLE IF NOT EXISTS audit_log (
seq BIGINT NOT NULL PRIMARY KEY,
timestamp DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_id VARCHAR(255) NOT NULL,
event_kind INT NOT NULL,
actor_pubkey VARCHAR(255) NOT NULL,
action VARCHAR(64) NOT NULL,
channel_id BINARY(16),
metadata JSON NOT NULL,
channel_id BYTEA,
metadata JSONB NOT NULL,
prev_hash VARCHAR(64) NOT NULL,
hash VARCHAR(64) NOT NULL,
INDEX idx_audit_log_timestamp (timestamp),
INDEX idx_audit_log_actor (actor_pubkey),
INDEX idx_audit_log_channel (channel_id)
hash VARCHAR(64) NOT NULL
);
"#;
/// One-time migration: widens `event_kind` from `SMALLINT` to `INT` on databases
/// created before the column type was corrected. Safe to run on an already-`INT`
/// column — MySQL is a no-op when the type matches.
///
/// Run this manually:
/// ```sql
/// ALTER TABLE audit_log MODIFY COLUMN event_kind INT NOT NULL;
/// ```
pub const AUDIT_MIGRATE_SQL: &str = r#"
ALTER TABLE audit_log MODIFY COLUMN event_kind INT NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log (actor_pubkey);
CREATE INDEX IF NOT EXISTS idx_audit_log_channel ON audit_log (channel_id);
"#;

View File

@@ -1,6 +1,6 @@
use chrono::{DateTime, Utc};
use futures_util::FutureExt as _;
use sqlx::{Acquire, MySqlPool, Row};
use sqlx::{Acquire, PgPool, Row};
use tracing::{debug, instrument, warn};
use sprout_core::kind::KIND_AUTH;
@@ -13,20 +13,20 @@ use crate::{
schema::AUDIT_SCHEMA_SQL,
};
const AUDIT_LOCK_NAME: &str = "sprout_audit";
const AUDIT_LOCK_TIMEOUT_SECS: i64 = 10;
/// Advisory lock key derived from a stable hash of "sprout_audit".
const AUDIT_LOCK_KEY: i64 = 0x5370_7275_7441_7564; // "SprutAud" as hex
/// Append-only audit log service backed by MySQL.
/// Append-only audit log service backed by Postgres.
///
/// Serialises writes via `GET_LOCK` so the hash chain remains consistent
/// Serialises writes via `pg_advisory_lock` so the hash chain remains consistent
/// even when multiple relay processes share the same database.
pub struct AuditService {
pool: MySqlPool,
pool: PgPool,
}
impl AuditService {
/// Creates a new `AuditService` using the given connection pool.
pub fn new(pool: MySqlPool) -> Self {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
@@ -36,13 +36,10 @@ impl AuditService {
Ok(())
}
/// Append a new entry to the audit log. Single-writer via `GET_LOCK`.
/// Append a new entry to the audit log. Single-writer via `pg_advisory_lock`.
///
/// MySQL's GET_LOCK is session-scoped (not transaction-scoped), so we
/// acquire it before beginning the transaction and release it explicitly
/// after commit (or on any error path). `DO RELEASE_LOCK` is called in
/// all branches — success, error, and via `tokio::task::spawn` on panic —
/// so the lock is never left held on a pooled connection.
/// Postgres advisory locks are session-scoped, so we acquire before the
/// transaction and release after commit (or on any error path).
#[instrument(skip(self, entry), fields(action = %entry.action))]
pub async fn log(&self, entry: NewAuditEntry) -> Result<AuditEntry, AuditError> {
if entry.event_kind == KIND_AUTH {
@@ -52,31 +49,21 @@ impl AuditService {
let mut conn = self.pool.acquire().await?;
let lock_acquired: i32 = sqlx::query_scalar("SELECT GET_LOCK(?, ?)")
.bind(AUDIT_LOCK_NAME)
.bind(AUDIT_LOCK_TIMEOUT_SECS)
.fetch_one(&mut *conn)
// Acquire session-level advisory lock (blocks until available).
sqlx::query("SELECT pg_advisory_lock($1)")
.bind(AUDIT_LOCK_KEY)
.execute(&mut *conn)
.await?;
if lock_acquired != 1 {
return Err(AuditError::Database(sqlx::Error::Protocol(
"failed to acquire advisory lock for audit log".into(),
)));
}
// Run log_inner and release the lock regardless of outcome.
// We use catch_unwind to handle panics so the lock is always released
// before the connection is returned to the pool.
//
// ⚠️ SAFETY: log_inner is not UnwindSafe by default (it holds &mut conn),
// but we use AssertUnwindSafe because we own the connection and will not
// observe partial state after a panic — the connection is dropped.
let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry))
.catch_unwind()
.await;
let _ = sqlx::query("DO RELEASE_LOCK(?)")
.bind(AUDIT_LOCK_NAME)
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
.bind(AUDIT_LOCK_KEY)
.execute(&mut *conn)
.await;
@@ -88,7 +75,7 @@ impl AuditService {
async fn log_inner(
&self,
conn: &mut sqlx::pool::PoolConnection<sqlx::MySql>,
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
entry: NewAuditEntry,
) -> Result<AuditEntry, AuditError> {
let mut tx = conn.begin().await?;
@@ -130,7 +117,7 @@ impl AuditService {
INSERT INTO audit_log
(seq, timestamp, event_id, event_kind, actor_pubkey, action,
channel_id, metadata, prev_hash, hash)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
)
.bind(audit_entry.seq)
@@ -160,7 +147,7 @@ impl AuditService {
SELECT seq, timestamp, event_id, event_kind, actor_pubkey,
action, channel_id, metadata, prev_hash, hash
FROM audit_log
WHERE seq BETWEEN ? AND ?
WHERE seq BETWEEN $1 AND $2
ORDER BY seq ASC
"#,
)
@@ -217,9 +204,9 @@ impl AuditService {
SELECT seq, timestamp, event_id, event_kind, actor_pubkey,
action, channel_id, metadata, prev_hash, hash
FROM audit_log
WHERE seq >= ?
WHERE seq >= $1
ORDER BY seq ASC
LIMIT ?
LIMIT $2
"#,
)
.bind(from_seq)
@@ -231,7 +218,7 @@ impl AuditService {
}
}
fn row_to_audit_entry(row: &sqlx::mysql::MySqlRow) -> Result<AuditEntry, AuditError> {
fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result<AuditEntry, AuditError> {
let seq: i64 = row.get("seq");
let action_str: String = row.get("action");
let action: AuditAction = action_str.parse().map_err(|_| {
@@ -277,10 +264,10 @@ mod tests {
DB_LOCK.get_or_init(|| Mutex::new(()))
}
async fn test_pool() -> Option<MySqlPool> {
async fn test_pool() -> Option<PgPool> {
let url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://sprout:sprout_dev@localhost:3306/sprout".into());
MySqlPool::connect(&url).await.ok()
.unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".into());
PgPool::connect(&url).await.ok()
}
fn sample_new_entry(kind: u32, action: AuditAction) -> NewAuditEntry {
@@ -294,7 +281,7 @@ mod tests {
}
}
async fn reset_audit_table(pool: &MySqlPool) {
async fn reset_audit_table(pool: &PgPool) {
sqlx::query("TRUNCATE TABLE audit_log")
.execute(pool)
.await
@@ -302,7 +289,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn genesis_entry() {
let _guard = db_lock().lock().await;
let Some(pool) = test_pool().await else {
@@ -323,7 +310,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn chain_integrity() {
let _guard = db_lock().lock().await;
let Some(pool) = test_pool().await else {
@@ -354,7 +341,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn verify_chain_detects_tampering() {
let _guard = db_lock().lock().await;
let Some(pool) = test_pool().await else {
@@ -377,7 +364,7 @@ mod tests {
.await
.unwrap();
sqlx::query("UPDATE audit_log SET actor_pubkey = 'tampered' WHERE seq = ?")
sqlx::query("UPDATE audit_log SET actor_pubkey = 'tampered' WHERE seq = $1")
.bind(e2.seq)
.execute(&pool)
.await
@@ -388,7 +375,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn auth_events_rejected() {
let Some(pool) = test_pool().await else {
return;

View File

@@ -308,7 +308,7 @@ pub fn event_kind_u32(event: &nostr::Event) -> u32 {
event.kind.as_u16() as u32
}
/// Extract the kind from a nostr Event as i32 (for MySQL INT columns).
/// Extract the kind from a nostr Event as i32 (for Postgres INT columns).
/// Safe: all Sprout kinds fit in i32 (max 65535 < i32::MAX).
pub fn event_kind_i32(event: &nostr::Event) -> i32 {
event.kind.as_u16() as i32

View File

@@ -5,7 +5,7 @@ edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "MySQL event store and data access layer for Sprout"
description = "Postgres event store and data access layer for Sprout"
[dependencies]
sprout-core = { workspace = true }

View File

@@ -1,7 +1,7 @@
//! API token CRUD operations.
use chrono::{DateTime, Utc};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::error::{DbError, Result};
@@ -10,7 +10,7 @@ use crate::event::uuid_from_bytes;
/// Create a new API token record. The caller is responsible for generating
/// the raw token and computing its SHA-256 hash.
pub async fn create_api_token(
pool: &MySqlPool,
pool: &PgPool,
token_hash: &[u8],
owner_pubkey: &[u8],
name: &str,
@@ -35,7 +35,7 @@ pub async fn create_api_token(
sqlx::query(
r#"
INSERT INTO api_tokens (id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4, $5, $6, $7)
"#,
)
.bind(id_bytes)
@@ -53,12 +53,12 @@ pub async fn create_api_token(
/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
///
/// Uses a `SELECT ... WHERE COUNT < 10` subquery so the check and insert are atomic —
/// Uses a subquery so the check and insert are atomic --
/// no TOCTOU race between a separate count query and the insert.
///
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded.
pub async fn create_api_token_if_under_limit(
pool: &MySqlPool,
pool: &PgPool,
token_hash: &[u8],
owner_pubkey: &[u8],
name: &str,
@@ -80,17 +80,16 @@ pub async fn create_api_token_if_under_limit(
.transpose()?;
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10.
// The subquery and insert execute atomically — no separate count + insert race.
// The subquery and insert execute atomically -- no separate count + insert race.
let result = sqlx::query(
r#"
INSERT INTO api_tokens
(id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
SELECT ?, ?, ?, ?, ?, ?, ?, TRUE
FROM DUAL
SELECT $1, $2, $3, $4, $5, $6, $7, TRUE
WHERE (
SELECT COUNT(*)
FROM api_tokens
WHERE owner_pubkey = ?
WHERE owner_pubkey = $8
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
) < 10
@@ -108,7 +107,7 @@ pub async fn create_api_token_if_under_limit(
.await?;
if result.rows_affected() == 0 {
// Limit exceeded — the WHERE clause prevented the INSERT.
// Limit exceeded -- the WHERE clause prevented the INSERT.
return Ok(None);
}
@@ -122,7 +121,7 @@ pub async fn create_api_token_if_under_limit(
/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
/// error responses rather than treating both as "not found".
pub async fn get_api_token_by_hash_including_revoked(
pool: &MySqlPool,
pool: &PgPool,
hash: &[u8],
) -> Result<Option<crate::ApiTokenRecord>> {
let row = sqlx::query(
@@ -130,7 +129,7 @@ pub async fn get_api_token_by_hash_including_revoked(
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at
FROM api_tokens
WHERE token_hash = ?
WHERE token_hash = $1
"#,
)
.bind(hash)
@@ -180,11 +179,11 @@ pub async fn get_api_token_by_hash_including_revoked(
/// List all tokens (including revoked) for a pubkey, ordered by creation time descending.
///
/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
/// responsible for stripping `token_hash` before returning data to clients — the
/// responsible for stripping `token_hash` before returning data to clients -- the
/// raw token value is never exposed after the initial mint response.
/// Used by `GET /api/tokens` to show a user their full token history.
pub async fn list_tokens_by_owner(
pool: &MySqlPool,
pool: &PgPool,
pubkey: &[u8],
) -> Result<Vec<crate::ApiTokenRecord>> {
let rows = sqlx::query(
@@ -192,7 +191,7 @@ pub async fn list_tokens_by_owner(
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
created_at, expires_at, last_used_at, revoked_at
FROM api_tokens
WHERE owner_pubkey = ?
WHERE owner_pubkey = $1
ORDER BY created_at DESC
"#,
)
@@ -247,7 +246,7 @@ pub async fn list_tokens_by_owner(
/// Only revokes if the token is owned by `owner_pubkey` and not already revoked.
/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
pub async fn revoke_token(
pool: &MySqlPool,
pool: &PgPool,
id: Uuid,
owner_pubkey: &[u8],
revoked_by: &[u8],
@@ -256,9 +255,9 @@ pub async fn revoke_token(
let result = sqlx::query(
r#"
UPDATE api_tokens
SET revoked_at = NOW(6), revoked_by = ?
WHERE id = ?
AND owner_pubkey = ?
SET revoked_at = NOW(), revoked_by = $1
WHERE id = $2
AND owner_pubkey = $3
AND revoked_at IS NULL
"#,
)
@@ -276,15 +275,15 @@ pub async fn revoke_token(
/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
/// If all tokens are already revoked, returns 0 with no error.
pub async fn revoke_all_tokens(
pool: &MySqlPool,
pool: &PgPool,
owner_pubkey: &[u8],
revoked_by: &[u8],
) -> Result<u64> {
let result = sqlx::query(
r#"
UPDATE api_tokens
SET revoked_at = NOW(6), revoked_by = ?
WHERE owner_pubkey = ?
SET revoked_at = NOW(), revoked_by = $1
WHERE owner_pubkey = $2
AND revoked_at IS NULL
"#,
)

View File

@@ -5,7 +5,7 @@
//! - `private`: hidden, invite-only
use chrono::{DateTime, Utc};
use sqlx::{MySql, MySqlPool, Row, Transaction};
use sqlx::{PgPool, Postgres, Row, Transaction};
use uuid::Uuid;
use crate::error::{DbError, Result};
@@ -199,7 +199,7 @@ pub struct MemberRecord {
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
pub async fn create_channel(
pool: &MySqlPool,
pool: &PgPool,
name: &str,
channel_type: ChannelType,
visibility: ChannelVisibility,
@@ -221,7 +221,7 @@ pub async fn create_channel(
sqlx::query(
r#"
INSERT INTO channels (id, name, channel_type, visibility, description, created_by)
VALUES (?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4, $5, $6)
"#,
)
.bind(&id_bytes)
@@ -236,11 +236,11 @@ pub async fn create_channel(
sqlx::query(
r#"
INSERT INTO channel_members (channel_id, pubkey, role, invited_by)
VALUES (?, ?, 'owner', ?)
ON DUPLICATE KEY UPDATE
VALUES ($1, $2, 'owner', $3)
ON CONFLICT (channel_id, pubkey) DO UPDATE SET
removed_at = NULL,
removed_by = NULL,
role = VALUES(role)
role = EXCLUDED.role
"#,
)
.bind(&id_bytes)
@@ -256,7 +256,7 @@ pub async fn create_channel(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels WHERE id = ?
FROM channels WHERE id = $1
"#,
)
.bind(&id_bytes)
@@ -269,7 +269,7 @@ pub async fn create_channel(
}
/// Fetches a channel record by ID. Returns `ChannelNotFound` if missing or deleted.
pub async fn get_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<ChannelRecord> {
pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecord> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
@@ -279,7 +279,7 @@ pub async fn get_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<ChannelRe
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels WHERE id = ? AND deleted_at IS NULL
FROM channels WHERE id = $1 AND deleted_at IS NULL
"#,
)
.bind(&id_bytes)
@@ -291,9 +291,9 @@ pub async fn get_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<ChannelRe
}
/// Returns the canvas content for a channel, if any.
pub async fn get_canvas(pool: &MySqlPool, channel_id: Uuid) -> Result<Option<String>> {
pub async fn get_canvas(pool: &PgPool, channel_id: Uuid) -> Result<Option<String>> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query("SELECT canvas FROM channels WHERE id = ? AND deleted_at IS NULL")
let row = sqlx::query("SELECT canvas FROM channels WHERE id = $1 AND deleted_at IS NULL")
.bind(&id_bytes)
.fetch_optional(pool)
.await?
@@ -302,9 +302,9 @@ pub async fn get_canvas(pool: &MySqlPool, channel_id: Uuid) -> Result<Option<Str
}
/// Sets or clears the canvas content for a channel.
pub async fn set_canvas(pool: &MySqlPool, channel_id: Uuid, canvas: Option<&str>) -> Result<()> {
pub async fn set_canvas(pool: &PgPool, channel_id: Uuid, canvas: Option<&str>) -> Result<()> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let rows = sqlx::query("UPDATE channels SET canvas = ? WHERE id = ? AND deleted_at IS NULL")
let rows = sqlx::query("UPDATE channels SET canvas = $1 WHERE id = $2 AND deleted_at IS NULL")
.bind(canvas)
.bind(&id_bytes)
.execute(pool)
@@ -327,7 +327,7 @@ pub async fn set_canvas(pool: &MySqlPool, channel_id: Uuid, canvas: Option<&str>
/// The entire check-then-insert sequence runs inside a transaction to prevent TOCTOU
/// races (e.g. the inviter being removed between the role check and the INSERT).
pub async fn add_member(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
pubkey: &[u8],
role: MemberRole,
@@ -404,11 +404,11 @@ pub async fn add_member(
sqlx::query(
r#"
INSERT INTO channel_members (channel_id, pubkey, role, invited_by)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
VALUES ($1, $2, $3, $4)
ON CONFLICT (channel_id, pubkey) DO UPDATE SET
removed_at = NULL,
removed_by = NULL,
role = VALUES(role)
role = EXCLUDED.role
"#,
)
.bind(&channel_id_bytes)
@@ -421,7 +421,7 @@ pub async fn add_member(
let row = sqlx::query(
r#"
SELECT channel_id, pubkey, role, joined_at, invited_by, removed_at
FROM channel_members WHERE channel_id = ? AND pubkey = ?
FROM channel_members WHERE channel_id = $1 AND pubkey = $2
"#,
)
.bind(&channel_id_bytes)
@@ -442,7 +442,7 @@ pub async fn add_member(
/// The authorization check and the UPDATE run inside a transaction to prevent a
/// TOCTOU race where the actor's role changes between the check and the update.
pub async fn remove_member(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
pubkey: &[u8],
actor_pubkey: &[u8],
@@ -473,7 +473,7 @@ pub async fn remove_member(
if target_role.as_deref() == Some("owner") {
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM channel_members \
WHERE channel_id = ? AND role = 'owner' AND removed_at IS NULL",
WHERE channel_id = $1 AND role = 'owner' AND removed_at IS NULL",
)
.bind(&channel_id_bytes)
.fetch_one(&mut *tx)
@@ -489,8 +489,8 @@ pub async fn remove_member(
let result = sqlx::query(
r#"
UPDATE channel_members
SET removed_at = NOW(), removed_by = ?
WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL
SET removed_at = NOW(), removed_by = $1
WHERE channel_id = $2 AND pubkey = $3 AND removed_at IS NULL
"#,
)
.bind(actor_pubkey)
@@ -508,12 +508,12 @@ pub async fn remove_member(
}
/// Returns `true` if the given pubkey is an active member of the channel.
pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Result<bool> {
pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<bool> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM channel_members cm \
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL",
)
.bind(&channel_id_bytes)
.bind(pubkey)
@@ -526,14 +526,14 @@ pub async fn is_member(pool: &MySqlPool, channel_id: Uuid, pubkey: &[u8]) -> Res
/// Returns all active members of the given channel.
///
/// Returns an empty list if the channel has been soft-deleted.
pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<MemberRecord>> {
pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result<Vec<MemberRecord>> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let rows = sqlx::query(
r#"
SELECT cm.channel_id, cm.pubkey, cm.role, cm.joined_at, cm.invited_by, cm.removed_at
FROM channel_members cm
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.channel_id = ? AND cm.removed_at IS NULL
WHERE cm.channel_id = $1 AND cm.removed_at IS NULL
ORDER BY cm.joined_at ASC
LIMIT 1000
"#,
@@ -548,13 +548,13 @@ pub async fn get_members(pool: &MySqlPool, channel_id: Uuid) -> Result<Vec<Membe
///
/// Includes channels where the pubkey is an active member AND all open channels.
/// Open channels must be included in REQ filter resolution.
pub async fn get_accessible_channel_ids(pool: &MySqlPool, pubkey: &[u8]) -> Result<Vec<Uuid>> {
pub async fn get_accessible_channel_ids(pool: &PgPool, pubkey: &[u8]) -> Result<Vec<Uuid>> {
let rows = sqlx::query(
r#"
SELECT cm.channel_id
FROM channel_members cm
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
WHERE cm.pubkey = ? AND cm.removed_at IS NULL
WHERE cm.pubkey = $1 AND cm.removed_at IS NULL
UNION
SELECT id AS channel_id
FROM channels
@@ -575,10 +575,7 @@ pub async fn get_accessible_channel_ids(pool: &MySqlPool, pubkey: &[u8]) -> Resu
}
/// Lists channels, optionally filtered by visibility string.
pub async fn list_channels(
pool: &MySqlPool,
visibility: Option<&str>,
) -> Result<Vec<ChannelRecord>> {
pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Vec<ChannelRecord>> {
let rows = if let Some(vis) = visibility {
sqlx::query(
r#"
@@ -588,7 +585,7 @@ pub async fn list_channels(
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels
WHERE deleted_at IS NULL AND visibility = ?
WHERE deleted_at IS NULL AND visibility = $1
ORDER BY created_at DESC
LIMIT 1000
"#,
@@ -619,14 +616,14 @@ pub async fn list_channels(
/// Transaction-aware variant of [`get_active_role_tx`].
async fn get_active_role_tx(
tx: &mut Transaction<'_, MySql>,
tx: &mut Transaction<'_, Postgres>,
channel_id: Uuid,
pubkey: &[u8],
) -> Result<Option<String>> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT role FROM channel_members \
WHERE channel_id = ? AND pubkey = ? AND removed_at IS NULL",
WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL",
)
.bind(&channel_id_bytes)
.bind(pubkey)
@@ -637,7 +634,7 @@ async fn get_active_role_tx(
/// Transaction-aware variant of [`get_channel`].
async fn get_channel_tx(
tx: &mut Transaction<'_, MySql>,
tx: &mut Transaction<'_, Postgres>,
channel_id: Uuid,
) -> Result<ChannelRecord> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
@@ -648,7 +645,7 @@ async fn get_channel_tx(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels WHERE id = ? AND deleted_at IS NULL
FROM channels WHERE id = $1 AND deleted_at IS NULL
"#,
)
.bind(&id_bytes)
@@ -669,7 +666,7 @@ pub struct BotMemberRecord {
pub agent_type: Option<String>,
/// Optional JSON capabilities descriptor.
pub capabilities: Option<serde_json::Value>,
/// Comma-separated channel names (from GROUP_CONCAT).
/// Comma-separated channel names (from string_agg).
pub channel_names: String,
}
@@ -699,12 +696,12 @@ pub struct AccessibleChannel {
/// open channels (visible to everyone) plus channels where the user is an active member.
///
/// Uses DISTINCT + LEFT JOIN so a user who is a member of an open channel does not
/// see it twice. Results are ordered stream → forum → dm, then alphabetically by name.
/// see it twice. Results are ordered stream -> forum -> dm, then alphabetically by name.
///
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with
/// that visibility value are returned. `None` returns all accessible channels.
pub async fn get_accessible_channels(
pool: &MySqlPool,
pool: &PgPool,
pubkey: &[u8],
visibility_filter: Option<&str>,
member_only: Option<bool>,
@@ -729,16 +726,16 @@ pub async fn get_accessible_channels(
(cm.channel_id IS NOT NULL) AS is_member
FROM channels c
LEFT JOIN channel_members cm
ON c.id = cm.channel_id AND cm.pubkey = ? AND cm.removed_at IS NULL
ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL
WHERE c.deleted_at IS NULL
{membership_clause}
"#
);
let sql = if visibility_filter.is_some() {
format!("{base} AND c.visibility = ?\n ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
format!("{base} AND c.visibility = $2\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000")
} else {
format!("{base} ORDER BY FIELD(c.channel_type, 'stream', 'forum', 'dm'), c.name\n LIMIT 1000")
format!("{base} ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000")
};
let query = sqlx::query(&sql).bind(pubkey);
@@ -751,7 +748,7 @@ pub async fn get_accessible_channels(
let rows = query.fetch_all(pool).await?;
rows.into_iter()
.map(|row| {
let is_member: bool = row.try_get::<i8, _>("is_member").unwrap_or(0) != 0;
let is_member: bool = row.try_get("is_member").unwrap_or(false);
let channel = row_to_channel_record(row)?;
Ok(AccessibleChannel { channel, is_member })
})
@@ -760,13 +757,13 @@ pub async fn get_accessible_channels(
/// Returns all bot-role members with their aggregated channel names.
///
/// Channel names are returned as a comma-separated string from GROUP_CONCAT.
/// Channel names are returned as a comma-separated string from string_agg.
/// Members with no active channel memberships are excluded (INNER JOIN on channels).
pub async fn get_bot_members(pool: &MySqlPool) -> Result<Vec<BotMemberRecord>> {
pub async fn get_bot_members(pool: &PgPool) -> Result<Vec<BotMemberRecord>> {
let rows = sqlx::query(
r#"
SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities,
GROUP_CONCAT(DISTINCT c.name ORDER BY c.name SEPARATOR ',') AS channel_names
string_agg(DISTINCT c.name, ',' ORDER BY c.name) AS channel_names
FROM channel_members cm
LEFT JOIN users u ON cm.pubkey = u.pubkey
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL
@@ -799,18 +796,18 @@ pub async fn get_bot_members(pool: &MySqlPool) -> Result<Vec<BotMemberRecord>> {
/// Returns only users that exist in the `users` table. Ordering matches input order
/// is NOT guaranteed — callers should index by pubkey if order matters.
/// Returns an empty vec immediately when `pubkeys` is empty (no query issued).
pub async fn get_users_bulk(pool: &MySqlPool, pubkeys: &[Vec<u8>]) -> Result<Vec<UserRecord>> {
pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec<u8>]) -> Result<Vec<UserRecord>> {
if pubkeys.is_empty() {
return Ok(Vec::new());
}
// Build a parameterised IN clause: (?, ?, ...)
// Safety: placeholders are "?" markers only — all values are bound via
// `.bind()` below. No user input is interpolated into the SQL string.
let placeholders = pubkeys.iter().map(|_| "?").collect::<Vec<_>>().join(", ");
let sql = format!(
"SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})"
);
// Build a parameterised IN clause: ($1, $2, ...)
let placeholders = (1..=pubkeys.len())
.map(|i| format!("${i}"))
.collect::<Vec<_>>()
.join(", ");
let sql =
format!("SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE pubkey IN ({placeholders})");
let mut q = sqlx::query(&sql);
for pk in pubkeys {
@@ -831,7 +828,7 @@ pub async fn get_users_bulk(pool: &MySqlPool, pubkeys: &[Vec<u8>]) -> Result<Vec
Ok(out)
}
fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
let topic_required: bool = row.try_get("topic_required")?;
@@ -869,7 +866,7 @@ fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
})
}
fn row_to_member_record(row: sqlx::mysql::MySqlRow) -> Result<MemberRecord> {
fn row_to_member_record(row: sqlx::postgres::PgRow) -> Result<MemberRecord> {
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
let channel_id = uuid_from_bytes(&channel_id_bytes)?;
@@ -898,7 +895,7 @@ pub struct ChannelUpdate {
/// At least one field must be `Some`; returns `InvalidData` otherwise.
/// Returns the updated `ChannelRecord` on success.
pub async fn update_channel(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
updates: ChannelUpdate,
) -> Result<ChannelRecord> {
@@ -911,15 +908,19 @@ pub async fn update_channel(
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
// Build SET clause dynamically — only include fields that are Some.
let mut set_parts: Vec<&str> = Vec::new();
// Track parameter index for positional placeholders.
let mut set_parts: Vec<String> = Vec::new();
let mut param_idx: usize = 1;
if updates.name.is_some() {
set_parts.push("name = ?");
set_parts.push(format!("name = ${param_idx}"));
param_idx += 1;
}
if updates.description.is_some() {
set_parts.push("description = ?");
set_parts.push(format!("description = ${param_idx}"));
param_idx += 1;
}
let sql = format!(
"UPDATE channels SET {}, updated_at = NOW(6) WHERE id = ? AND deleted_at IS NULL",
"UPDATE channels SET {}, updated_at = NOW() WHERE id = ${param_idx} AND deleted_at IS NULL",
set_parts.join(", ")
);
@@ -941,16 +942,11 @@ pub async fn update_channel(
}
/// Sets the topic for a channel, recording who set it and when.
pub async fn set_topic(
pool: &MySqlPool,
channel_id: Uuid,
topic: &str,
set_by: &[u8],
) -> Result<()> {
pub async fn set_topic(pool: &PgPool, channel_id: Uuid, topic: &str, set_by: &[u8]) -> Result<()> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let result = sqlx::query(
"UPDATE channels SET topic = ?, topic_set_by = ?, topic_set_at = NOW(6) \
WHERE id = ? AND deleted_at IS NULL",
"UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \
WHERE id = $3 AND deleted_at IS NULL",
)
.bind(topic)
.bind(set_by)
@@ -965,15 +961,15 @@ pub async fn set_topic(
/// Sets the purpose for a channel, recording who set it and when.
pub async fn set_purpose(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
purpose: &str,
set_by: &[u8],
) -> Result<()> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let result = sqlx::query(
"UPDATE channels SET purpose = ?, purpose_set_by = ?, purpose_set_at = NOW(6) \
WHERE id = ? AND deleted_at IS NULL",
"UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \
WHERE id = $3 AND deleted_at IS NULL",
)
.bind(purpose)
.bind(set_by)
@@ -990,11 +986,11 @@ pub async fn set_purpose(
///
/// Returns `AccessDenied` if the channel is already archived.
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
pub async fn archive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
// First check: does the channel exist and what is its state?
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = ? AND deleted_at IS NULL")
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = $1 AND deleted_at IS NULL")
.bind(&id_bytes)
.fetch_optional(pool)
.await?;
@@ -1012,8 +1008,8 @@ pub async fn archive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
}
sqlx::query(
"UPDATE channels SET archived_at = NOW(6) \
WHERE id = ? AND deleted_at IS NULL AND archived_at IS NULL",
"UPDATE channels SET archived_at = NOW() \
WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NULL",
)
.bind(&id_bytes)
.execute(pool)
@@ -1026,11 +1022,11 @@ pub async fn archive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
///
/// Returns `AccessDenied` if the channel is not currently archived.
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()> {
pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
// First check: does the channel exist and what is its state?
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = ? AND deleted_at IS NULL")
let row = sqlx::query("SELECT archived_at FROM channels WHERE id = $1 AND deleted_at IS NULL")
.bind(&id_bytes)
.fetch_optional(pool)
.await?;
@@ -1047,7 +1043,7 @@ pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()>
sqlx::query(
"UPDATE channels SET archived_at = NULL \
WHERE id = ? AND deleted_at IS NULL AND archived_at IS NOT NULL",
WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NOT NULL",
)
.bind(&id_bytes)
.execute(pool)
@@ -1056,14 +1052,14 @@ pub async fn unarchive_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<()>
Ok(())
}
/// Soft-delete a channel by setting `deleted_at = NOW(6)`.
/// Soft-delete a channel by setting `deleted_at = NOW()`.
///
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
/// deleted or not found.
pub async fn soft_delete_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<bool> {
pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result<bool> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let result =
sqlx::query("UPDATE channels SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
.bind(&id_bytes)
.execute(pool)
.await?;
@@ -1072,10 +1068,10 @@ pub async fn soft_delete_channel(pool: &MySqlPool, channel_id: Uuid) -> Result<b
}
/// Returns the count of active (non-removed) members in a channel.
pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64> {
pub async fn get_member_count(pool: &PgPool, channel_id: Uuid) -> Result<i64> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT COUNT(*) as cnt FROM channel_members WHERE channel_id = ? AND removed_at IS NULL",
"SELECT COUNT(*) as cnt FROM channel_members WHERE channel_id = $1 AND removed_at IS NULL",
)
.bind(&id_bytes)
.fetch_one(pool)
@@ -1085,10 +1081,10 @@ pub async fn get_member_count(pool: &MySqlPool, channel_id: Uuid) -> Result<i64>
/// Bulk-fetch member counts for a set of channel IDs.
///
/// Returns a map of `channel_id → count`. Channels with zero members are omitted.
/// Returns a map of `channel_id -> count`. Channels with zero members are omitted.
/// Single query regardless of input size.
pub async fn get_member_counts_bulk(
pool: &MySqlPool,
pool: &PgPool,
channel_ids: &[Uuid],
) -> Result<std::collections::HashMap<Uuid, i64>> {
use crate::event::uuid_from_bytes;
@@ -1097,7 +1093,7 @@ pub async fn get_member_counts_bulk(
return Ok(std::collections::HashMap::new());
}
let mut qb: sqlx::QueryBuilder<sqlx::MySql> = sqlx::QueryBuilder::new(
let mut qb: sqlx::QueryBuilder<sqlx::Postgres> = sqlx::QueryBuilder::new(
"SELECT channel_id, COUNT(*) as cnt FROM channel_members \
WHERE removed_at IS NULL AND channel_id IN (",
);
@@ -1123,7 +1119,7 @@ pub async fn get_member_counts_bulk(
///
/// Returns `None` if the pubkey is not an active member.
pub async fn get_member_role(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
pubkey: &[u8],
) -> Result<Option<String>> {
@@ -1131,7 +1127,7 @@ pub async fn get_member_role(
let row = sqlx::query(
"SELECT cm.role FROM channel_members cm \
JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \
WHERE cm.channel_id = ? AND cm.pubkey = ? AND cm.removed_at IS NULL",
WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL",
)
.bind(&channel_id_bytes)
.bind(pubkey)

View File

@@ -1,18 +1,18 @@
//! Direct message channel persistence.
//!
//! DMs are channels with channel_type='dm' and visibility='private'.
//! Participant sets are immutable — adding a member creates a NEW DM.
//! Participant sets are immutable -- adding a member creates a NEW DM.
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::channel::ChannelRecord;
use crate::error::{DbError, Result};
use crate::event::uuid_from_bytes;
// ── Public structs ────────────────────────────────────────────────────────────
// -- Public structs -----------------------------------------------------------
/// A DM conversation with its participant list.
#[derive(Debug, Clone)]
@@ -38,7 +38,7 @@ pub struct DmParticipant {
pub role: String,
}
// ── Pure helpers ──────────────────────────────────────────────────────────────
// -- Pure helpers -------------------------------------------------------------
/// Compute a stable SHA-256 fingerprint for a set of participant pubkeys.
///
@@ -57,13 +57,13 @@ pub fn compute_participant_hash(pubkeys: &[&[u8]]) -> [u8; 32] {
hasher.finalize().into()
}
// ── DB functions ──────────────────────────────────────────────────────────────
// -- DB functions -------------------------------------------------------------
/// Find an existing DM by its participant hash.
///
/// Returns `None` if no matching DM exists or if it has been deleted.
pub async fn find_dm_by_participants(
pool: &MySqlPool,
pool: &PgPool,
participant_hash: &[u8],
) -> Result<Option<ChannelRecord>> {
let row = sqlx::query(
@@ -74,7 +74,7 @@ pub async fn find_dm_by_participants(
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels
WHERE participant_hash = ?
WHERE participant_hash = $1
AND channel_type = 'dm'
AND deleted_at IS NULL
LIMIT 1
@@ -91,11 +91,11 @@ pub async fn find_dm_by_participants(
/// existing one if a DM with the same participant set already exists.
///
/// Rules:
/// - `participants` must contain 2–9 entries (enforced here).
/// - `participants` must contain 2-9 entries (enforced here).
/// - `created_by` must be one of the participants.
/// - The operation is idempotent: same participant set → same channel returned.
/// - The operation is idempotent: same participant set -> same channel returned.
pub async fn create_dm(
pool: &MySqlPool,
pool: &PgPool,
participants: &[&[u8]],
created_by: &[u8],
) -> Result<ChannelRecord> {
@@ -131,7 +131,7 @@ pub async fn create_dm(
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels
WHERE participant_hash = ?
WHERE participant_hash = $1
AND channel_type = 'dm'
AND deleted_at IS NULL
LIMIT 1
@@ -160,7 +160,7 @@ pub async fn create_dm(
r#"
INSERT INTO channels
(id, name, channel_type, visibility, created_by, participant_hash)
VALUES (?, ?, 'dm', 'private', ?, ?)
VALUES ($1, $2, 'dm', 'private', $3, $4)
"#,
)
.bind(&id_bytes)
@@ -175,11 +175,11 @@ pub async fn create_dm(
sqlx::query(
r#"
INSERT INTO channel_members (channel_id, pubkey, role, invited_by)
VALUES (?, ?, 'member', ?)
ON DUPLICATE KEY UPDATE
VALUES ($1, $2, 'member', $3)
ON CONFLICT (channel_id, pubkey) DO UPDATE SET
removed_at = NULL,
removed_by = NULL,
role = VALUES(role)
role = EXCLUDED.role
"#,
)
.bind(&id_bytes)
@@ -196,7 +196,7 @@ pub async fn create_dm(
nip29_group_id, topic_required, max_members,
topic, topic_set_by, topic_set_at,
purpose, purpose_set_by, purpose_set_at
FROM channels WHERE id = ?
FROM channels WHERE id = $1
"#,
)
.bind(&id_bytes)
@@ -213,7 +213,7 @@ pub async fn create_dm(
/// Includes participant details for each DM. Supports cursor-based pagination
/// using `updated_at` ordering.
pub async fn list_dms_for_user(
pool: &MySqlPool,
pool: &PgPool,
pubkey: &[u8],
limit: u32,
cursor: Option<Uuid>,
@@ -223,7 +223,7 @@ pub async fn list_dms_for_user(
// Resolve cursor to a timestamp for keyset pagination.
let cursor_ts: Option<DateTime<Utc>> = if let Some(cid) = cursor {
let cid_bytes = cid.as_bytes().as_slice().to_vec();
let row = sqlx::query("SELECT updated_at FROM channels WHERE id = ?")
let row = sqlx::query("SELECT updated_at FROM channels WHERE id = $1")
.bind(&cid_bytes)
.fetch_optional(pool)
.await?;
@@ -241,13 +241,13 @@ pub async fn list_dms_for_user(
FROM channels c
JOIN channel_members cm
ON c.id = cm.channel_id
AND cm.pubkey = ?
AND cm.pubkey = $1
AND cm.removed_at IS NULL
WHERE c.channel_type = 'dm'
AND c.deleted_at IS NULL
AND c.updated_at < ?
AND c.updated_at < $2
ORDER BY c.updated_at DESC
LIMIT ?
LIMIT $3
"#,
)
.bind(pubkey)
@@ -262,12 +262,12 @@ pub async fn list_dms_for_user(
FROM channels c
JOIN channel_members cm
ON c.id = cm.channel_id
AND cm.pubkey = ?
AND cm.pubkey = $1
AND cm.removed_at IS NULL
WHERE c.channel_type = 'dm'
AND c.deleted_at IS NULL
ORDER BY c.updated_at DESC
LIMIT ?
LIMIT $2
"#,
)
.bind(pubkey)
@@ -290,7 +290,7 @@ pub async fn list_dms_for_user(
SELECT cm.pubkey, cm.role, u.display_name
FROM channel_members cm
LEFT JOIN users u ON cm.pubkey = u.pubkey
WHERE cm.channel_id = ?
WHERE cm.channel_id = $1
AND cm.removed_at IS NULL
ORDER BY cm.joined_at ASC
"#,
@@ -327,10 +327,10 @@ pub async fn list_dms_for_user(
/// ensuring the caller is always a participant in their own DM.
///
/// Returns `(channel, was_created)`:
/// - `was_created = true` — a new DM was created.
/// - `was_created = false` — an existing DM was returned.
/// - `was_created = true` -- a new DM was created.
/// - `was_created = false` -- an existing DM was returned.
pub async fn open_dm(
pool: &MySqlPool,
pool: &PgPool,
pubkeys: &[&[u8]],
created_by: &[u8],
) -> Result<(ChannelRecord, bool)> {
@@ -357,21 +357,12 @@ pub async fn open_dm(
// Create new DM.
let channel = create_dm(pool, &all, created_by).await?;
// Determine if we actually created it by checking the created_at/updated_at delta.
// A simpler approach: re-check the hash. If created_at == updated_at it's brand new.
// But the most reliable signal is whether find_dm returned None above.
// Since create_dm is idempotent (returns existing if race occurred), we check
// whether the channel was just created by comparing created_at ≈ now.
// For simplicity, we return true here — the caller treats it as "just created".
// In the race case (two concurrent open_dm calls), one will get true and one false
// (the second call's create_dm returns the existing record, but we already checked
// above and got None). This is an acceptable edge case for idempotent DM creation.
Ok((channel, true))
}
// ── Row mapping ───────────────────────────────────────────────────────────────
// -- Row mapping --------------------------------------------------------------
fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
let topic_required: bool = row.try_get("topic_required")?;
@@ -400,7 +391,7 @@ fn row_to_channel_record(row: sqlx::mysql::MySqlRow) -> Result<ChannelRecord> {
})
}
// ── Tests ─────────────────────────────────────────────────────────────────────
// -- Tests --------------------------------------------------------------------
#[cfg(test)]
mod tests {

View File

@@ -9,10 +9,6 @@ pub enum DbError {
#[error("database error: {0}")]
Sqlx(#[from] sqlx::Error),
/// A SQLx migration error.
#[error("migration error: {0}")]
Migrate(#[from] sqlx::migrate::MigrateError),
/// Attempted to store an AUTH event (kind 22242), which is forbidden.
#[error("AUTH events (kind 22242) must not be stored")]
AuthEventRejected,

View File

@@ -2,11 +2,11 @@
//!
//! AUTH events (kind 22242) are never stored — they carry bearer tokens.
//! Ephemeral events (kinds 20000–29999) are never stored — Redis pub/sub only.
//! Deduplication is application-layer: INSERT IGNORE.
//! Deduplication is application-layer: ON CONFLICT DO NOTHING.
use chrono::{DateTime, Utc};
use nostr::Event;
use sqlx::{MySqlPool, QueryBuilder, Row};
use sqlx::{PgPool, QueryBuilder, Row};
use uuid::Uuid;
use sprout_core::kind::{event_kind_i32, is_ephemeral, KIND_AUTH};
@@ -19,7 +19,7 @@ use crate::error::{DbError, Result};
pub struct EventQuery {
/// Restrict results to this channel.
pub channel_id: Option<Uuid>,
/// Restrict results to these kind values (stored as `i32` in MySQL).
/// Restrict results to these kind values (stored as `i32` in Postgres).
pub kinds: Option<Vec<i32>>,
/// Restrict results to events from this pubkey.
pub pubkey: Option<Vec<u8>>,
@@ -40,7 +40,7 @@ pub struct EventQuery {
///
/// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate.
pub async fn insert_event(
pool: &MySqlPool,
pool: &PgPool,
event: &Event,
channel_id: Option<Uuid>,
) -> Result<(StoredEvent, bool)> {
@@ -58,7 +58,7 @@ pub async fn insert_event(
let pubkey_bytes = event.pubkey.to_bytes();
let sig_bytes = event.sig.serialize();
let tags_json = serde_json::to_value(&event.tags)?;
// Cast chain: nostr Kind (u16) → i32 (MySQL INT column). Safe: all Sprout kinds fit in i32.
// Cast chain: nostr Kind (u16) → i32 (Postgres INT column). Safe: all Sprout kinds fit in i32.
let kind_i32 = event_kind_i32(event);
let created_at_secs = event.created_at.as_u64() as i64;
let created_at = DateTime::from_timestamp(created_at_secs, 0)
@@ -68,8 +68,9 @@ pub async fn insert_event(
let result = sqlx::query(
r#"
INSERT IGNORE INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
"#,
)
.bind(id_bytes.as_slice())
@@ -96,7 +97,7 @@ pub async fn insert_event(
///
/// Uses `QueryBuilder` for dynamic filter composition — avoids string concatenation
/// while keeping all user values in bind parameters.
pub async fn query_events(pool: &MySqlPool, q: &EventQuery) -> Result<Vec<StoredEvent>> {
pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEvent>> {
// kinds:[] means "match no kinds" — return empty immediately.
if q.kinds.as_deref().is_some_and(|k| k.is_empty()) {
return Ok(vec![]);
@@ -105,7 +106,7 @@ pub async fn query_events(pool: &MySqlPool, q: &EventQuery) -> Result<Vec<Stored
let limit_val = q.limit.unwrap_or(100).min(1000);
let offset_val = q.offset.unwrap_or(0);
let mut qb: QueryBuilder<sqlx::MySql> = if let Some(ref p_hex) = q.p_tag_hex {
let mut qb: QueryBuilder<sqlx::Postgres> = if let Some(ref p_hex) = q.p_tag_hex {
// Join against event_mentions for #p-filtered queries (indexed).
let mut b = QueryBuilder::new(
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, \
@@ -168,7 +169,7 @@ pub async fn query_events(pool: &MySqlPool, q: &EventQuery) -> Result<Vec<Stored
Ok(out)
}
pub(crate) fn row_to_stored_event(row: sqlx::mysql::MySqlRow) -> Result<Option<StoredEvent>> {
pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result<Option<StoredEvent>> {
let id_bytes: Vec<u8> = row.try_get("id")?;
let pubkey_bytes: Vec<u8> = row.try_get("pubkey")?;
let created_at: DateTime<Utc> = row.try_get("created_at")?;
@@ -181,7 +182,7 @@ pub(crate) fn row_to_stored_event(row: sqlx::mysql::MySqlRow) -> Result<Option<S
let channel_id_bytes: Option<Vec<u8>> = row.try_get("channel_id")?;
let channel_id: Option<Uuid> = channel_id_bytes.map(|b| uuid_from_bytes(&b)).transpose()?;
// kind is stored as i32 (MySQL INT) but Nostr uses u16. Values > 65535 are corrupt.
// kind is stored as i32 (Postgres INT) but Nostr uses u16. Values > 65535 are corrupt.
let kind_u16 = u16::try_from(kind_i32)
.map_err(|_| DbError::InvalidData(format!("kind out of u16 range: {kind_i32}")))?;
@@ -212,14 +213,14 @@ pub(crate) fn row_to_stored_event(row: sqlx::mysql::MySqlRow) -> Result<Option<S
)))
}
/// Soft-delete an event by setting `deleted_at = NOW(6)`.
/// Soft-delete an event by setting `deleted_at = NOW()`.
///
/// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted
/// or not found. Callers are responsible for decrementing thread reply counts
/// when the deleted event is a thread reply.
pub async fn soft_delete_event(pool: &MySqlPool, event_id: &[u8]) -> Result<bool> {
pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result<bool> {
let result =
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
.bind(event_id)
.execute(pool)
.await?;
@@ -233,7 +234,7 @@ pub async fn soft_delete_event(pool: &MySqlPool, event_id: &[u8]) -> Result<bool
/// them cannot leave counters permanently inflated. Returns `Ok(true)` if the
/// event was deleted this call.
pub async fn soft_delete_event_and_update_thread(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
parent_event_id: Option<&[u8]>,
root_event_id: Option<&[u8]>,
@@ -241,7 +242,7 @@ pub async fn soft_delete_event_and_update_thread(
let mut tx = pool.begin().await?;
let result =
sqlx::query("UPDATE events SET deleted_at = NOW(6) WHERE id = ? AND deleted_at IS NULL")
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL")
.bind(event_id)
.execute(&mut *tx)
.await?;
@@ -253,7 +254,7 @@ pub async fn soft_delete_event_and_update_thread(
sqlx::query(
"UPDATE thread_metadata \
SET reply_count = GREATEST(reply_count - 1, 0) \
WHERE event_id = ?",
WHERE event_id = $1",
)
.bind(pid)
.execute(&mut *tx)
@@ -263,7 +264,7 @@ pub async fn soft_delete_event_and_update_thread(
sqlx::query(
"UPDATE thread_metadata \
SET descendant_count = GREATEST(descendant_count - 1, 0) \
WHERE event_id = ?",
WHERE event_id = $1",
)
.bind(root_id)
.execute(&mut *tx)
@@ -278,13 +279,13 @@ pub async fn soft_delete_event_and_update_thread(
/// Returns the `created_at` timestamp of the most recent non-deleted event in a channel.
pub async fn get_last_message_at(
pool: &MySqlPool,
pool: &PgPool,
channel_id: uuid::Uuid,
) -> Result<Option<DateTime<Utc>>> {
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
let row = sqlx::query(
"SELECT created_at FROM events \
WHERE channel_id = ? AND deleted_at IS NULL \
WHERE channel_id = $1 AND deleted_at IS NULL \
ORDER BY created_at DESC LIMIT 1",
)
.bind(&id_bytes)
@@ -302,14 +303,14 @@ pub async fn get_last_message_at(
/// Returns a map of `channel_id → last_message_at`. Channels with no events are omitted.
/// Single query regardless of input size.
pub async fn get_last_message_at_bulk(
pool: &MySqlPool,
pool: &PgPool,
channel_ids: &[uuid::Uuid],
) -> Result<std::collections::HashMap<uuid::Uuid, DateTime<Utc>>> {
if channel_ids.is_empty() {
return Ok(std::collections::HashMap::new());
}
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT channel_id, MAX(created_at) as last_at FROM events \
WHERE deleted_at IS NULL AND channel_id IN (",
);
@@ -336,10 +337,10 @@ pub async fn get_last_message_at_bulk(
/// Returns `None` if the event does not exist or has been soft-deleted.
/// Use [`get_event_by_id_including_deleted`] when you need to inspect
/// tombstoned rows (e.g. audit, undelete).
pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
pub async fn get_event_by_id(pool: &PgPool, id_bytes: &[u8]) -> Result<Option<StoredEvent>> {
let row = sqlx::query(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
FROM events WHERE id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1",
)
.bind(id_bytes)
.fetch_optional(pool)
@@ -357,12 +358,12 @@ pub async fn get_event_by_id(pool: &MySqlPool, id_bytes: &[u8]) -> Result<Option
/// when the caller must distinguish "never existed" from "was deleted" (e.g.
/// audit trails, compliance queries).
pub async fn get_event_by_id_including_deleted(
pool: &MySqlPool,
pool: &PgPool,
id_bytes: &[u8],
) -> Result<Option<StoredEvent>> {
let row = sqlx::query(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE id = ? ORDER BY created_at DESC LIMIT 1",
FROM events WHERE id = $1 ORDER BY created_at DESC LIMIT 1",
)
.bind(id_bytes)
.fetch_optional(pool)
@@ -378,13 +379,13 @@ pub async fn get_event_by_id_including_deleted(
///
/// Returns events in arbitrary order — callers reorder as needed.
/// Uses a single `WHERE id IN (...)` query regardless of input size.
pub async fn get_events_by_ids(pool: &MySqlPool, ids: &[&[u8]]) -> Result<Vec<StoredEvent>> {
pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result<Vec<StoredEvent>> {
if ids.is_empty() {
return Ok(vec![]);
}
debug_assert!(ids.len() <= 500, "batch fetch should be bounded by caller");
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE deleted_at IS NULL AND id IN (",
);
@@ -437,7 +438,7 @@ pub struct ThreadMetadataParams<'a> {
///
/// Returns `(StoredEvent, was_inserted)`.
pub async fn insert_event_with_thread_metadata(
pool: &MySqlPool,
pool: &PgPool,
event: &Event,
channel_id: Option<Uuid>,
thread_meta: Option<ThreadMetadataParams<'_>>,
@@ -468,8 +469,9 @@ pub async fn insert_event_with_thread_metadata(
// ── Insert event ──────────────────────────────────────────────────────────
let result = sqlx::query(
r#"
INSERT IGNORE INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
"#,
)
.bind(id_bytes.as_slice())
@@ -490,16 +492,17 @@ pub async fn insert_event_with_thread_metadata(
if was_inserted {
if let Some(ref meta) = thread_meta {
let ch_bytes = meta.channel_id.as_bytes().as_slice().to_vec();
let broadcast_val: i8 = if meta.broadcast { 1 } else { 0 };
let broadcast_val: bool = meta.broadcast;
let tm_result = sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
"#,
)
.bind(meta.event_created_at)
@@ -525,12 +528,13 @@ pub async fn insert_event_with_thread_metadata(
.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false)
ON CONFLICT DO NOTHING
"#,
)
.bind(parent_ts)
@@ -546,12 +550,13 @@ pub async fn insert_event_with_thread_metadata(
meta.root_event_created_at.unwrap_or(meta.event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false)
ON CONFLICT DO NOTHING
"#,
)
.bind(root_ts)
@@ -565,8 +570,8 @@ pub async fn insert_event_with_thread_metadata(
sqlx::query(
r#"
UPDATE thread_metadata
SET reply_count = reply_count + 1, last_reply_at = NOW(6)
WHERE event_id = ?
SET reply_count = reply_count + 1, last_reply_at = NOW()
WHERE event_id = $1
"#,
)
.bind(pid)
@@ -578,7 +583,7 @@ pub async fn insert_event_with_thread_metadata(
r#"
UPDATE thread_metadata
SET descendant_count = descendant_count + 1
WHERE event_id = ?
WHERE event_id = $1
"#,
)
.bind(root_id)

View File

@@ -10,13 +10,13 @@
//! `query_mentions` and `query_needs_action` join against the `event_mentions` table,
//! which carries composite indexes on `(pubkey_hex, event_created_at DESC)` and
//! `(pubkey_hex, event_kind, event_created_at DESC)`. This replaces the Phase 1
//! `JSON_CONTAINS` full-table scan with an indexed lookup, keeping feed queries
//! full-table scan with an indexed lookup, keeping feed queries
//! sub-millisecond at scale (>100k events).
//!
//! **Phase 2 implemented**: the `event_mentions` table is populated by
//! [`crate::insert_mentions`] on every event insert. `query_mentions` and
//! `query_needs_action` now use `INNER JOIN event_mentions` instead of
//! `JSON_CONTAINS`.
//! scanning tags JSON.
//!
//! All feed queries enforce a hard `LIMIT` cap of `FEED_MAX_LIMIT` rows to bound
//! the result-set size and prevent runaway memory usage.
@@ -28,7 +28,7 @@
pub const FEED_MAX_LIMIT: i64 = 100;
use chrono::{DateTime, Utc};
use sqlx::{MySqlPool, QueryBuilder};
use sqlx::{PgPool, QueryBuilder};
use uuid::Uuid;
use sprout_core::kind::{
@@ -43,13 +43,13 @@ use crate::event::row_to_stored_event;
/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags).
///
/// Joins against the `event_mentions` table — Phase 2 implementation.
/// Joins against the `event_mentions` table -- Phase 2 implementation.
/// **Performance**: indexed lookup on `(pubkey_hex, event_created_at DESC)`.
///
/// Only returns events from `accessible_channel_ids` for access control.
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
pub async fn query_mentions(
pool: &MySqlPool,
pool: &PgPool,
pubkey_bytes: &[u8],
accessible_channel_ids: &[Uuid],
since: Option<DateTime<Utc>>,
@@ -58,7 +58,7 @@ pub async fn query_mentions(
let limit = limit.min(FEED_MAX_LIMIT);
let pubkey_hex = hex::encode(pubkey_bytes);
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \
e.received_at, e.channel_id \
FROM events e \
@@ -108,7 +108,7 @@ pub async fn query_mentions(
/// `(pubkey_hex, event_kind, event_created_at DESC)`.
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
pub async fn query_needs_action(
pool: &MySqlPool,
pool: &PgPool,
pubkey_bytes: &[u8],
accessible_channel_ids: &[Uuid],
since: Option<DateTime<Utc>>,
@@ -117,7 +117,7 @@ pub async fn query_needs_action(
let limit = limit.min(FEED_MAX_LIMIT);
let pubkey_hex = hex::encode(pubkey_bytes);
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \
e.received_at, e.channel_id \
FROM events e \
@@ -161,16 +161,16 @@ pub async fn query_needs_action(
///
/// Returns stream messages, forum posts, and agent job events.
/// Workflow execution kinds (46001-46012) are intentionally excluded to avoid noise.
/// **Performance**: uses indexed `kind` + `channel_id` columns — no JSON scan.
/// **Performance**: uses indexed `kind` + `channel_id` columns -- no JSON scan.
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
pub async fn query_activity(
pool: &MySqlPool,
pool: &PgPool,
accessible_channel_ids: &[Uuid],
since: Option<DateTime<Utc>>,
limit: i64,
) -> Result<Vec<StoredEvent>> {
let limit = limit.min(FEED_MAX_LIMIT);
let mut qb: QueryBuilder<sqlx::MySql> = QueryBuilder::new(
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(
"SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \
FROM events WHERE 1=1",
);
@@ -206,20 +206,19 @@ pub async fn query_activity(
Ok(out)
}
// ── Tests ─────────────────────────────────────────────────────────────────────
// -- Tests --------------------------------------------------------------------
#[cfg(test)]
mod tests {
use uuid::Uuid;
// ── Hex encoding of pubkey ────────────────────────────────────────────────
// -- Hex encoding of pubkey -----------------------------------------------
#[test]
fn pubkey_hex_encoding_is_lowercase() {
let pubkey_bytes = vec![0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45];
let hex = hex::encode(&pubkey_bytes);
assert_eq!(hex, "abcdef012345");
// Must be lowercase — MySQL JSON_CONTAINS is case-sensitive.
assert_eq!(hex, hex.to_lowercase());
}
@@ -246,13 +245,10 @@ mod tests {
assert_eq!(hex, "f".repeat(64));
}
// ── JSON tag format for JSON_CONTAINS ────────────────────────────────────
// -- JSON tag format for tag matching -------------------------------------
#[test]
fn json_tag_format_for_p_tag_mention() {
// The JSON_CONTAINS query uses serde_json::json!([["p", pubkey_hex]]).to_string()
// The outer array wraps the sub-array so MySQL checks for exact element membership,
// not element-wise containment across the whole tags array.
let pubkey_hex = "abc123def456".to_owned();
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
assert_eq!(tag_json, r#"[["p","abc123def456"]]"#);
@@ -260,7 +256,6 @@ mod tests {
#[test]
fn json_tag_format_is_compact_not_pretty() {
// Must be compact JSON — no spaces — for MySQL JSON_CONTAINS.
let pubkey_hex = "deadbeef".to_owned();
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
assert!(
@@ -273,8 +268,6 @@ mod tests {
fn json_tag_format_p_tag_is_first_element() {
let pubkey_hex = "aabbccdd".to_owned();
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
// The outer array wraps the inner ["p", ...] sub-array.
// Must start with [["p" — outer array containing p-tag sub-array.
assert!(tag_json.starts_with(r#"[["p","#), "got: {tag_json}");
}
@@ -282,7 +275,6 @@ mod tests {
fn json_tag_format_round_trips_through_serde() {
let pubkey_hex = "cafebabe00112233".to_owned();
let tag_json = serde_json::json!([["p", pubkey_hex.clone()]]).to_string();
// Parse back and verify structure: outer array with one inner array element.
let parsed: serde_json::Value = serde_json::from_str(&tag_json).unwrap();
let outer = parsed.as_array().unwrap();
assert_eq!(outer.len(), 1, "outer array must have exactly one element");
@@ -292,7 +284,7 @@ mod tests {
assert_eq!(inner[1].as_str().unwrap(), pubkey_hex);
}
// ── Kind number sets ──────────────────────────────────────────────────────
// -- Kind number sets -----------------------------------------------------
#[test]
fn mentions_query_includes_stream_message_kind() {
@@ -382,8 +374,6 @@ mod tests {
KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT,
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2,
};
// Workflow execution events (46001–46012) must NOT appear in activity feed
// to prevent loops. Verify they are absent from the activity kind set.
let activity_kinds: &[u32] = &[
KIND_STREAM_MESSAGE,
KIND_STREAM_MESSAGE_V2,
@@ -422,21 +412,19 @@ mod tests {
for kind in needs_action_kinds {
assert!(
!activity_kinds.contains(kind),
"kind {kind} appears in both needs_action and activity — check intent"
"kind {kind} appears in both needs_action and activity -- check intent"
);
}
}
// ── Channel ID filtering logic ────────────────────────────────────────────
// -- Channel ID filtering logic -------------------------------------------
#[test]
fn channel_id_bytes_encoding_is_correct() {
// Channel IDs are stored as BINARY(16) — UUID bytes, not hex strings.
let channel_id = Uuid::parse_str("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50").unwrap();
let bytes = channel_id.as_bytes().to_vec();
assert_eq!(bytes.len(), 16);
// Round-trip: bytes → UUID → bytes must be identical.
let recovered = Uuid::from_slice(&bytes).unwrap();
assert_eq!(channel_id, recovered);
}
@@ -449,7 +437,6 @@ mod tests {
let bytes1 = id1.as_bytes().to_vec();
let bytes2 = id2.as_bytes().to_vec();
// Different UUIDs must produce different byte sequences.
assert_ne!(bytes1, bytes2);
}
@@ -462,8 +449,6 @@ mod tests {
#[test]
fn empty_channel_list_skips_channel_filter() {
// When accessible_channel_ids is empty, the IN clause is omitted.
// The query builder only adds "AND channel_id IN (...)" when !accessible.is_empty().
let accessible: Vec<Uuid> = vec![];
assert!(
accessible.is_empty(),
@@ -485,7 +470,6 @@ mod tests {
let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
assert_eq!(ids.len(), 5);
// Each must produce a unique 16-byte sequence.
let byte_seqs: Vec<Vec<u8>> = ids.iter().map(|id| id.as_bytes().to_vec()).collect();
let unique: std::collections::HashSet<Vec<u8>> = byte_seqs.into_iter().collect();
assert_eq!(unique.len(), 5, "all channel IDs must be distinct");

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
//! Call `ensure_future_partitions` on startup and monthly via cron.
use chrono::{Datelike, TimeZone, Utc};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use tracing::info;
use crate::error::{DbError, Result};
@@ -12,7 +12,7 @@ use crate::error::{DbError, Result};
const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"];
/// Ensures monthly partition tables exist for the next `months_ahead` months.
pub async fn ensure_future_partitions(pool: &MySqlPool, months_ahead: u32) -> Result<()> {
pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Result<()> {
let now = Utc::now();
for i in 0..=(months_ahead as i32) {
@@ -29,6 +29,13 @@ pub async fn ensure_future_partitions(pool: &MySqlPool, months_ahead: u32) -> Re
} else {
(target_year, target_month + 1)
};
let start = Utc
.with_ymd_and_hms(target_year, target_month, 1, 0, 0, 0)
.single()
.ok_or_else(|| {
DbError::InvalidData(format!("invalid date: {target_year}-{target_month:02}-01"))
})?;
let end = Utc
.with_ymd_and_hms(end_year, end_month, 1, 0, 0, 0)
.single()
@@ -37,11 +44,11 @@ pub async fn ensure_future_partitions(pool: &MySqlPool, months_ahead: u32) -> Re
})?;
let suffix = format!("{:04}_{:02}", target_year, target_month);
let start_str = start.format("%Y-%m-%d").to_string();
let end_str = end.format("%Y-%m-%d").to_string();
let partition_name = format!("p{}", suffix);
for table in PARTITIONED_TABLES {
ensure_partition(pool, table, &partition_name, &end_str, &suffix).await?;
ensure_partition(pool, table, &start_str, &end_str, &suffix).await?;
}
}
@@ -65,13 +72,13 @@ fn validate_date_str(s: &str) -> bool {
}
async fn ensure_partition(
pool: &MySqlPool,
pool: &PgPool,
table_name: &str,
partition_name: &str,
start_date_str: &str,
end_date_str: &str,
suffix: &str,
) -> Result<()> {
// Allowlist check — parameterized queries cannot be used for DDL identifiers.
// Allowlist check -- parameterized queries cannot be used for DDL identifiers.
if !PARTITIONED_TABLES.contains(&table_name) {
return Err(DbError::InvalidData(format!(
"table not in partition allowlist: {table_name:?}"
@@ -82,23 +89,30 @@ async fn ensure_partition(
"partition suffix contains invalid characters: {suffix:?}"
)));
}
if !validate_date_str(start_date_str) {
return Err(DbError::InvalidData(format!(
"start_date_str is not YYYY-MM-DD: {start_date_str:?}"
)));
}
if !validate_date_str(end_date_str) {
return Err(DbError::InvalidData(format!(
"end_date_str is not YYYY-MM-DD: {end_date_str:?}"
)));
}
let partition_name = format!("{table_name}_{suffix}");
let row = sqlx::query(
r#"
SELECT COUNT(*) as cnt
FROM information_schema.PARTITIONS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND PARTITION_NAME = ?
FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = current_schema()
AND c.relname = $1
AND c.relispartition = true
"#,
)
.bind(table_name)
.bind(partition_name)
.bind(&partition_name)
.fetch_one(pool)
.await?;
@@ -107,14 +121,14 @@ async fn ensure_partition(
return Ok(());
}
// DDL identifiers cannot be parameterized in MySQL — all inputs are validated above.
// DDL identifiers cannot be parameterized -- all inputs are validated above.
let sql = format!(
"ALTER TABLE {table_name} ADD PARTITION \
(PARTITION {partition_name} VALUES LESS THAN (TO_DAYS('{end_date_str}')))"
"CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name} \
FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')"
);
sqlx::query(&sql).execute(pool).await?;
info!("added partition {table_name}_{suffix}");
info!("added partition {partition_name}");
Ok(())
}

View File

@@ -3,11 +3,11 @@
//! One reaction per user per emoji per event. Soft-delete via removed_at.
use chrono::{DateTime, Utc};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use crate::error::Result;
// ── Public structs ────────────────────────────────────────────────────────────
// -- Public structs -----------------------------------------------------------
/// A grouped set of reactions for a single emoji on an event.
#[derive(Debug, Clone)]
@@ -56,21 +56,17 @@ pub struct ActiveReactionRecord {
pub reaction_event_id: Option<Vec<u8>>,
}
// ── Write operations ──────────────────────────────────────────────────────────
// -- Write operations ---------------------------------------------------------
/// Add (or re-activate) a reaction.
///
/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if
/// the reaction is already active (duplicate, no change made).
///
/// Uses `INSERT ... ON DUPLICATE KEY UPDATE` to eliminate the TOCTOU race where
/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where
/// two concurrent adds both see no existing row and then race to INSERT.
/// MySQL rows_affected semantics (CLIENT_FOUND_ROWS off):
/// 1 = new row inserted → added
/// 2 = existing row updated (reactivated from soft-delete) → re-added
/// 0 = duplicate key matched but no values changed (already active) → no-op
pub async fn add_reaction(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
@@ -80,15 +76,11 @@ pub async fn add_reaction(
let result = sqlx::query(
r#"
INSERT INTO reactions (event_created_at, event_id, pubkey, emoji, reaction_event_id)
VALUES (?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
created_at = IF(removed_at IS NOT NULL, NOW(6), created_at),
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (event_created_at, event_id, pubkey, emoji) DO UPDATE SET
created_at = CASE WHEN reactions.removed_at IS NOT NULL THEN NOW() ELSE reactions.created_at END,
removed_at = NULL,
-- Keep existing non-NULL event ID if the new value is NULL.
-- REST calls add_reaction(None) first, then backfills via
-- set_reaction_event_id. Without COALESCE, a re-add would
-- clobber the existing event ID with NULL.
reaction_event_id = COALESCE(VALUES(reaction_event_id), reaction_event_id)
reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id)
"#,
)
.bind(event_created_at)
@@ -108,7 +100,7 @@ pub async fn add_reaction(
///
/// Returns `true` if a row was updated, `false` if not found or already removed.
pub async fn remove_reaction(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
@@ -117,11 +109,11 @@ pub async fn remove_reaction(
let result = sqlx::query(
r#"
UPDATE reactions
SET removed_at = NOW(6)
WHERE event_created_at = ?
AND event_id = ?
AND pubkey = ?
AND emoji = ?
SET removed_at = NOW()
WHERE event_created_at = $1
AND event_id = $2
AND pubkey = $3
AND emoji = $4
AND removed_at IS NULL
"#,
)
@@ -139,14 +131,14 @@ pub async fn remove_reaction(
///
/// Returns `true` if a row was updated, `false` if not found or already removed.
pub async fn remove_reaction_by_source_event_id(
pool: &MySqlPool,
pool: &PgPool,
reaction_event_id: &[u8],
) -> Result<bool> {
let result = sqlx::query(
r#"
UPDATE reactions
SET removed_at = NOW(6)
WHERE reaction_event_id = ?
SET removed_at = NOW()
WHERE reaction_event_id = $1
AND removed_at IS NULL
"#,
)
@@ -159,7 +151,7 @@ pub async fn remove_reaction_by_source_event_id(
/// Look up the active reaction row for one actor + emoji + target tuple.
pub async fn get_active_reaction_record(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
@@ -169,10 +161,10 @@ pub async fn get_active_reaction_record(
r#"
SELECT reaction_event_id
FROM reactions
WHERE event_id = ?
AND event_created_at = ?
AND pubkey = ?
AND emoji = ?
WHERE event_id = $1
AND event_created_at = $2
AND pubkey = $3
AND emoji = $4
AND removed_at IS NULL
LIMIT 1
"#,
@@ -197,7 +189,7 @@ pub async fn get_active_reaction_record(
/// Called after the kind:7 event is created and stored, to link the
/// reaction row to its source event. Returns `true` if the row was updated.
pub async fn set_reaction_event_id(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
pubkey: &[u8],
@@ -207,11 +199,11 @@ pub async fn set_reaction_event_id(
let result = sqlx::query(
r#"
UPDATE reactions
SET reaction_event_id = ?
WHERE event_created_at = ?
AND event_id = ?
AND pubkey = ?
AND emoji = ?
SET reaction_event_id = $1
WHERE event_created_at = $2
AND event_id = $3
AND pubkey = $4
AND emoji = $5
AND removed_at IS NULL
"#,
)
@@ -226,45 +218,39 @@ pub async fn set_reaction_event_id(
Ok(result.rows_affected() > 0)
}
// ── Read operations ───────────────────────────────────────────────────────────
// -- Read operations ----------------------------------------------------------
/// Get all active reactions for an event, grouped by emoji.
///
/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting
/// user pubkeys. Display names are NOT resolved here — callers should enrich via
/// user pubkeys. Display names are NOT resolved here -- callers should enrich via
/// `get_users_bulk` if needed.
///
/// `cursor` is reserved for future keyset pagination (currently unused).
pub async fn get_reactions(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
limit: u32,
_cursor: Option<&str>,
) -> Result<Vec<ReactionGroup>> {
// Raise the GROUP_CONCAT length limit for this session. The default (1024 bytes)
// truncates at ~15 users with 64-char hex pubkeys. 1 MiB handles any realistic load.
sqlx::query("SET SESSION group_concat_max_len = 1048576")
.execute(pool)
.await?;
let rows = sqlx::query(
r#"
SELECT emoji,
COUNT(*) AS count,
GROUP_CONCAT(HEX(pubkey) ORDER BY created_at SEPARATOR ',') AS pubkeys_hex
string_agg(encode(pubkey, 'hex'), ',' ORDER BY created_at) AS pubkeys_hex
FROM reactions
WHERE event_id = ?
AND event_created_at = ?
WHERE event_id = $1
AND event_created_at = $2
AND removed_at IS NULL
GROUP BY emoji
ORDER BY emoji
LIMIT ?
LIMIT $3
"#,
)
.bind(event_id)
.bind(event_created_at)
.bind(limit)
.bind(limit as i64)
.fetch_all(pool)
.await?;
@@ -273,8 +259,6 @@ pub async fn get_reactions(
for row in rows {
let emoji: String = row.try_get("emoji")?;
let count: i64 = row.try_get("count")?;
// GROUP_CONCAT(HEX(pubkey)) returns comma-separated hex strings.
// Using HEX avoids corruption from 0x2C bytes inside binary pubkeys.
let pubkeys_hex: Option<String> = row.try_get("pubkeys_hex")?;
let users = parse_pubkeys_hex(pubkeys_hex.as_deref().unwrap_or(""));
@@ -294,16 +278,16 @@ pub async fn get_reactions(
/// Returns one [`BulkReactionEntry`] per input pair that has at least one
/// active reaction. Pairs with no reactions are omitted.
pub async fn get_reactions_bulk(
pool: &MySqlPool,
pool: &PgPool,
event_ids: &[(&[u8], DateTime<Utc>)],
) -> Result<Vec<BulkReactionEntry>> {
if event_ids.is_empty() {
return Ok(Vec::new());
}
// Run one query per event. For typical message-list sizes (≤100 events)
// Run one query per event. For typical message-list sizes (<=100 events)
// this is acceptable; a single-query approach with dynamic IN clauses over
// composite keys is complex in MySQL and can be added later if needed.
// composite keys can be added later if needed.
let mut entries = Vec::new();
for (event_id, event_created_at) in event_ids {
@@ -311,8 +295,8 @@ pub async fn get_reactions_bulk(
r#"
SELECT emoji, COUNT(*) AS count
FROM reactions
WHERE event_id = ?
AND event_created_at = ?
WHERE event_id = $1
AND event_created_at = $2
AND removed_at IS NULL
GROUP BY emoji
ORDER BY emoji
@@ -344,13 +328,12 @@ pub async fn get_reactions_bulk(
Ok(entries)
}
// ── Helpers ───────────────────────────────────────────────────────────────────
// -- Helpers ------------------------------------------------------------------
/// Parse a `GROUP_CONCAT(HEX(pubkey))` string into individual pubkeys.
/// Parse a `string_agg(encode(pubkey, 'hex'))` string into individual pubkeys.
///
/// MySQL's `HEX()` encodes each byte as two uppercase hex characters, so a
/// 32-byte pubkey becomes a 64-character hex string. The comma separator is
/// safe because hex output never contains 0x2C bytes.
/// Postgres `encode(bytea, 'hex')` produces lowercase hex characters, so a
/// 32-byte pubkey becomes a 64-character hex string.
fn parse_pubkeys_hex(hex_str: &str) -> Vec<ReactionUser> {
if hex_str.is_empty() {
return Vec::new();

View File

@@ -5,13 +5,13 @@
//! ingested and updated as replies arrive or are deleted.
use chrono::{DateTime, Utc};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::error::Result;
use crate::event::uuid_from_bytes;
// ── Structs ───────────────────────────────────────────────────────────────────
// -- Structs ------------------------------------------------------------------
/// A single reply within a thread, joined with event content.
#[derive(Debug, Clone)]
@@ -74,7 +74,7 @@ pub struct TopLevelMessage {
pub thread_summary: Option<ThreadSummary>,
}
/// Raw thread_metadata row — used when processing deletes or computing ancestry.
/// Raw thread_metadata row -- used when processing deletes or computing ancestry.
#[derive(Debug, Clone)]
pub struct ThreadMetadataRecord {
/// The Nostr event ID this metadata row tracks.
@@ -97,7 +97,7 @@ pub struct ThreadMetadataRecord {
pub broadcast: bool,
}
// ── Write operations ──────────────────────────────────────────────────────────
// -- Write operations ---------------------------------------------------------
/// Insert a row into `thread_metadata`.
///
@@ -109,7 +109,7 @@ pub struct ThreadMetadataRecord {
/// with the actual number of reply rows (F9).
#[allow(clippy::too_many_arguments)]
pub async fn insert_thread_metadata(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
event_created_at: DateTime<Utc>,
channel_id: Uuid,
@@ -121,18 +121,18 @@ pub async fn insert_thread_metadata(
broadcast: bool,
) -> Result<()> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let broadcast_val: i8 = if broadcast { 1 } else { 0 };
let mut tx = pool.begin().await?;
let result = sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING
"#,
)
.bind(event_created_at)
@@ -143,26 +143,27 @@ pub async fn insert_thread_metadata(
.bind(root_event_id)
.bind(root_event_created_at)
.bind(depth)
.bind(broadcast_val)
.bind(broadcast)
.execute(&mut *tx)
.await?;
// Only bump reply counts if the row was actually inserted (not a duplicate).
// INSERT IGNORE on a duplicate key returns rows_affected = 0.
// ON CONFLICT DO NOTHING on a duplicate key returns rows_affected = 0.
if result.rows_affected() > 0 {
if let Some(pid) = parent_event_id {
// Ensure the parent has a thread_metadata row so the UPDATE below
// has something to hit. Root (depth=0) messages don't get a row on
// first insert, so we create a stub here with INSERT IGNORE.
// first insert, so we create a stub here.
let parent_ts = parent_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false)
ON CONFLICT DO NOTHING
"#,
)
.bind(parent_ts)
@@ -177,12 +178,13 @@ pub async fn insert_thread_metadata(
let root_ts = root_event_created_at.unwrap_or(event_created_at);
sqlx::query(
r#"
INSERT IGNORE INTO thread_metadata
INSERT INTO thread_metadata
(event_created_at, event_id, channel_id,
parent_event_id, parent_event_created_at,
root_event_id, root_event_created_at,
depth, broadcast)
VALUES (?, ?, ?, NULL, NULL, NULL, NULL, 0, 0)
VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false)
ON CONFLICT DO NOTHING
"#,
)
.bind(root_ts)
@@ -198,8 +200,8 @@ pub async fn insert_thread_metadata(
r#"
UPDATE thread_metadata
SET reply_count = reply_count + 1,
last_reply_at = NOW(6)
WHERE event_id = ?
last_reply_at = NOW()
WHERE event_id = $1
"#,
)
.bind(pid)
@@ -212,7 +214,7 @@ pub async fn insert_thread_metadata(
r#"
UPDATE thread_metadata
SET descendant_count = descendant_count + 1
WHERE event_id = ?
WHERE event_id = $1
"#,
)
.bind(root_id)
@@ -229,7 +231,7 @@ pub async fn insert_thread_metadata(
/// Increment `reply_count` (and `last_reply_at`) on the parent event.
/// If `root_event_id` is provided, also increments `descendant_count` on the
/// root — even when root == parent (direct reply to root). This is correct
/// root -- even when root == parent (direct reply to root). This is correct
/// because `reply_count` tracks direct children only, while `descendant_count`
/// tracks ALL descendants at every nesting level.
///
@@ -238,7 +240,7 @@ pub async fn insert_thread_metadata(
/// incrementing outside of insert is needed (e.g., event re-parenting).
#[allow(dead_code)]
pub async fn increment_reply_count(
pool: &MySqlPool,
pool: &PgPool,
parent_event_id: &[u8],
root_event_id: Option<&[u8]>,
) -> Result<()> {
@@ -247,8 +249,8 @@ pub async fn increment_reply_count(
r#"
UPDATE thread_metadata
SET reply_count = reply_count + 1,
last_reply_at = NOW(6)
WHERE event_id = ?
last_reply_at = NOW()
WHERE event_id = $1
"#,
)
.bind(parent_event_id)
@@ -256,14 +258,12 @@ pub async fn increment_reply_count(
.await?;
// Always bump root's descendant_count, regardless of whether root == parent.
// - Direct reply (root == parent): root row gets reply_count+1 AND descendant_count+1.
// - Nested reply (root != parent): parent gets reply_count+1, root gets descendant_count+1.
if let Some(root_id) = root_event_id {
sqlx::query(
r#"
UPDATE thread_metadata
SET descendant_count = descendant_count + 1
WHERE event_id = ?
WHERE event_id = $1
"#,
)
.bind(root_id)
@@ -276,9 +276,9 @@ pub async fn increment_reply_count(
/// Decrement `reply_count` on the parent event (floor at 0).
/// If `root_event_id` is provided, also decrements `descendant_count` on the
/// root — even when root == parent. Mirrors the increment logic exactly.
/// root -- even when root == parent. Mirrors the increment logic exactly.
pub async fn decrement_reply_count(
pool: &MySqlPool,
pool: &PgPool,
parent_event_id: &[u8],
root_event_id: Option<&[u8]>,
) -> Result<()> {
@@ -287,7 +287,7 @@ pub async fn decrement_reply_count(
r#"
UPDATE thread_metadata
SET reply_count = GREATEST(reply_count - 1, 0)
WHERE event_id = ?
WHERE event_id = $1
"#,
)
.bind(parent_event_id)
@@ -300,7 +300,7 @@ pub async fn decrement_reply_count(
r#"
UPDATE thread_metadata
SET descendant_count = GREATEST(descendant_count - 1, 0)
WHERE event_id = ?
WHERE event_id = $1
"#,
)
.bind(root_id)
@@ -311,27 +311,23 @@ pub async fn decrement_reply_count(
Ok(())
}
// ── Read operations ───────────────────────────────────────────────────────────
// -- Read operations ----------------------------------------------------------
/// Fetch all replies under a root event, ordered chronologically.
///
/// - `depth_limit` — if `Some(n)`, only returns replies at depth ≤ n.
/// - `cursor` — if `Some(ts_bytes)`, returns replies with `event_created_at`
/// - `depth_limit` -- if `Some(n)`, only returns replies at depth <= n.
/// - `cursor` -- if `Some(ts_bytes)`, returns replies with `event_created_at`
/// strictly after the timestamp encoded in `ts_bytes`. The bytes must be an
/// 8-byte big-endian i64 Unix timestamp in seconds. The caller (REST handler)
/// encodes the last reply's `created_at` as the next-page cursor.
/// Binary event IDs do NOT correlate with chronological order, so the old
/// `event_id > cursor` condition produced non-deterministic pagination (F8).
/// - `limit` — maximum rows returned (caller should cap this).
/// 8-byte big-endian i64 Unix timestamp in seconds.
/// - `limit` -- maximum rows returned (caller should cap this).
pub async fn get_thread_replies(
pool: &MySqlPool,
pool: &PgPool,
root_event_id: &[u8],
depth_limit: Option<u32>,
limit: u32,
cursor: Option<&[u8]>,
) -> Result<Vec<ThreadReply>> {
// Decode cursor bytes → DateTime<Utc> for the keyset condition.
// Bytes are an 8-byte big-endian i64 Unix timestamp (seconds).
// Decode cursor bytes -> DateTime<Utc> for the keyset condition.
let cursor_ts: Option<DateTime<Utc>> = match cursor {
Some(bytes) if bytes.len() == 8 => {
let secs = i64::from_be_bytes(bytes.try_into().expect("length checked"));
@@ -341,6 +337,8 @@ pub async fn get_thread_replies(
};
// Build the query dynamically based on optional filters.
// Track the next positional parameter index.
let mut param_idx = 2u32; // $1 is root_event_id
let mut sql = String::from(
r#"
SELECT
@@ -359,29 +357,33 @@ pub async fn get_thread_replies(
JOIN events e
ON e.created_at = tm.event_created_at
AND e.id = tm.event_id
WHERE tm.root_event_id = ?
WHERE tm.root_event_id = $1
AND e.deleted_at IS NULL
"#,
);
if depth_limit.is_some() {
sql.push_str(" AND tm.depth <= ?");
sql.push_str(&format!(" AND tm.depth <= ${param_idx}"));
param_idx += 1;
}
if cursor_ts.is_some() {
sql.push_str(" AND tm.event_created_at > ?");
sql.push_str(&format!(" AND tm.event_created_at > ${param_idx}"));
param_idx += 1;
}
sql.push_str(" ORDER BY tm.event_created_at ASC LIMIT ?");
sql.push_str(&format!(
" ORDER BY tm.event_created_at ASC LIMIT ${param_idx}"
));
let mut q = sqlx::query(&sql).bind(root_event_id);
if let Some(dl) = depth_limit {
q = q.bind(dl);
q = q.bind(dl as i32);
}
if let Some(ts) = cursor_ts {
q = q.bind(ts);
}
q = q.bind(limit);
q = q.bind(limit as i32);
let rows = q.fetch_all(pool).await?;
@@ -397,7 +399,7 @@ pub async fn get_thread_replies(
let kind: i32 = row.try_get("kind")?;
let depth: i32 = row.try_get("depth")?;
let created_at: DateTime<Utc> = row.try_get("event_created_at")?;
let broadcast_val: i8 = row.try_get("broadcast")?;
let broadcast_val: bool = row.try_get("broadcast")?;
let channel_id = uuid_from_bytes(&channel_id_bytes)?;
@@ -412,7 +414,7 @@ pub async fn get_thread_replies(
kind,
depth,
created_at,
broadcast: broadcast_val != 0,
broadcast: broadcast_val,
});
}
@@ -420,15 +422,12 @@ pub async fn get_thread_replies(
}
/// Fetch aggregated thread stats for a single event, plus up to 10 participant pubkeys.
pub async fn get_thread_summary(
pool: &MySqlPool,
event_id: &[u8],
) -> Result<Option<ThreadSummary>> {
pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result<Option<ThreadSummary>> {
let row = sqlx::query(
r#"
SELECT reply_count, descendant_count, last_reply_at
FROM thread_metadata
WHERE event_id = ?
WHERE event_id = $1
LIMIT 1
"#,
)
@@ -445,9 +444,7 @@ pub async fn get_thread_summary(
let descendant_count: i32 = row.try_get("descendant_count")?;
let last_reply_at: Option<DateTime<Utc>> = row.try_get("last_reply_at")?;
// Collect distinct participant pubkeys from the thread, most recent first (M1).
// Without ORDER BY the result is non-deterministic across MySQL restarts/replicas.
// Wrapping in a subquery lets us ORDER BY after DISTINCT.
// Collect distinct participant pubkeys from the thread, most recent first.
let participant_rows = sqlx::query(
r#"
SELECT pubkey FROM (
@@ -456,7 +453,7 @@ pub async fn get_thread_summary(
JOIN events e
ON e.created_at = tm.event_created_at
AND e.id = tm.event_id
WHERE tm.root_event_id = ?
WHERE tm.root_event_id = $1
AND e.deleted_at IS NULL
GROUP BY e.pubkey
) sub
@@ -486,13 +483,13 @@ pub async fn get_thread_summary(
/// Returns events that are either:
/// - Not in thread_metadata at all (no thread context set yet), OR
/// - At depth 0 (root messages), OR
/// - At depth 1 with `broadcast = 1` (replies surfaced to the channel)
/// - At depth 1 with `broadcast = true` (replies surfaced to the channel)
///
/// Results are ordered newest-first for a standard channel view.
/// `before_cursor` enables keyset pagination (pass the `created_at` of the
/// last item from the previous page).
pub async fn get_channel_messages_top_level(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
limit: u32,
before_cursor: Option<DateTime<Utc>>,
@@ -500,6 +497,7 @@ pub async fn get_channel_messages_top_level(
) -> Result<Vec<TopLevelMessage>> {
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
let mut param_idx = 2u32; // $1 is channel_id
let mut sql = String::from(
r#"
SELECT
@@ -514,18 +512,19 @@ pub async fn get_channel_messages_top_level(
LEFT JOIN thread_metadata tm
ON tm.event_created_at = e.created_at
AND tm.event_id = e.id
WHERE e.channel_id = ?
WHERE e.channel_id = $1
AND e.deleted_at IS NULL
AND (
tm.depth IS NULL
OR tm.depth = 0
OR (tm.depth = 1 AND tm.broadcast = 1)
OR (tm.depth = 1 AND tm.broadcast = true)
)
"#,
);
if before_cursor.is_some() {
sql.push_str(" AND e.created_at < ?");
sql.push_str(&format!(" AND e.created_at < ${param_idx}"));
param_idx += 1;
}
if let Some(kinds) = kind_filter {
@@ -539,14 +538,14 @@ pub async fn get_channel_messages_top_level(
}
}
sql.push_str(" ORDER BY e.created_at DESC LIMIT ?");
sql.push_str(&format!(" ORDER BY e.created_at DESC LIMIT ${param_idx}"));
let mut q = sqlx::query(&sql).bind(channel_id_bytes.as_slice());
if let Some(cursor) = before_cursor {
q = q.bind(cursor);
}
q = q.bind(limit);
q = q.bind(limit as i32);
let rows = q.fetch_all(pool).await?;
@@ -581,7 +580,7 @@ pub async fn get_channel_messages_top_level(
/// Used when processing soft-deletes to find the parent/root so reply counts
/// can be decremented.
pub async fn get_thread_metadata_by_event(
pool: &MySqlPool,
pool: &PgPool,
event_id: &[u8],
) -> Result<Option<ThreadMetadataRecord>> {
let row = sqlx::query(
@@ -597,7 +596,7 @@ pub async fn get_thread_metadata_by_event(
descendant_count,
broadcast
FROM thread_metadata
WHERE event_id = ?
WHERE event_id = $1
LIMIT 1
"#,
)
@@ -618,7 +617,7 @@ pub async fn get_thread_metadata_by_event(
let depth: i32 = row.try_get("depth")?;
let reply_count: i32 = row.try_get("reply_count")?;
let descendant_count: i32 = row.try_get("descendant_count")?;
let broadcast_val: i8 = row.try_get("broadcast")?;
let broadcast_val: bool = row.try_get("broadcast")?;
let channel_id = uuid_from_bytes(&channel_id_bytes)?;
@@ -631,6 +630,6 @@ pub async fn get_thread_metadata_by_event(
depth,
reply_count,
descendant_count,
broadcast: broadcast_val != 0,
broadcast: broadcast_val,
}))
}

View File

@@ -1,7 +1,7 @@
//! User CRUD operations.
use crate::error::Result;
use sqlx::MySqlPool;
use sqlx::PgPool;
use sqlx::Row;
/// A user's profile fields.
@@ -34,11 +34,12 @@ pub struct UserSearchProfile {
/// Ensure a user record exists for the given pubkey (upsert).
/// Creates with minimal fields if not present; no-op if already exists.
pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {
pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> {
sqlx::query(
r#"
INSERT IGNORE INTO users (pubkey)
VALUES (?)
INSERT INTO users (pubkey)
VALUES ($1)
ON CONFLICT DO NOTHING
"#,
)
.bind(pubkey)
@@ -48,7 +49,7 @@ pub async fn ensure_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<()> {
}
/// Get a single user record by pubkey.
pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result<Option<UserProfile>> {
let row = sqlx::query_as::<
_,
(
@@ -62,7 +63,7 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
r#"
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE pubkey = ?
WHERE pubkey = $1
"#,
)
.bind(pubkey)
@@ -81,33 +82,39 @@ pub async fn get_user(pool: &MySqlPool, pubkey: &[u8]) -> Result<Option<UserProf
}
/// Update a user's profile fields (display_name, avatar_url, about, nip05_handle).
/// Only updates fields that are Some — None fields are left unchanged.
/// Only updates fields that are Some -- None fields are left unchanged.
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
///
/// Empty strings are treated as "clear to NULL" — this is important for kind:0
/// Empty strings are treated as "clear to NULL" -- this is important for kind:0
/// absolute-state semantics where absent fields must be cleared, and for the
/// `nip05_handle` column which has a UNIQUE constraint (multiple NULLs are allowed,
/// but multiple empty strings would violate uniqueness).
pub async fn update_user_profile(
pool: &MySqlPool,
pool: &PgPool,
pubkey: &[u8],
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<()> {
let mut set_parts: Vec<&str> = Vec::new();
let mut set_parts: Vec<String> = Vec::new();
let mut param_idx = 1u32;
if display_name.is_some() {
set_parts.push("display_name = ?");
set_parts.push(format!("display_name = ${param_idx}"));
param_idx += 1;
}
if avatar_url.is_some() {
set_parts.push("avatar_url = ?");
set_parts.push(format!("avatar_url = ${param_idx}"));
param_idx += 1;
}
if about.is_some() {
set_parts.push("about = ?");
set_parts.push(format!("about = ${param_idx}"));
param_idx += 1;
}
if nip05_handle.is_some() {
set_parts.push("nip05_handle = ?");
set_parts.push(format!("nip05_handle = ${param_idx}"));
param_idx += 1;
}
if set_parts.is_empty() {
@@ -121,7 +128,10 @@ pub async fn update_user_profile(
val.filter(|s| !s.is_empty())
}
let sql = format!("UPDATE users SET {} WHERE pubkey = ?", set_parts.join(", "));
let sql = format!(
"UPDATE users SET {} WHERE pubkey = ${param_idx}",
set_parts.join(", ")
);
let mut query = sqlx::query(&sql);
if display_name.is_some() {
query = query.bind(empty_to_none(display_name));
@@ -143,7 +153,7 @@ pub async fn update_user_profile(
/// Look up a user by their full NIP-05 handle (exact match, case-insensitive).
/// Both `local_part` and `domain` must already be lowercased by the caller.
pub async fn get_user_by_nip05(
pool: &MySqlPool,
pool: &PgPool,
local_part: &str,
domain: &str,
) -> Result<Option<UserProfile>> {
@@ -161,7 +171,7 @@ pub async fn get_user_by_nip05(
r#"
SELECT pubkey, display_name, avatar_url, about, nip05_handle
FROM users
WHERE LOWER(nip05_handle) = LOWER(?)
WHERE LOWER(nip05_handle) = LOWER($1)
LIMIT 1
"#,
)
@@ -184,7 +194,7 @@ pub async fn get_user_by_nip05(
///
/// Empty queries return an empty vec and do not hit the database.
pub async fn search_users(
pool: &MySqlPool,
pool: &PgPool,
query: &str,
limit: u32,
) -> Result<Vec<UserSearchProfile>> {
@@ -201,31 +211,25 @@ pub async fn search_users(
r#"
SELECT pubkey, display_name, avatar_url, nip05_handle
FROM users
WHERE LOWER(COALESCE(display_name, '')) LIKE ?
OR LOWER(COALESCE(nip05_handle, '')) LIKE ?
OR LOWER(HEX(pubkey)) LIKE ?
WHERE LOWER(COALESCE(display_name, '')) LIKE $1
OR LOWER(COALESCE(nip05_handle, '')) LIKE $1
OR LOWER(encode(pubkey, 'hex')) LIKE $1
ORDER BY
CASE
WHEN LOWER(COALESCE(display_name, '')) = ? THEN 0
WHEN LOWER(COALESCE(nip05_handle, '')) = ? THEN 1
WHEN LOWER(HEX(pubkey)) = ? THEN 2
WHEN LOWER(COALESCE(display_name, '')) LIKE ? THEN 3
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE ? THEN 4
WHEN LOWER(HEX(pubkey)) LIKE ? THEN 5
WHEN LOWER(COALESCE(display_name, '')) = $2 THEN 0
WHEN LOWER(COALESCE(nip05_handle, '')) = $2 THEN 1
WHEN LOWER(encode(pubkey, 'hex')) = $2 THEN 2
WHEN LOWER(COALESCE(display_name, '')) LIKE $3 THEN 3
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $3 THEN 4
WHEN LOWER(encode(pubkey, 'hex')) LIKE $3 THEN 5
ELSE 6
END,
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(HEX(pubkey)))
LIMIT ?
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
LIMIT $4
"#,
)
.bind(&contains_pattern)
.bind(&contains_pattern)
.bind(&contains_pattern)
.bind(&normalized)
.bind(&normalized)
.bind(&normalized)
.bind(&prefix_pattern)
.bind(&prefix_pattern)
.bind(&prefix_pattern)
.bind(limit)
.fetch_all(pool)
@@ -248,11 +252,11 @@ pub async fn search_users(
/// The owner pubkey must already exist in the users table (FK constraint).
/// Returns an error if the agent pubkey is not found (rows_affected == 0).
pub async fn set_agent_owner(
pool: &MySqlPool,
pool: &PgPool,
agent_pubkey: &[u8],
owner_pubkey: &[u8],
) -> Result<()> {
let result = sqlx::query(r#"UPDATE users SET agent_owner_pubkey = ? WHERE pubkey = ?"#)
let result = sqlx::query(r#"UPDATE users SET agent_owner_pubkey = $1 WHERE pubkey = $2"#)
.bind(owner_pubkey)
.bind(agent_pubkey)
.execute(pool)
@@ -269,14 +273,15 @@ pub async fn set_agent_owner(
/// Returns None if the pubkey is not in the users table.
/// Returns Some((policy_str, owner_bytes_or_none)) if found.
pub async fn get_agent_channel_policy(
pool: &MySqlPool,
pool: &PgPool,
pubkey: &[u8],
) -> Result<Option<(String, Option<Vec<u8>>)>> {
let row =
sqlx::query(r#"SELECT channel_add_policy, agent_owner_pubkey FROM users WHERE pubkey = ?"#)
.bind(pubkey)
.fetch_optional(pool)
.await?;
let row = sqlx::query(
r#"SELECT channel_add_policy, agent_owner_pubkey FROM users WHERE pubkey = $1"#,
)
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|r| -> Result<(String, Option<Vec<u8>>)> {
let policy: String = r.try_get("channel_add_policy")?;
@@ -289,13 +294,13 @@ pub async fn get_agent_channel_policy(
/// Set the channel_add_policy for a user.
/// Returns an error if the pubkey is not found (rows_affected == 0).
/// Returns an error if `policy` is not one of the valid ENUM values.
pub async fn set_channel_add_policy(pool: &MySqlPool, pubkey: &[u8], policy: &str) -> Result<()> {
pub async fn set_channel_add_policy(pool: &PgPool, pubkey: &[u8], policy: &str) -> Result<()> {
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
return Err(crate::error::DbError::InvalidData(format!(
"invalid channel_add_policy: {policy}"
)));
}
let result = sqlx::query(r#"UPDATE users SET channel_add_policy = ? WHERE pubkey = ?"#)
let result = sqlx::query(r#"UPDATE users SET channel_add_policy = $1 WHERE pubkey = $2"#)
.bind(policy)
.bind(pubkey)
.execute(pool)
@@ -314,16 +319,12 @@ mod tests {
use crate::Db;
use nostr::Keys;
const TEST_DB_URL: &str = "mysql://sprout:sprout_dev@localhost:3306/sprout";
const TEST_DB_URL: &str = "postgres://sprout:sprout_dev@localhost:5432/sprout";
async fn setup_db() -> Db {
let pool = MySqlPool::connect(TEST_DB_URL)
let pool = PgPool::connect(TEST_DB_URL)
.await
.expect("connect to test DB");
sqlx::migrate!("../../migrations")
.run(&pool)
.await
.expect("migrate");
Db::from_pool(pool)
}
@@ -334,7 +335,7 @@ mod tests {
/// Setting an agent owner then reading back the policy should return
/// the default "anyone" policy and the owner pubkey.
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_set_agent_owner_and_get_policy() {
let db = setup_db().await;
let agent_pk = random_pubkey();
@@ -366,7 +367,7 @@ mod tests {
/// set_channel_add_policy should persist each of the three valid policies.
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy() {
let db = setup_db().await;
let pk = random_pubkey();
@@ -409,7 +410,7 @@ mod tests {
/// get_agent_channel_policy should return None for a pubkey that has
/// never been inserted into the users table.
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_get_policy_unknown_pubkey() {
let db = setup_db().await;
let pk = random_pubkey();
@@ -422,15 +423,15 @@ mod tests {
}
/// set_agent_owner should return Err when the agent pubkey does not exist
/// in the users table (0 rows affected → NotFound).
/// in the users table (0 rows affected -> NotFound).
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_set_agent_owner_nonexistent_agent() {
let db = setup_db().await;
let agent_pk = random_pubkey();
let owner_pk = random_pubkey();
// Only ensure the owner exists — agent is intentionally absent.
// Only ensure the owner exists -- agent is intentionally absent.
ensure_user(&db.pool, &owner_pk)
.await
.expect("ensure owner");
@@ -443,9 +444,9 @@ mod tests {
}
/// set_channel_add_policy should return Err when the pubkey does not exist
/// in the users table (0 rows affected → NotFound).
/// in the users table (0 rows affected -> NotFound).
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy_nonexistent_user() {
let db = setup_db().await;
let pk = random_pubkey();
@@ -458,7 +459,7 @@ mod tests {
}
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_set_channel_add_policy_rejects_invalid() {
let db = setup_db().await;
let pubkey = nostr::Keys::generate().public_key().serialize().to_vec();
@@ -470,7 +471,7 @@ mod tests {
/// A user with "owner_only" policy but no agent_owner_pubkey set should
/// return Some(("owner_only", None)).
#[tokio::test]
#[ignore = "requires MySQL"]
#[ignore = "requires Postgres"]
async fn test_owner_only_with_no_owner() {
let db = setup_db().await;
let pk = random_pubkey();

View File

@@ -1,7 +1,7 @@
//! Workflow CRUD — workflows, workflow_runs, and workflow_approvals tables.
//! Workflow CRUD -- workflows, workflow_runs, and workflow_approvals tables.
//!
//! All IDs are stored as BINARY(16) (UUID bytes). Never uses string interpolation
//! for query values — all user data goes through bind parameters.
//! for query values -- all user data goes through bind parameters.
//!
//! Security notes:
//! - Approval tokens are stored as SHA-256 hashes (never plaintext).
@@ -12,13 +12,13 @@ use std::str::FromStr;
use chrono::{DateTime, Utc};
use sha2::{Digest, Sha256};
use sqlx::{MySqlPool, Row};
use sqlx::{PgPool, Row};
use uuid::Uuid;
use crate::error::{DbError, Result};
use crate::event::uuid_from_bytes;
// ── Token hashing ─────────────────────────────────────────────────────────────
// -- Token hashing ------------------------------------------------------------
/// Default maximum rows returned by list queries. Callers may request fewer.
pub const LIST_DEFAULT_LIMIT: i64 = 100;
@@ -33,7 +33,7 @@ fn hash_approval_token(token: &str) -> Vec<u8> {
Sha256::digest(token.as_bytes()).to_vec()
}
// ── Status enums ──────────────────────────────────────────────────────────────
// -- Status enums -------------------------------------------------------------
/// Status of a workflow definition. Stored as ENUM('active','disabled','archived').
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
@@ -157,7 +157,7 @@ impl FromStr for ApprovalStatus {
}
}
// ── Record types ──────────────────────────────────────────────────────────────
// -- Record types -------------------------------------------------------------
/// A workflow definition record. Run-state columns live in `workflow_runs`.
#[derive(Debug, Clone)]
@@ -197,7 +197,7 @@ pub struct WorkflowRunRecord {
pub trigger_event_id: Option<Vec<u8>>,
/// Index of the step currently executing (0-based).
pub current_step: i32,
/// JSON execution trace — one entry per completed step.
/// JSON execution trace -- one entry per completed step.
pub execution_trace: serde_json::Value,
/// Serialized `TriggerContext` captured at workflow start.
/// NULL for runs created before this column was added (backwards-compatible).
@@ -239,12 +239,12 @@ pub struct ApprovalRecord {
pub created_at: DateTime<Utc>,
}
// ── Workflow CRUD ─────────────────────────────────────────────────────────────
// -- Workflow CRUD ------------------------------------------------------------
/// Insert a new workflow record. Returns the new workflow's UUID.
/// New workflows start as `active` and `enabled = TRUE`.
pub async fn create_workflow(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Option<Uuid>,
owner_pubkey: &[u8],
name: &str,
@@ -258,7 +258,7 @@ pub async fn create_workflow(
r#"
INSERT INTO workflows
(id, name, owner_pubkey, channel_id, definition, definition_hash, status, enabled)
VALUES (?, ?, ?, ?, ?, ?, 'active', TRUE)
VALUES ($1, $2, $3, $4, $5, $6, 'active', TRUE)
"#,
)
.bind(id.as_bytes().to_vec())
@@ -274,13 +274,13 @@ pub async fn create_workflow(
}
/// Fetch a single workflow by ID. Returns `DbError::InvalidData` if missing.
pub async fn get_workflow(pool: &MySqlPool, id: Uuid) -> Result<WorkflowRecord> {
pub async fn get_workflow(pool: &PgPool, id: Uuid) -> Result<WorkflowRecord> {
let row = sqlx::query(
r#"
SELECT id, name, owner_pubkey, channel_id, definition, definition_hash,
status, enabled, created_at, updated_at
FROM workflows
WHERE id = ?
WHERE id = $1
"#,
)
.bind(id.as_bytes().to_vec())
@@ -296,7 +296,7 @@ pub async fn get_workflow(pool: &MySqlPool, id: Uuid) -> Result<WorkflowRecord>
/// `limit` is capped at [`LIST_MAX_LIMIT`]. Pass `None` to use [`LIST_DEFAULT_LIMIT`].
/// `offset` enables pagination (0-based row offset).
pub async fn list_channel_workflows(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
limit: Option<i64>,
offset: Option<i64>,
@@ -309,9 +309,9 @@ pub async fn list_channel_workflows(
SELECT id, name, owner_pubkey, channel_id, definition, definition_hash,
status, enabled, created_at, updated_at
FROM workflows
WHERE channel_id = ?
WHERE channel_id = $1
ORDER BY created_at DESC
LIMIT ? OFFSET ?
LIMIT $2 OFFSET $3
"#,
)
.bind(channel_id.as_bytes().to_vec())
@@ -327,10 +327,10 @@ pub async fn list_channel_workflows(
/// Used by the trigger-matching path to find workflows that should fire.
/// Only returns workflows with status = 'active' AND enabled = TRUE.
///
/// Bounded to [`LIST_MAX_LIMIT`] rows — the trigger path should not process
/// Bounded to [`LIST_MAX_LIMIT`] rows -- the trigger path should not process
/// an unbounded number of workflows per event.
pub async fn list_enabled_channel_workflows(
pool: &MySqlPool,
pool: &PgPool,
channel_id: Uuid,
) -> Result<Vec<WorkflowRecord>> {
let rows = sqlx::query(
@@ -338,11 +338,11 @@ pub async fn list_enabled_channel_workflows(
SELECT id, name, owner_pubkey, channel_id, definition, definition_hash,
status, enabled, created_at, updated_at
FROM workflows
WHERE channel_id = ?
WHERE channel_id = $1
AND status = 'active'
AND enabled = TRUE
ORDER BY created_at DESC
LIMIT ?
LIMIT $2
"#,
)
.bind(channel_id.as_bytes().to_vec())
@@ -358,7 +358,7 @@ pub async fn list_enabled_channel_workflows(
/// Used by the cron scheduler. Filters by trigger type in SQL to avoid loading
/// event-triggered workflows that the cron loop would immediately discard.
/// Results are bounded to [`LIST_MAX_LIMIT`] rows.
pub async fn list_all_enabled_workflows(pool: &MySqlPool) -> Result<Vec<WorkflowRecord>> {
pub async fn list_all_enabled_workflows(pool: &PgPool) -> Result<Vec<WorkflowRecord>> {
let rows = sqlx::query(
r#"
SELECT id, name, owner_pubkey, channel_id, definition, definition_hash,
@@ -366,9 +366,9 @@ pub async fn list_all_enabled_workflows(pool: &MySqlPool) -> Result<Vec<Workflow
FROM workflows
WHERE status = 'active'
AND enabled = TRUE
AND JSON_EXTRACT(definition, '$.trigger.on') = 'schedule'
AND definition->'trigger'->>'on' = 'schedule'
ORDER BY created_at ASC
LIMIT ?
LIMIT $1
"#,
)
.bind(LIST_MAX_LIMIT)
@@ -380,7 +380,7 @@ pub async fn list_all_enabled_workflows(pool: &MySqlPool) -> Result<Vec<Workflow
/// Update a workflow's name, definition, and definition_hash.
pub async fn update_workflow(
pool: &MySqlPool,
pool: &PgPool,
id: Uuid,
name: &str,
definition_json: &str,
@@ -389,8 +389,8 @@ pub async fn update_workflow(
let affected = sqlx::query(
r#"
UPDATE workflows
SET name = ?, definition = ?, definition_hash = ?
WHERE id = ?
SET name = $1, definition = $2, definition_hash = $3
WHERE id = $4
"#,
)
.bind(name)
@@ -407,17 +407,13 @@ pub async fn update_workflow(
Ok(())
}
/// Update a workflow's status (active → disabled → archived).
pub async fn update_workflow_status(
pool: &MySqlPool,
id: Uuid,
status: WorkflowStatus,
) -> Result<()> {
/// Update a workflow's status (active -> disabled -> archived).
pub async fn update_workflow_status(pool: &PgPool, id: Uuid, status: WorkflowStatus) -> Result<()> {
let affected = sqlx::query(
r#"
UPDATE workflows
SET status = ?
WHERE id = ?
SET status = $1
WHERE id = $2
"#,
)
.bind(status.to_string())
@@ -433,12 +429,12 @@ pub async fn update_workflow_status(
}
/// Enable or disable a workflow without changing its status.
pub async fn set_workflow_enabled(pool: &MySqlPool, id: Uuid, enabled: bool) -> Result<()> {
pub async fn set_workflow_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Result<()> {
let affected = sqlx::query(
r#"
UPDATE workflows
SET enabled = ?
WHERE id = ?
SET enabled = $1
WHERE id = $2
"#,
)
.bind(enabled)
@@ -454,8 +450,8 @@ pub async fn set_workflow_enabled(pool: &MySqlPool, id: Uuid, enabled: bool) ->
}
/// Delete a workflow and all its runs/approvals (CASCADE).
pub async fn delete_workflow(pool: &MySqlPool, id: Uuid) -> Result<()> {
let affected = sqlx::query("DELETE FROM workflows WHERE id = ?")
pub async fn delete_workflow(pool: &PgPool, id: Uuid) -> Result<()> {
let affected = sqlx::query("DELETE FROM workflows WHERE id = $1")
.bind(id.as_bytes().to_vec())
.execute(pool)
.await?
@@ -467,7 +463,7 @@ pub async fn delete_workflow(pool: &MySqlPool, id: Uuid) -> Result<()> {
Ok(())
}
// ── Workflow Run CRUD ─────────────────────────────────────────────────────────
// -- Workflow Run CRUD --------------------------------------------------------
/// Insert a new workflow run. Returns the new run's UUID.
///
@@ -475,7 +471,7 @@ pub async fn delete_workflow(pool: &MySqlPool, id: Uuid) -> Result<()> {
/// so that post-approval resume steps can restore the original trigger data and
/// correctly resolve `{{trigger.*}}` template variables.
pub async fn create_workflow_run(
pool: &MySqlPool,
pool: &PgPool,
workflow_id: Uuid,
trigger_event_id: Option<&[u8]>,
trigger_context: Option<&serde_json::Value>,
@@ -486,7 +482,7 @@ pub async fn create_workflow_run(
r#"
INSERT INTO workflow_runs
(id, workflow_id, status, trigger_event_id, current_step, execution_trace, trigger_context)
VALUES (?, ?, 'pending', ?, 0, '[]', ?)
VALUES ($1, $2, 'pending', $3, 0, '[]', $4)
"#,
)
.bind(id.as_bytes().to_vec())
@@ -500,13 +496,13 @@ pub async fn create_workflow_run(
}
/// Fetch a single workflow run by ID.
pub async fn get_workflow_run(pool: &MySqlPool, id: Uuid) -> Result<WorkflowRunRecord> {
pub async fn get_workflow_run(pool: &PgPool, id: Uuid) -> Result<WorkflowRunRecord> {
let row = sqlx::query(
r#"
SELECT id, workflow_id, status, trigger_event_id, current_step,
execution_trace, trigger_context, started_at, completed_at, error_message, created_at
FROM workflow_runs
WHERE id = ?
WHERE id = $1
"#,
)
.bind(id.as_bytes().to_vec())
@@ -519,7 +515,7 @@ pub async fn get_workflow_run(pool: &MySqlPool, id: Uuid) -> Result<WorkflowRunR
/// List runs for a workflow, newest first, up to `limit` rows.
pub async fn list_workflow_runs(
pool: &MySqlPool,
pool: &PgPool,
workflow_id: Uuid,
limit: i64,
) -> Result<Vec<WorkflowRunRecord>> {
@@ -529,9 +525,9 @@ pub async fn list_workflow_runs(
SELECT id, workflow_id, status, trigger_event_id, current_step,
execution_trace, trigger_context, started_at, completed_at, error_message, created_at
FROM workflow_runs
WHERE workflow_id = ?
WHERE workflow_id = $1
ORDER BY created_at DESC
LIMIT ?
LIMIT $2
"#,
)
.bind(workflow_id.as_bytes().to_vec())
@@ -549,7 +545,7 @@ pub async fn list_workflow_runs(
/// column AFTER `SET status = ?` had already changed it, so the condition was
/// always false. We now check the bind parameter directly.
pub async fn update_workflow_run(
pool: &MySqlPool,
pool: &PgPool,
id: Uuid,
status: RunStatus,
current_step: i32,
@@ -560,15 +556,15 @@ pub async fn update_workflow_run(
let affected = sqlx::query(
r#"
UPDATE workflow_runs
SET status = ?,
current_step = ?,
execution_trace = ?,
error_message = ?,
started_at = CASE WHEN ? = 'running' AND started_at IS NULL
THEN NOW(6) ELSE started_at END,
completed_at = CASE WHEN ? IN ('completed','failed','cancelled')
THEN NOW(6) ELSE completed_at END
WHERE id = ?
SET status = $1,
current_step = $2,
execution_trace = $3,
error_message = $4,
started_at = CASE WHEN $5 = 'running' AND started_at IS NULL
THEN NOW() ELSE started_at END,
completed_at = CASE WHEN $6 IN ('completed','failed','cancelled')
THEN NOW() ELSE completed_at END
WHERE id = $7
"#,
)
.bind(&status_str)
@@ -588,7 +584,7 @@ pub async fn update_workflow_run(
Ok(())
}
// ── Approval CRUD ─────────────────────────────────────────────────────────────
// -- Approval CRUD ------------------------------------------------------------
/// Parameters for creating a new approval request.
pub struct CreateApprovalParams<'a> {
@@ -612,7 +608,7 @@ pub struct CreateApprovalParams<'a> {
///
/// The `token` parameter is the raw (plaintext) token. It is hashed with
/// SHA-256 before storage so the DB never holds the raw value.
pub async fn create_approval(pool: &MySqlPool, params: CreateApprovalParams<'_>) -> Result<()> {
pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) -> Result<()> {
let CreateApprovalParams {
token,
workflow_id,
@@ -628,7 +624,7 @@ pub async fn create_approval(pool: &MySqlPool, params: CreateApprovalParams<'_>)
r#"
INSERT INTO workflow_approvals
(token, workflow_id, run_id, step_id, step_index, approver_spec, status, expires_at)
VALUES (?, ?, ?, ?, ?, ?, 'pending', ?)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
"#,
)
.bind(token_hash)
@@ -648,7 +644,7 @@ pub async fn create_approval(pool: &MySqlPool, params: CreateApprovalParams<'_>)
///
/// The token is hashed before the DB lookup so plaintext tokens are never
/// sent to the database layer.
pub async fn get_approval(pool: &MySqlPool, token: &str) -> Result<ApprovalRecord> {
pub async fn get_approval(pool: &PgPool, token: &str) -> Result<ApprovalRecord> {
let token_hash = hash_approval_token(token);
let row = sqlx::query(
@@ -656,7 +652,7 @@ pub async fn get_approval(pool: &MySqlPool, token: &str) -> Result<ApprovalRecor
SELECT token, workflow_id, run_id, step_id, step_index, approver_spec,
status, approver_pubkey, note, expires_at, created_at
FROM workflow_approvals
WHERE token = ?
WHERE token = $1
"#,
)
.bind(token_hash)
@@ -676,10 +672,10 @@ pub async fn get_approval(pool: &MySqlPool, token: &str) -> Result<ApprovalRecor
/// # TOCTOU safety (N5)
/// The WHERE clause includes `AND status = 'pending'` so that two concurrent
/// grant/deny requests cannot both succeed. If the approval was already acted
/// on (status ≠ 'pending'), the UPDATE touches 0 rows and this function
/// on (status != 'pending'), the UPDATE touches 0 rows and this function
/// returns `Ok(false)`. Callers should treat `false` as a conflict (HTTP 409).
pub async fn update_approval(
pool: &MySqlPool,
pool: &PgPool,
token: &str,
status: ApprovalStatus,
approver_pubkey: Option<&[u8]>,
@@ -690,12 +686,12 @@ pub async fn update_approval(
let affected = sqlx::query(
r#"
UPDATE workflow_approvals
SET status = ?,
approver_pubkey = ?,
note = ?,
granted_at = CASE WHEN ? = 'granted' THEN NOW(6) ELSE granted_at END,
denied_at = CASE WHEN ? = 'denied' THEN NOW(6) ELSE denied_at END
WHERE token = ? AND status = 'pending'
SET status = $1,
approver_pubkey = $2,
note = $3,
granted_at = CASE WHEN $4 = 'granted' THEN NOW() ELSE granted_at END,
denied_at = CASE WHEN $5 = 'denied' THEN NOW() ELSE denied_at END
WHERE token = $6 AND status = 'pending'
"#,
)
.bind(&status_str)
@@ -708,15 +704,12 @@ pub async fn update_approval(
.await?
.rows_affected();
// 0 rows affected means either the token doesn't exist or it was already
// acted on (status ≠ 'pending'). Return false so callers can distinguish
// this from a DB error and surface a proper conflict response.
Ok(affected > 0)
}
// ── Row mappers ───────────────────────────────────────────────────────────────
// -- Row mappers --------------------------------------------------------------
fn row_to_workflow_record(row: sqlx::mysql::MySqlRow) -> Result<WorkflowRecord> {
fn row_to_workflow_record(row: sqlx::postgres::PgRow) -> Result<WorkflowRecord> {
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
@@ -744,7 +737,7 @@ fn row_to_workflow_record(row: sqlx::mysql::MySqlRow) -> Result<WorkflowRecord>
})
}
fn row_to_run_record(row: sqlx::mysql::MySqlRow) -> Result<WorkflowRunRecord> {
fn row_to_run_record(row: sqlx::postgres::PgRow) -> Result<WorkflowRunRecord> {
let id_bytes: Vec<u8> = row.try_get("id")?;
let id = uuid_from_bytes(&id_bytes)?;
@@ -769,7 +762,7 @@ fn row_to_run_record(row: sqlx::mysql::MySqlRow) -> Result<WorkflowRunRecord> {
})
}
fn row_to_approval_record(row: sqlx::mysql::MySqlRow) -> Result<ApprovalRecord> {
fn row_to_approval_record(row: sqlx::postgres::PgRow) -> Result<ApprovalRecord> {
let wf_bytes: Vec<u8> = row.try_get("workflow_id")?;
let workflow_id = uuid_from_bytes(&wf_bytes)?;
@@ -794,14 +787,14 @@ fn row_to_approval_record(row: sqlx::mysql::MySqlRow) -> Result<ApprovalRecord>
})
}
// ── Tests ─────────────────────────────────────────────────────────────────────
// -- Tests --------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
// ── WorkflowStatus enum ───────────────────────────────────────────────────
// -- WorkflowStatus enum --------------------------------------------------
#[test]
fn workflow_status_display_is_lowercase() {
@@ -830,7 +823,7 @@ mod tests {
assert_ne!(WorkflowStatus::Active, WorkflowStatus::Disabled);
}
// ── RunStatus enum ────────────────────────────────────────────────────────
// -- RunStatus enum -------------------------------------------------------
#[test]
fn run_status_display_is_lowercase() {
@@ -863,7 +856,7 @@ mod tests {
assert!(matches!(err, DbError::InvalidData(_)));
}
// ── ApprovalStatus enum ───────────────────────────────────────────────────
// -- ApprovalStatus enum --------------------------------------------------
#[test]
fn approval_status_display_is_lowercase() {
@@ -887,7 +880,7 @@ mod tests {
assert!(matches!(err, DbError::InvalidData(_)));
}
// ── WorkflowRecord ────────────────────────────────────────────────────────
// -- WorkflowRecord -------------------------------------------------------
#[test]
fn workflow_record_fields_are_accessible() {
@@ -1012,7 +1005,7 @@ mod tests {
assert_eq!(record.status, WorkflowStatus::Active);
}
// ── WorkflowRunRecord ─────────────────────────────────────────────────────
// -- WorkflowRunRecord ----------------------------------------------------
#[test]
fn workflow_run_record_fields_are_accessible() {
@@ -1144,7 +1137,7 @@ mod tests {
assert_eq!(cloned.status, RunStatus::Running);
}
// ── ApprovalRecord ────────────────────────────────────────────────────────
// -- ApprovalRecord -------------------------------------------------------
#[test]
fn approval_record_fields_are_accessible() {
@@ -1248,7 +1241,7 @@ mod tests {
assert_eq!(cloned.status, ApprovalStatus::Granted);
}
// ── uuid_from_bytes (helper) ──────────────────────────────────────────────
// -- uuid_from_bytes (helper) ---------------------------------------------
#[test]
fn uuid_from_bytes_round_trips() {

View File

@@ -192,10 +192,9 @@ mod tests {
let ev = signed_parse(r#"{"event":"room_finished","room":{"name":"r1"}}"#).unwrap();
assert_eq!(ev, WebhookEvent::RoomFinished { room: "r1".into() });
let ev = signed_parse(
r#"{"event":"participant_joined","room":{"name":"r1"},"participant":{"identity":"alice"}}"#,
)
.unwrap();
let ev =
signed_parse(r#"{"event":"participant_joined","room":{"name":"r1"},"participant":{"identity":"alice"}}"#)
.unwrap();
assert_eq!(
ev,
WebhookEvent::ParticipantJoined {
@@ -204,10 +203,9 @@ mod tests {
}
);
let ev = signed_parse(
r#"{"event":"participant_left","room":{"name":"r1"},"participant":{"identity":"alice"}}"#,
)
.unwrap();
let ev =
signed_parse(r#"{"event":"participant_left","room":{"name":"r1"},"participant":{"identity":"alice"}}"#)
.unwrap();
assert_eq!(
ev,
WebhookEvent::ParticipantLeft {

View File

@@ -79,8 +79,7 @@ mod tests {
0xFF, 0xD8, // APP0 (JFIF marker)
0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00, 0x00, 0x01, 0x00,
0x01, 0x00, 0x00, // SOF0: precision=8, height=1, width=1, components=1
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00,
// EOI
0xFF, 0xC0, 0x00, 0x0B, 0x08, 0x00, 0x01, 0x00, 0x01, 0x01, 0x01, 0x11, 0x00, // EOI
0xFF, 0xD9,
];
@@ -130,8 +129,8 @@ mod tests {
// Header
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
// Logical Screen Descriptor: width=1, height=1, flags, bgcolor, aspect
0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
// Global Color Table (2 colors: white, black)
0x01, 0x00, 0x01, 0x00, 0x80, 0x00,
0x00, // Global Color Table (2 colors: white, black)
0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, // Image Descriptor
0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, // Image Data
0x02, 0x02, 0x4C, 0x01, 0x00, // Trailer

View File

@@ -27,9 +27,7 @@ pub async fn run_subscriber(redis_url: String, broadcast_tx: broadcast::Sender<C
// established and ran successfully, so reset backoff to the initial
// value — a brief Redis restart should reconnect quickly.
backoff_secs = BACKOFF_INITIAL_SECS;
tracing::warn!(
"Redis pub/sub stream ended (clean disconnect) — reconnecting in {backoff_secs}s"
);
tracing::warn!("Redis pub/sub stream ended (clean disconnect) — reconnecting in {backoff_secs}s");
}
Err(e) => {
tracing::error!("Redis pub/sub error: {e} — reconnecting in {backoff_secs}s");

View File

@@ -995,7 +995,10 @@ pub async fn list_messages(
})
.transpose()
.map_err(|_| {
api_error(StatusCode::BAD_REQUEST, "Invalid 'kinds' parameter — expected comma-separated integers (e.g. '45001' or '9,45001')")
api_error(
StatusCode::BAD_REQUEST,
"Invalid 'kinds' parameter — expected comma-separated integers (e.g. '45001' or '9,45001')",
)
})?;
let mut messages = state

View File

@@ -51,7 +51,7 @@ pub async fn list_channel_workflows(
let workflows = state
.db
.list_channel_workflows(channel_id)
.list_channel_workflows(channel_id, None, None)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;

View File

@@ -18,7 +18,7 @@ pub enum ConfigError {
pub struct Config {
/// Address the relay HTTP/WebSocket server binds to.
pub bind_addr: SocketAddr,
/// MySQL database connection URL.
/// Postgres database connection URL.
pub database_url: String,
/// Redis connection URL used by the pub/sub manager.
pub redis_url: String,
@@ -72,7 +72,7 @@ impl Config {
.map_err(|e| ConfigError::InvalidBindAddr(e.to_string()))?;
let database_url = std::env::var("DATABASE_URL")
.unwrap_or_else(|_| "mysql://sprout:sprout_dev@localhost:3306/sprout".to_string());
.unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".to_string());
let redis_url =
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());

View File

@@ -62,7 +62,17 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into();
let record = match state.db.get_api_token_by_hash(&hash).await {
Ok(r) => r,
Ok(Some(r)) => r,
Ok(None) => {
warn!(conn_id = %conn_id, "API token not found");
*conn.auth_state.write().await = AuthState::Failed;
conn.send(RelayMessage::ok(
&event_id_hex,
false,
"auth-required: invalid token",
));
return;
}
Err(e) => {
warn!(conn_id = %conn_id, error = %e, "API token lookup failed");
*conn.auth_state.write().await = AuthState::Failed;

View File

@@ -415,7 +415,7 @@ async fn emit_addressable_discovery_event(
let (stored, _) = state
.db
.replace_addressable_event(&event, channel_id)
.replace_addressable_event(&event, Some(channel_id))
.await?;
let kind_u32 = event_kind_u32(&stored.event);
dispatch_persistent_event(state, &stored, kind_u32, relay_pubkey_hex).await;

View File

@@ -50,22 +50,16 @@ async fn main() -> anyhow::Result<()> {
..DbConfig::default()
};
let db = Db::new(&db_config).await.map_err(|e| {
error!("Failed to connect to MySQL: {e}");
error!("Failed to connect to Postgres: {e}");
anyhow::anyhow!("DB connection failed: {e}")
})?;
info!("MySQL connected");
db.migrate().await.map_err(|e| {
error!("Migration failed: {e}");
anyhow::anyhow!("Migration failed: {e}")
})?;
info!("Migrations applied");
info!("Postgres connected");
if let Err(e) = db.ensure_future_partitions(3).await {
error!("Failed to ensure partitions: {e}");
}
let audit_pool = sqlx::MySqlPool::connect(&config.database_url)
let audit_pool = sqlx::PgPool::connect(&config.database_url)
.await
.map_err(|e| anyhow::anyhow!("Audit DB connection failed: {e}"))?;
let audit = AuditService::new(audit_pool);

View File

@@ -411,7 +411,8 @@ mod tests {
#[test]
fn validate_rejects_empty_name() {
let yaml = "name: ''\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n";
let yaml =
"name: ''\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n";
let err = parse_yaml(yaml).unwrap_err();
assert!(
matches!(err, WorkflowError::InvalidDefinition(_)),
@@ -693,7 +694,8 @@ mod tests {
#[test]
fn validate_rejects_whitespace_only_name() {
let yaml = "name: ' '\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n";
let yaml =
"name: ' '\ntrigger:\n on: message_posted\nsteps:\n - id: s1\n action: send_message\n text: hi\n";
let err = parse_yaml(yaml).unwrap_err();
assert!(
matches!(err, WorkflowError::InvalidDefinition(_)),

View File

@@ -1,9 +1,5 @@
[advisories]
ignore = [
# rsa 0.9.10 — Marvin Attack timing sidechannel. No fix available.
# Transitive dep: sqlx-mysql → rsa. Only used for MySQL TLS handshake,
# not for RSA key operations. Acceptable risk until sqlx updates.
{ id = "RUSTSEC-2023-0071", reason = "transitive dep via sqlx-mysql; no upstream fix available" },
# instant 0.1.13 — unmaintained crate. Transitive dep: nostr → instant.
# Will be resolved when nostr crate updates its dependencies.
{ id = "RUSTSEC-2024-0384", reason = "transitive dep via nostr; no upstream fix available" },

View File

@@ -1,20 +1,19 @@
services:
mysql:
image: mysql:8.0
container_name: sprout-mysql
postgres:
image: postgres:17-alpine
container_name: sprout-postgres
environment:
MYSQL_ROOT_PASSWORD: sprout_dev
MYSQL_DATABASE: sprout
MYSQL_USER: sprout
MYSQL_PASSWORD: sprout_dev
POSTGRES_USER: sprout
POSTGRES_PASSWORD: sprout_dev
POSTGRES_DB: sprout
ports:
- "3306:3306"
- "5432:5432"
volumes:
- mysql-data:/var/lib/mysql
- postgres-data:/var/lib/postgresql/data
networks:
- sprout-net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -psprout_dev"]
test: ["CMD-SHELL", "pg_isready -U sprout"]
interval: 5s
timeout: 5s
retries: 10
@@ -24,7 +23,7 @@ services:
limits:
memory: 512m
labels:
com.sprout.service: "mysql"
com.sprout.service: "postgres"
com.sprout.env: "dev"
restart: unless-stopped
@@ -86,10 +85,10 @@ services:
networks:
- sprout-net
depends_on:
mysql:
postgres:
condition: service_healthy
environment:
ADMINER_DEFAULT_SERVER: mysql
ADMINER_DEFAULT_SERVER: postgres
deploy:
resources:
limits:
@@ -196,10 +195,10 @@ services:
restart: unless-stopped
volumes:
mysql-data:
name: sprout-mysql-data
postgres-data:
name: sprout-postgres-data
labels:
com.sprout.volume: "mysql"
com.sprout.volume: "postgres"
typesense-data:
name: sprout-typesense-data
labels:

View File

@@ -145,21 +145,9 @@ desktop-app *ARGS:
# ─── Database ─────────────────────────────────────────────────────────────────
# Run database migrations (uses sqlx CLI if available, falls back to docker exec)
# Apply schema migrations via pgschema
migrate:
#!/usr/bin/env bash
set -euo pipefail
if command -v sqlx &>/dev/null; then
echo "Running migrations via sqlx..."
sqlx migrate run --source migrations
else
echo "sqlx CLI not found — applying migrations via docker exec..."
for sql_file in migrations/*.sql; do
echo " Applying $(basename "$sql_file")..."
docker run --rm -i --network "${SPROUT_DOCKER_NETWORK:-sprout-net}" -e MYSQL_PWD="${SPROUT_DB_PASS:-sprout_dev}" "${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}" mysql -h"${SPROUT_DOCKER_DB_HOST:-mysql}" -u"${SPROUT_DB_USER:-sprout}" "${SPROUT_DB_NAME:-sprout}" < "$sql_file" 2>/dev/null || true
done
echo "Migrations applied."
fi
./bin/pgschema apply --file schema/schema.sql --auto-approve
# ─── Utilities ────────────────────────────────────────────────────────────────

View File

@@ -1,262 +0,0 @@
-- Sprout initial schema — MySQL 8.0
-- Monthly range partitioning on events.created_at and delivery_log.delivered_at
-- Run via: sqlx migrate run --database-url $DATABASE_URL
-- ─── Channels ────────────────────────────────────────────────────────────────
CREATE TABLE channels (
id BINARY(16) NOT NULL,
name TEXT NOT NULL,
channel_type ENUM('stream','forum','dm','workflow') NOT NULL DEFAULT 'stream',
visibility ENUM('open','private') NOT NULL DEFAULT 'private',
description TEXT,
canvas TEXT,
created_by VARBINARY(32) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
archived_at DATETIME(6),
deleted_at DATETIME(6),
nip29_group_id VARCHAR(255) UNIQUE,
topic_required TINYINT(1) NOT NULL DEFAULT 1,
max_members INT,
PRIMARY KEY (id),
CONSTRAINT name_not_empty CHECK (LENGTH(name) > 0)
);
CREATE INDEX idx_channels_type ON channels (channel_type);
CREATE INDEX idx_channels_visibility ON channels (visibility);
CREATE INDEX idx_channels_created_by ON channels (created_by);
-- Note: GIN/full-text index on name+description omitted; use MySQL FULLTEXT or app-layer search
-- ─── Channel Members ─────────────────────────────────────────────────────────
CREATE TABLE channel_members (
channel_id BINARY(16) NOT NULL,
pubkey VARBINARY(32) NOT NULL,
role ENUM('owner','admin','member','guest','bot') NOT NULL DEFAULT 'member',
joined_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
invited_by VARBINARY(32),
removed_at DATETIME(6),
removed_by VARBINARY(32),
PRIMARY KEY (channel_id, pubkey),
CONSTRAINT fk_channel_members_channel
FOREIGN KEY (channel_id) REFERENCES channels(id) ON DELETE CASCADE
);
-- Note: Partial indexes (WHERE removed_at IS NULL) not supported in MySQL.
-- Using regular indexes instead.
CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey);
CREATE INDEX idx_channel_members_channel ON channel_members (channel_id);
-- ─── Users ───────────────────────────────────────────────────────────────────
CREATE TABLE users (
pubkey VARBINARY(32) NOT NULL,
nip05_handle VARCHAR(255) UNIQUE,
display_name TEXT,
avatar_url TEXT,
agent_type VARCHAR(255),
capabilities JSON,
okta_user_id VARCHAR(255) UNIQUE,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
deactivated_at DATETIME(6),
metadata_event_id VARBINARY(32),
-- NOTE: No FK to events — events is partitioned; MySQL does not support FK
-- from/to partitioned tables. Integrity enforced at application layer.
PRIMARY KEY (pubkey),
CONSTRAINT pubkey_length CHECK (LENGTH(pubkey) = 32)
);
-- Note: Partial indexes not supported in MySQL. Using regular indexes.
CREATE INDEX idx_users_nip05 ON users (nip05_handle);
CREATE INDEX idx_users_agent_type ON users (agent_type);
CREATE INDEX idx_users_okta ON users (okta_user_id);
-- ─── Events (Partitioned by Month) ───────────────────────────────────────────
-- Partitioned by RANGE on TO_DAYS(created_at).
-- ⚠️ MySQL requires the partition key to be part of every unique index / PK.
-- PK is (created_at, id) to satisfy this requirement.
-- ⚠️ MySQL does not support FK constraints on partitioned tables.
-- channel_id → channels(id) is enforced at the application layer.
-- ⚠️ Deduplication by id alone is not enforceable via unique index across
-- partitions. SHA-256 collision resistance + app-layer INSERT IGNORE used.
CREATE TABLE events (
id VARBINARY(32) NOT NULL,
pubkey VARBINARY(32) NOT NULL,
created_at DATETIME(6) NOT NULL,
kind INT NOT NULL,
tags JSON NOT NULL,
content TEXT NOT NULL,
sig VARBINARY(64) NOT NULL,
received_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
channel_id BINARY(16),
-- No FK: channel_id REFERENCES channels(id) — not supported on partitioned tables
PRIMARY KEY (created_at, id)
)
PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p2026_01 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p2026_02 VALUES LESS THAN (TO_DAYS('2026-03-01')),
PARTITION p2026_03 VALUES LESS THAN (TO_DAYS('2026-04-01')),
PARTITION p2026_04 VALUES LESS THAN (TO_DAYS('2026-05-01')),
PARTITION p2026_05 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p2026_06 VALUES LESS THAN (TO_DAYS('2026-07-01'))
);
-- Composite index: pubkey + kind + created_at (NIP-01 author+kind queries)
-- created_at must be leftmost or included for partition pruning
CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at);
-- Composite index: channel + created_at (channel message pagination)
-- Note: Partial index (WHERE channel_id IS NOT NULL) not supported; regular index used
CREATE INDEX idx_events_channel_created ON events (channel_id, created_at);
-- Composite index: kind + created_at (kind-only queries)
CREATE INDEX idx_events_kind_created ON events (kind, created_at);
-- Note: GIN index on tags JSON omitted — no equivalent in MySQL.
-- For tag filtering, add generated columns + regular indexes as needed at app layer.
-- ─── Persistent Subscriptions ────────────────────────────────────────────────
CREATE TABLE subscriptions (
id VARCHAR(255) NOT NULL,
name TEXT NOT NULL,
owner_pubkey VARBINARY(32) NOT NULL,
filter_channel_ids JSON,
filter_topics JSON,
filter_authors JSON,
filter_kinds JSON,
filter_content_regex TEXT,
filter_tags JSON,
delivery_method ENUM('websocket','webhook','email_digest','push') NOT NULL,
delivery_url TEXT,
delivery_secret TEXT,
delivery_retry_max INT NOT NULL DEFAULT 3,
delivery_email_frequency TEXT,
delivery_email_send_at TIME,
delivery_email_timezone TEXT,
status ENUM('active','paused','deleted') NOT NULL DEFAULT 'active',
pause_reason ENUM('manual','circuit_breaker','rate_limit','admin'),
paused_at DATETIME(6),
visibility VARCHAR(50) NOT NULL DEFAULT 'private',
total_matched BIGINT NOT NULL DEFAULT 0,
total_delivered BIGINT NOT NULL DEFAULT 0,
last_matched_at DATETIME(6),
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
CONSTRAINT fk_subscriptions_owner
FOREIGN KEY (owner_pubkey) REFERENCES users(pubkey)
);
-- Note: Partial indexes not supported. Regular indexes used.
-- Note: GIN indexes on JSON arrays (filter_kinds, filter_channel_ids) omitted.
-- Use JSON_CONTAINS() at query time or add generated columns for hot paths.
CREATE INDEX idx_subscriptions_owner ON subscriptions (owner_pubkey);
CREATE INDEX idx_subscriptions_status ON subscriptions (status);
-- ─── Delivery Log (Partitioned) ───────────────────────────────────────────────
-- AUTO_INCREMENT replaces CREATE SEQUENCE + nextval().
-- ⚠️ PK must include partition key (delivered_at) — using (delivered_at, id).
-- ⚠️ MySQL does not support FK constraints on partitioned tables.
-- subscription_id → subscriptions(id) enforced at application layer.
CREATE TABLE delivery_log (
id BIGINT NOT NULL AUTO_INCREMENT,
subscription_id VARCHAR(255) NOT NULL,
event_id VARBINARY(32) NOT NULL,
delivered_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
method ENUM('websocket','webhook','email_digest','push') NOT NULL,
success TINYINT(1) NOT NULL,
http_status INT,
error_message TEXT,
attempt_number SMALLINT NOT NULL DEFAULT 1,
-- No FK: subscription_id REFERENCES subscriptions(id) — not supported on partitioned tables
PRIMARY KEY (delivered_at, id),
KEY (id) -- MySQL requires AUTO_INCREMENT column to be a key
)
PARTITION BY RANGE (TO_DAYS(delivered_at)) (
PARTITION p2026_03 VALUES LESS THAN (TO_DAYS('2026-04-01')),
PARTITION p2026_04 VALUES LESS THAN (TO_DAYS('2026-05-01')),
PARTITION p2026_05 VALUES LESS THAN (TO_DAYS('2026-06-01')),
PARTITION p2026_06 VALUES LESS THAN (TO_DAYS('2026-07-01'))
);
CREATE INDEX idx_delivery_log_sub_delivered ON delivery_log (subscription_id, delivered_at);
-- Note: Partial index (WHERE success = FALSE) not supported; regular index used
CREATE INDEX idx_delivery_log_failures ON delivery_log (subscription_id, delivered_at, success);
-- ─── Workflows ────────────────────────────────────────────────────────────────
CREATE TABLE workflows (
id BINARY(16) NOT NULL,
name TEXT NOT NULL,
owner_pubkey VARBINARY(32) NOT NULL,
channel_id BINARY(16),
definition JSON NOT NULL,
definition_hash VARBINARY(32) NOT NULL,
status ENUM('pending','running','waiting_approval','completed','failed','cancelled') NOT NULL DEFAULT 'pending',
trigger_event_id VARBINARY(32),
current_step INT NOT NULL DEFAULT 0,
execution_trace JSON NOT NULL,
started_at DATETIME(6),
completed_at DATETIME(6),
failed_at DATETIME(6),
error_message TEXT,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
CONSTRAINT fk_workflows_owner
FOREIGN KEY (owner_pubkey) REFERENCES users(pubkey),
CONSTRAINT fk_workflows_channel
FOREIGN KEY (channel_id) REFERENCES channels(id)
);
CREATE INDEX idx_workflows_owner ON workflows (owner_pubkey);
-- Note: Partial index (WHERE status IN (...)) not supported; regular index used
CREATE INDEX idx_workflows_status ON workflows (status);
-- Note: Partial index (WHERE channel_id IS NOT NULL) not supported; regular index used
CREATE INDEX idx_workflows_channel ON workflows (channel_id);
-- ─── API Tokens ───────────────────────────────────────────────────────────────
CREATE TABLE api_tokens (
id BINARY(16) NOT NULL,
token_hash VARBINARY(32) NOT NULL UNIQUE,
owner_pubkey VARBINARY(32) NOT NULL,
name TEXT NOT NULL,
scopes JSON NOT NULL,
channel_ids JSON,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
expires_at DATETIME(6),
last_used_at DATETIME(6),
revoked_at DATETIME(6),
revoked_by VARBINARY(32),
PRIMARY KEY (id),
CONSTRAINT token_hash_length CHECK (LENGTH(token_hash) = 32),
CONSTRAINT fk_api_tokens_owner
FOREIGN KEY (owner_pubkey) REFERENCES users(pubkey)
);
-- Note: Partial indexes (WHERE revoked_at IS NULL) not supported; regular indexes used
CREATE INDEX idx_api_tokens_owner ON api_tokens (owner_pubkey);
CREATE INDEX idx_api_tokens_hash ON api_tokens (token_hash);
-- ─── Rate Limit Violations ────────────────────────────────────────────────────
CREATE TABLE rate_limit_violations (
id BIGINT NOT NULL AUTO_INCREMENT,
pubkey VARBINARY(32) NOT NULL,
violation_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
limit_type TEXT NOT NULL,
limit_value INT NOT NULL,
actual_value INT NOT NULL,
action_taken TEXT NOT NULL,
PRIMARY KEY (id)
);
CREATE INDEX idx_rate_violations_pubkey_time ON rate_limit_violations (pubkey, violation_at);

View File

@@ -1,19 +0,0 @@
CREATE TABLE IF NOT EXISTS workflow_runs (
id BINARY(16) NOT NULL,
workflow_id BINARY(16) NOT NULL,
status ENUM('pending','running','waiting_approval','completed','failed','cancelled')
NOT NULL DEFAULT 'pending',
trigger_event_id VARBINARY(32),
current_step INT NOT NULL DEFAULT 0,
execution_trace JSON NOT NULL,
started_at DATETIME(6),
completed_at DATETIME(6),
error_message TEXT,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (id),
CONSTRAINT fk_wr_workflow
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE
);
CREATE INDEX idx_wr_workflow ON workflow_runs (workflow_id);
CREATE INDEX idx_wr_status ON workflow_runs (status);

View File

@@ -1,25 +0,0 @@
CREATE TABLE IF NOT EXISTS workflow_approvals (
token VARCHAR(36) NOT NULL,
workflow_id BINARY(16) NOT NULL,
run_id BINARY(16) NOT NULL,
step_id VARCHAR(64) NOT NULL,
step_index INT NOT NULL,
approver_spec TEXT NOT NULL,
status ENUM('pending','granted','denied','expired')
NOT NULL DEFAULT 'pending',
approver_pubkey VARBINARY(32),
note TEXT,
granted_at DATETIME(6),
denied_at DATETIME(6),
expires_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (token),
CONSTRAINT fk_wa_workflow
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
CONSTRAINT fk_wa_run
FOREIGN KEY (run_id) REFERENCES workflow_runs(id) ON DELETE CASCADE
);
CREATE INDEX idx_wa_workflow ON workflow_approvals (workflow_id);
CREATE INDEX idx_wa_run ON workflow_approvals (run_id);
CREATE INDEX idx_wa_status ON workflow_approvals (status);

View File

@@ -1,43 +0,0 @@
-- Migration: Fix workflows table schema
--
-- The initial schema (20260306000001) placed run-state columns on the `workflows`
-- table. Those belong in `workflow_runs`. This migration:
-- 1. Converts existing status values to the new definition-only enum.
-- 2. Adds the `enabled` flag for soft-disabling without archiving.
-- 3. Drops all run-state columns that now live in `workflow_runs`.
--
-- Row conversion:
-- pending → active (was never activated, treat as active definition)
-- cancelled → archived (intentionally stopped)
-- everything else → active (running/waiting_approval/completed/failed were
-- run states, not definition states)
-- Step 1: Convert existing status values before changing the ENUM definition.
-- MySQL requires the value to be valid in the NEW enum before ALTER,
-- so we update first while the old enum is still in place.
UPDATE workflows SET status = 'active' WHERE status IN ('pending', 'running', 'waiting_approval', 'completed', 'failed');
UPDATE workflows SET status = 'archived' WHERE status = 'cancelled';
-- Step 2: Add the `enabled` column (default TRUE — all existing rows are enabled).
ALTER TABLE workflows
ADD COLUMN enabled BOOLEAN NOT NULL DEFAULT TRUE
AFTER status;
-- Step 3: Change the status ENUM to definition-only values.
ALTER TABLE workflows
MODIFY COLUMN status ENUM('active', 'disabled', 'archived') NOT NULL DEFAULT 'active';
-- Step 4: Drop run-state columns that belong in workflow_runs.
ALTER TABLE workflows
DROP COLUMN trigger_event_id,
DROP COLUMN current_step,
DROP COLUMN execution_trace,
DROP COLUMN started_at,
DROP COLUMN completed_at,
DROP COLUMN failed_at,
DROP COLUMN error_message;
-- Step 5: Add a composite index to support the trigger-matching query:
-- WHERE channel_id = ? AND status = 'active' AND enabled = TRUE
CREATE INDEX idx_workflows_channel_active
ON workflows (channel_id, status, enabled);

View File

@@ -1,9 +0,0 @@
-- Add trigger_context column to workflow_runs so that the original trigger data
-- is persisted and can be restored when a suspended workflow resumes after approval.
--
-- This fixes the bug where {{trigger.*}} template variables resolved to empty strings
-- in post-approval steps because TriggerContext::default() was used on resume.
--
-- NULL means no trigger context was captured (backwards-compatible with existing rows).
ALTER TABLE workflow_runs
ADD COLUMN trigger_context JSON DEFAULT NULL AFTER execution_trace;

View File

@@ -1,13 +0,0 @@
-- Add MAXVALUE catch-all partitions to prevent insert failures when
-- the pre-defined monthly partitions are exhausted (July 2026).
--
-- These are idempotent: MySQL's ADD PARTITION will fail if p_future
-- already exists, but sqlx only runs each migration once.
ALTER TABLE events ADD PARTITION (
PARTITION p_future VALUES LESS THAN MAXVALUE
);
ALTER TABLE delivery_log ADD PARTITION (
PARTITION p_future VALUES LESS THAN MAXVALUE
);

View File

@@ -1 +0,0 @@
CREATE INDEX idx_events_id ON events (id);

View File

@@ -1,16 +0,0 @@
-- Add topic and purpose fields to channels table.
--
-- topic: Current channel topic (short, visible in header).
-- topic_set_by: Pubkey of the user who last set the topic.
-- topic_set_at: When the topic was last set.
-- purpose: Channel purpose / description of intent.
-- purpose_set_by: Pubkey of the user who last set the purpose.
-- purpose_set_at: When the purpose was last set.
ALTER TABLE channels
ADD COLUMN topic TEXT AFTER description,
ADD COLUMN topic_set_by VARBINARY(32) AFTER topic,
ADD COLUMN topic_set_at DATETIME(6) AFTER topic_set_by,
ADD COLUMN purpose TEXT AFTER topic_set_at,
ADD COLUMN purpose_set_by VARBINARY(32) AFTER purpose,
ADD COLUMN purpose_set_at DATETIME(6) AFTER purpose_set_by;

View File

@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS thread_metadata (
event_created_at DATETIME(6) NOT NULL,
event_id VARBINARY(32) NOT NULL,
channel_id BINARY(16) NOT NULL,
parent_event_id VARBINARY(32),
parent_event_created_at DATETIME(6),
root_event_id VARBINARY(32),
root_event_created_at DATETIME(6),
depth INT NOT NULL DEFAULT 0,
reply_count INT NOT NULL DEFAULT 0,
descendant_count INT NOT NULL DEFAULT 0,
last_reply_at DATETIME(6),
broadcast TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (event_created_at, event_id),
CONSTRAINT fk_thread_channel FOREIGN KEY (channel_id) REFERENCES channels(id)
);
CREATE INDEX idx_thread_parent ON thread_metadata (parent_event_id);
CREATE INDEX idx_thread_root ON thread_metadata (root_event_id);
CREATE INDEX idx_thread_channel_depth ON thread_metadata (channel_id, depth, event_created_at);
CREATE INDEX idx_thread_event_id ON thread_metadata (event_id);

View File

@@ -1,2 +0,0 @@
ALTER TABLE events ADD COLUMN deleted_at DATETIME(6) DEFAULT NULL;
CREATE INDEX idx_events_deleted ON events (deleted_at);

View File

@@ -1,10 +0,0 @@
-- Add participant_hash column to channels for DM deduplication.
--
-- DMs are identified by the SHA-256 of their sorted participant pubkeys.
-- The unique index ensures that the same participant set maps to exactly one DM.
ALTER TABLE channels
ADD COLUMN participant_hash VARBINARY(32) AFTER max_members;
CREATE UNIQUE INDEX idx_channels_dm_hash
ON channels (participant_hash);

View File

@@ -1,35 +0,0 @@
-- Denormalized mentions table for indexed p-tag lookups.
-- Replaces JSON_CONTAINS full-table scans in feed queries.
CREATE TABLE IF NOT EXISTS event_mentions (
pubkey_hex VARCHAR(64) NOT NULL,
event_id VARBINARY(32) NOT NULL,
event_created_at DATETIME(6) NOT NULL,
channel_id BINARY(16) NULL,
event_kind INT UNSIGNED NOT NULL,
PRIMARY KEY (pubkey_hex, event_id),
INDEX idx_mentions_pubkey_time (pubkey_hex, event_created_at DESC),
INDEX idx_mentions_pubkey_kind_time (pubkey_hex, event_kind, event_created_at DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Backfill existing events that have p-tags.
-- JSON_TABLE extracts each tag sub-array; we filter for p-tags.
INSERT IGNORE INTO event_mentions (pubkey_hex, event_id, event_created_at, channel_id, event_kind)
SELECT
LOWER(jt.pubkey_hex),
e.id,
e.created_at,
e.channel_id,
e.kind
FROM events e,
JSON_TABLE(
e.tags,
'$[*]' COLUMNS (
tag_name VARCHAR(10) PATH '$[0]',
pubkey_hex VARCHAR(64) PATH '$[1]'
)
) AS jt
WHERE jt.tag_name = 'p'
AND jt.pubkey_hex IS NOT NULL
AND LENGTH(jt.pubkey_hex) = 64
AND jt.pubkey_hex REGEXP '^[0-9a-fA-F]{64}$'
AND e.deleted_at IS NULL;

View File

@@ -1,12 +0,0 @@
CREATE TABLE IF NOT EXISTS reactions (
event_created_at DATETIME(6) NOT NULL,
event_id VARBINARY(32) NOT NULL,
pubkey VARBINARY(32) NOT NULL,
emoji VARCHAR(64) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
removed_at DATETIME(6),
PRIMARY KEY (event_created_at, event_id, pubkey, emoji)
);
CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (pubkey);

View File

@@ -1,4 +0,0 @@
ALTER TABLE reactions
ADD COLUMN reaction_event_id VARBINARY(32) NULL AFTER emoji;
CREATE INDEX idx_reactions_source_event ON reactions (reaction_event_id);

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN about TEXT DEFAULT NULL AFTER avatar_url;

View File

@@ -1,4 +0,0 @@
-- Upgrade the reaction_event_id index from non-unique to unique.
-- Prevents duplicate kind:7 events from creating orphaned reaction rows.
DROP INDEX idx_reactions_source_event ON reactions;
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id);

View File

@@ -1,8 +0,0 @@
-- Migration: add agent channel protection fields to users table
ALTER TABLE users
ADD COLUMN agent_owner_pubkey VARBINARY(32) NULL,
ADD COLUMN channel_add_policy ENUM('anyone', 'owner_only', 'nobody')
NOT NULL DEFAULT 'anyone',
ADD CONSTRAINT fk_users_agent_owner
FOREIGN KEY (agent_owner_pubkey) REFERENCES users(pubkey)
ON DELETE SET NULL;

View File

@@ -1,6 +0,0 @@
-- Add created_by_self_mint column to api_tokens.
-- Tracks whether a token was minted via POST /api/tokens (self-service)
-- or via the sprout-admin CLI.
ALTER TABLE api_tokens
ADD COLUMN created_by_self_mint TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = minted via POST /api/tokens, 0 = minted via sprout-admin CLI';

View File

@@ -1,10 +0,0 @@
-- Pubkey allowlist for NIP-42 authentication.
-- When SPROUT_PUBKEY_ALLOWLIST=true, pubkeys in this table may authenticate
-- via NIP-42 keypair-only (no JWT or API token required).
CREATE TABLE IF NOT EXISTS pubkey_allowlist (
pubkey BINARY(32) NOT NULL,
added_by BINARY(32),
added_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
note TEXT,
PRIMARY KEY (pubkey)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -1,5 +0,0 @@
-- Migrate legacy kind:40001 (pre-NIP-29 stream messages) to kind:9 (NIP-29 group chat).
-- This is a one-time migration. The application code already uses kind:9 for new messages.
-- The desktop client includes 40001 in CHANNEL_EVENT_KINDS for backward compat during
-- the transition window; this migration makes that unnecessary once complete.
UPDATE events SET kind = 9 WHERE kind = 40001;

View File

@@ -1,10 +0,0 @@
-- Composite index for addressable event replacement locking.
-- replace_addressable_event() uses SELECT ... FOR UPDATE on
-- (kind, pubkey, channel_id, deleted_at IS NULL) to serialize concurrent
-- writers for the same logical address. Without this index, InnoDB cannot
-- take a gap lock on a cold address (no existing rows), allowing two
-- concurrent first-time emissions to both insert.
--
-- The index covers the exact WHERE clause and enables next-key locking
-- on the (kind, pubkey, channel_id) range even when no active row exists.
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);

3
rust-toolchain.toml Normal file
View File

@@ -0,0 +1,3 @@
[toolchain]
channel = "stable"
profile = "default"

316
schema/schema.sql Normal file
View File

@@ -0,0 +1,316 @@
-- Sprout — Declarative Postgres schema (managed by pgschema)
--
-- This file represents the desired state of the database schema.
-- Use `pgschema apply --file schema/schema.sql` to bring the database up to date.
-- ── Custom types ──────────────────────────────────────────────────────────────
CREATE TYPE channel_type AS ENUM ('stream', 'forum', 'dm', 'workflow');
CREATE TYPE channel_visibility AS ENUM ('open', 'private');
CREATE TYPE member_role AS ENUM ('owner', 'admin', 'member', 'guest', 'bot');
CREATE TYPE workflow_status AS ENUM ('active', 'disabled', 'archived');
CREATE TYPE run_status AS ENUM ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled');
CREATE TYPE approval_status AS ENUM ('pending', 'granted', 'denied', 'expired');
CREATE TYPE delivery_method AS ENUM ('webhook', 'websocket');
CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted');
CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit');
CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody');
-- ── Channels ──────────────────────────────────────────────────────────────────
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
channel_type channel_type NOT NULL DEFAULT 'stream',
visibility channel_visibility NOT NULL DEFAULT 'open',
description TEXT,
canvas TEXT,
created_by BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
nip29_group_id VARCHAR(255) UNIQUE,
topic_required BOOLEAN NOT NULL DEFAULT FALSE,
max_members INT,
topic TEXT,
topic_set_by BYTEA,
topic_set_at TIMESTAMPTZ,
purpose TEXT,
purpose_set_by BYTEA,
purpose_set_at TIMESTAMPTZ,
participant_hash BYTEA
);
CREATE INDEX idx_channels_type ON channels (channel_type);
CREATE INDEX idx_channels_visibility ON channels (visibility);
CREATE INDEX idx_channels_created_by ON channels (created_by);
CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash);
-- ── Channel members ───────────────────────────────────────────────────────────
CREATE TABLE channel_members (
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
pubkey BYTEA NOT NULL,
role member_role NOT NULL DEFAULT 'member',
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
invited_by BYTEA,
removed_at TIMESTAMPTZ,
removed_by BYTEA,
PRIMARY KEY (channel_id, pubkey)
);
-- ── Users ─────────────────────────────────────────────────────────────────────
CREATE TABLE users (
pubkey BYTEA PRIMARY KEY,
nip05_handle VARCHAR(255) UNIQUE,
display_name VARCHAR(255),
avatar_url TEXT,
about TEXT,
agent_type VARCHAR(255),
capabilities JSONB,
okta_user_id VARCHAR(255) UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deactivated_at TIMESTAMPTZ,
metadata_event_id BYTEA,
agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL,
channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone',
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32)
);
-- ── Events (partitioned by month on created_at) ──────────────────────────────
CREATE TABLE events (
id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
kind INT NOT NULL,
tags JSONB NOT NULL,
content TEXT NOT NULL,
sig BYTEA NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
channel_id UUID,
deleted_at TIMESTAMPTZ,
PRIMARY KEY (created_at, id)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_p2026_01 PARTITION OF events
FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE events_p2026_02 PARTITION OF events
FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
CREATE TABLE events_p2026_03 PARTITION OF events
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE events_p2026_04 PARTITION OF events
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE events_p2026_05 PARTITION OF events
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE events_p2026_06 PARTITION OF events
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE events_p_future PARTITION OF events
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at);
CREATE INDEX idx_events_channel_created ON events (channel_id, created_at);
CREATE INDEX idx_events_kind_created ON events (kind, created_at);
CREATE INDEX idx_events_id ON events (id);
CREATE INDEX idx_events_deleted ON events (deleted_at);
CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at);
-- ── Event mentions ────────────────────────────────────────────────────────────
CREATE TABLE event_mentions (
pubkey_hex VARCHAR(64) NOT NULL,
event_id BYTEA NOT NULL,
event_created_at TIMESTAMPTZ NOT NULL,
channel_id UUID,
event_kind INT,
PRIMARY KEY (pubkey_hex, event_id)
);
CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC);
CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC);
-- ── Subscriptions ─────────────────────────────────────────────────────────────
CREATE TABLE subscriptions (
id VARCHAR(255) PRIMARY KEY,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
filter_kinds JSONB,
filter_authors JSONB,
filter_channel_ids JSONB,
filter_since TIMESTAMPTZ,
filter_until TIMESTAMPTZ,
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
delivery_url TEXT,
status subscription_status NOT NULL DEFAULT 'active',
pause_reason pause_reason,
delivered_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
CREATE TABLE delivery_log (
id BIGINT GENERATED ALWAYS AS IDENTITY,
subscription_id VARCHAR(255),
event_id BYTEA,
method delivery_method,
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
success BOOLEAN,
http_status INT,
error_message TEXT,
attempt_number INT DEFAULT 1,
PRIMARY KEY (delivered_at, id)
) PARTITION BY RANGE (delivered_at);
CREATE TABLE delivery_log_p2026_03 PARTITION OF delivery_log
FOR VALUES FROM ('2026-03-01') TO ('2026-04-01');
CREATE TABLE delivery_log_p2026_04 PARTITION OF delivery_log
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE delivery_log_p2026_05 PARTITION OF delivery_log
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log
FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');
CREATE TABLE delivery_log_p_future PARTITION OF delivery_log
FOR VALUES FROM ('2026-07-01') TO (MAXVALUE);
-- ── Workflows ─────────────────────────────────────────────────────────────────
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(255) NOT NULL,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
channel_id UUID REFERENCES channels(id),
definition JSONB NOT NULL,
definition_hash BYTEA NOT NULL,
status workflow_status NOT NULL DEFAULT 'active',
enabled BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled);
-- ── Workflow runs ─────────────────────────────────────────────────────────────
CREATE TABLE workflow_runs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
status run_status NOT NULL DEFAULT 'pending',
trigger_event_id BYTEA,
current_step INT NOT NULL DEFAULT 0,
execution_trace JSONB NOT NULL DEFAULT '[]',
trigger_context JSONB,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
error_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id);
CREATE INDEX idx_workflow_runs_status ON workflow_runs (status);
-- ── Workflow approvals ────────────────────────────────────────────────────────
CREATE TABLE workflow_approvals (
token BYTEA PRIMARY KEY,
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE,
step_id VARCHAR(64) NOT NULL,
step_index INT NOT NULL,
approver_spec TEXT NOT NULL,
status approval_status NOT NULL DEFAULT 'pending',
approver_pubkey BYTEA,
note TEXT,
granted_at TIMESTAMPTZ,
denied_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id);
CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id);
CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status);
-- ── API tokens ────────────────────────────────────────────────────────────────
CREATE TABLE api_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash BYTEA NOT NULL UNIQUE,
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
name VARCHAR(255) NOT NULL,
scopes JSONB NOT NULL,
channel_ids JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ,
revoked_by BYTEA,
created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE,
CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32)
);
-- ── Rate limit violations ─────────────────────────────────────────────────────
CREATE TABLE rate_limit_violations (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
pubkey BYTEA,
violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
limit_type VARCHAR(64),
limit_value INT,
actual_value INT,
action_taken VARCHAR(64)
);
-- ── Thread metadata ───────────────────────────────────────────────────────────
CREATE TABLE thread_metadata (
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
channel_id UUID NOT NULL REFERENCES channels(id),
parent_event_id BYTEA,
parent_event_created_at TIMESTAMPTZ,
root_event_id BYTEA,
root_event_created_at TIMESTAMPTZ,
depth INT NOT NULL DEFAULT 0,
reply_count INT NOT NULL DEFAULT 0,
descendant_count INT NOT NULL DEFAULT 0,
last_reply_at TIMESTAMPTZ,
broadcast BOOLEAN NOT NULL DEFAULT FALSE,
PRIMARY KEY (event_created_at, event_id)
);
CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id);
CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id);
CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at);
CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id);
-- ── Reactions ─────────────────────────────────────────────────────────────────
CREATE TABLE reactions (
event_created_at TIMESTAMPTZ NOT NULL,
event_id BYTEA NOT NULL,
pubkey BYTEA NOT NULL,
emoji VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
removed_at TIMESTAMPTZ,
reaction_event_id BYTEA,
PRIMARY KEY (event_created_at, event_id, pubkey, emoji)
);
CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at);
CREATE INDEX idx_reactions_pubkey ON reactions (pubkey);
CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id);
-- ── Pubkey allowlist ──────────────────────────────────────────────────────────
CREATE TABLE pubkey_allowlist (
pubkey BYTEA PRIMARY KEY,
added_by BYTEA,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
note TEXT
);

View File

@@ -20,16 +20,16 @@ BLUE='\033[0;34m'
NC='\033[0m'
log() { echo -e "${BLUE}[dev-reset]${NC} $*"; }
success(){ echo -e "${GREEN}[dev-reset]${NC} ✅ $*"; }
warn() { echo -e "${YELLOW}[dev-reset]${NC} ⚠️ $*"; }
error() { echo -e "${RED}[dev-reset]${NC} ❌ $*" >&2; }
success(){ echo -e "${GREEN}[dev-reset]${NC} $*"; }
warn() { echo -e "${YELLOW}[dev-reset]${NC} $*"; }
error() { echo -e "${RED}[dev-reset]${NC} $*" >&2; }
cd "${REPO_ROOT}"
# ---- Confirm ----------------------------------------------------------------
if [[ "${1:-}" != "--yes" ]]; then
echo -e "${YELLOW}⚠️ WARNING: This will DELETE all local data (mysql, typesense volumes).${NC}"
echo -e "${YELLOW}WARNING: This will DELETE all local data (postgres, typesense volumes).${NC}"
echo -e " Redis data is ephemeral and always wiped on restart."
echo ""
read -r -p "Are you sure? [y/N] " confirm

View File

@@ -21,9 +21,9 @@ BLUE='\033[0;34m'
NC='\033[0m' # No Color
log() { echo -e "${BLUE}[dev-setup]${NC} $*"; }
success(){ echo -e "${GREEN}[dev-setup]${NC} ✅ $*"; }
warn() { echo -e "${YELLOW}[dev-setup]${NC} ⚠️ $*"; }
error() { echo -e "${RED}[dev-setup]${NC} ❌ $*" >&2; }
success(){ echo -e "${GREEN}[dev-setup]${NC} $*"; }
warn() { echo -e "${YELLOW}[dev-setup]${NC} $*"; }
error() { echo -e "${RED}[dev-setup]${NC} $*" >&2; }
# ---- Preflight checks -------------------------------------------------------
@@ -84,7 +84,7 @@ wait_healthy() {
}
echo ""
wait_healthy "MySQL" "sprout-mysql"
wait_healthy "Postgres" "sprout-postgres"
wait_healthy "Redis" "sprout-redis"
wait_healthy "Typesense" "sprout-typesense"
echo ""
@@ -93,31 +93,28 @@ echo ""
log "Running database migrations..."
MIGRATION_DIR="${REPO_ROOT}/migrations"
PGSCHEMA="${REPO_ROOT}/bin/pgschema"
SCHEMA_FILE="${REPO_ROOT}/schema/schema.sql"
if [[ ! -d "${MIGRATION_DIR}" ]]; then
warn "No migrations directory found at ${MIGRATION_DIR}. Skipping."
if [[ ! -f "${SCHEMA_FILE}" ]]; then
warn "No schema.sql found at ${SCHEMA_FILE}. Skipping."
else
# Check if sqlx CLI is available (preferred)
if command -v sqlx &>/dev/null; then
log "Using sqlx CLI for migrations..."
# MySQL may need a moment after healthcheck before accepting connections
if [[ -x "${PGSCHEMA}" ]]; then
log "Using pgschema for migrations..."
attempts=0
max_attempts=10
until DATABASE_URL="mysql://sprout:sprout_dev@localhost:3306/sprout" \
sqlx migrate run --source "${MIGRATION_DIR}" 2>/dev/null; do
until "${PGSCHEMA}" apply --file "${SCHEMA_FILE}" --auto-approve 2>/dev/null; do
attempts=$((attempts + 1))
if [[ ${attempts} -ge ${max_attempts} ]]; then
error "Failed to run migrations after ${max_attempts} attempts"
exit 1
fi
log "MySQL not ready for connections yet, retrying in 2s... (${attempts}/${max_attempts})"
log "Postgres not ready for connections yet, retrying in 2s... (${attempts}/${max_attempts})"
sleep 2
done
success "Migrations applied via sqlx"
success "Migrations applied via pgschema"
else
error "sqlx CLI not found. Install it with: cargo install sqlx-cli --no-default-features --features mysql"
error "Running migrations directly via mysql bypasses migration tracking and causes errors."
error "pgschema not found at ${PGSCHEMA}. Run: ./bin/hermit install pgschema"
exit 1
fi
fi
@@ -125,11 +122,11 @@ fi
# ---- Print connection info --------------------------------------------------
echo ""
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
echo -e "${GREEN} Sprout dev environment is ready! 🌱${NC}"
echo -e "${GREEN}═══════════════════════════════════════════════════════${NC}"
echo -e "${GREEN}=======================================================${NC}"
echo -e "${GREEN} Sprout dev environment is ready!${NC}"
echo -e "${GREEN}=======================================================${NC}"
echo ""
echo -e " ${BLUE}MySQL${NC} mysql://sprout:sprout_dev@localhost:3306/sprout"
echo -e " ${BLUE}Postgres${NC} postgres://sprout:sprout_dev@localhost:5432/sprout"
echo -e " ${BLUE}Redis${NC} redis://localhost:6379"
echo -e " ${BLUE}Typesense${NC} http://localhost:8108 (key: sprout_dev_key)"
echo -e " ${BLUE}Adminer${NC} http://localhost:8082 (DB browser)"

View File

@@ -14,12 +14,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
MODE="${1:-all}"
TIMEOUT=60 # seconds to wait for services if starting them
DB_USER="${SPROUT_DB_USER:-sprout}"
DB_PASS="${SPROUT_DB_PASS:-sprout_dev}"
DB_NAME="${SPROUT_DB_NAME:-sprout}"
DOCKER_DB_HOST="${SPROUT_DOCKER_DB_HOST:-mysql}"
DOCKER_NETWORK="${SPROUT_DOCKER_NETWORK:-sprout-net}"
MYSQL_CLIENT_IMAGE="${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}"
# Colors
RED='\033[0;31m'
@@ -30,20 +24,13 @@ CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${BLUE}[run-tests]${NC} $*"; }
success(){ echo -e "${GREEN}[run-tests]${NC} ✅ $*"; }
warn() { echo -e "${YELLOW}[run-tests]${NC} ⚠️ $*"; }
error() { echo -e "${RED}[run-tests]${NC} ❌ $*" >&2; }
success(){ echo -e "${GREEN}[run-tests]${NC} $*"; }
warn() { echo -e "${YELLOW}[run-tests]${NC} $*"; }
error() { echo -e "${RED}[run-tests]${NC} $*" >&2; }
section(){ echo -e "\n${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; echo -e "${CYAN} $*${NC}"; echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"; }
cd "${REPO_ROOT}"
run_mysql_in_container() {
docker run --rm -i --network "${DOCKER_NETWORK}" \
-e MYSQL_PWD="${DB_PASS}" \
"${MYSQL_CLIENT_IMAGE}" \
mysql -h"${DOCKER_DB_HOST}" -u"${DB_USER}" "${DB_NAME}" "$@"
}
# ---- Load .env if present ---------------------------------------------------
if [[ -f ".env" ]]; then
@@ -54,7 +41,12 @@ if [[ -f ".env" ]]; then
set +o allexport
else
# Use defaults matching docker-compose.yml
export DATABASE_URL="mysql://sprout:sprout_dev@localhost:3306/sprout"
export DATABASE_URL="postgres://sprout:sprout_dev@localhost:5432/sprout"
export PGHOST=localhost
export PGPORT=5432
export PGUSER=sprout
export PGPASSWORD=sprout_dev
export PGDATABASE=sprout
export REDIS_URL="redis://localhost:6379"
export TYPESENSE_API_KEY="sprout_dev_key"
export TYPESENSE_URL="http://localhost:8108"
@@ -81,10 +73,10 @@ run_test_step() {
# ---- Check / start infra (for integration tests) ----------------------------
services_healthy() {
local mysql_ok redis_ok
mysql_ok=$(docker inspect --format='{{.State.Health.Status}}' sprout-mysql 2>/dev/null || echo "not_found")
local pg_ok redis_ok
pg_ok=$(docker inspect --format='{{.State.Health.Status}}' sprout-postgres 2>/dev/null || echo "not_found")
redis_ok=$(docker inspect --format='{{.State.Health.Status}}' sprout-redis 2>/dev/null || echo "not_found")
[[ "${mysql_ok}" == "healthy" && "${redis_ok}" == "healthy" ]]
[[ "${pg_ok}" == "healthy" && "${redis_ok}" == "healthy" ]]
}
ensure_services() {
@@ -116,26 +108,20 @@ ensure_services() {
ensure_migrations() {
log "Ensuring migrations are current..."
local migration_dir="${REPO_ROOT}/migrations"
local pgschema="${REPO_ROOT}/bin/pgschema"
local schema_file="${REPO_ROOT}/schema/schema.sql"
if [[ ! -d "${migration_dir}" ]]; then
warn "No migrations directory. Skipping."
if [[ ! -f "${schema_file}" ]]; then
warn "No schema.sql. Skipping."
return 0
fi
if command -v sqlx &>/dev/null; then
DATABASE_URL="${DATABASE_URL}" sqlx migrate run --source "${migration_dir}" 2>/dev/null \
if [[ -x "${pgschema}" ]]; then
"${pgschema}" apply --file "${schema_file}" --auto-approve 2>/dev/null \
&& success "Migrations current" \
|| warn "sqlx migrate run failed — DB may be out of date"
|| warn "pgschema apply failed — DB may be out of date"
else
# Fallback: apply all SQL files (idempotent if schema uses IF NOT EXISTS)
shopt -s nullglob
local sql_files=("${migration_dir}"/*.sql)
shopt -u nullglob
for sql_file in "${sql_files[@]}"; do
run_mysql_in_container < "${sql_file}" &>/dev/null || true
done
success "Migrations applied (mysql fallback)"
warn "pgschema not found at ${pgschema}. Schema may be out of date."
fi
}
@@ -200,7 +186,7 @@ echo ""
if [[ ${#PASSED[@]} -gt 0 ]]; then
echo -e " ${GREEN}Passed (${#PASSED[@]}):${NC}"
for t in "${PASSED[@]}"; do
echo -e " ${GREEN}✅${NC} ${t}"
echo -e " ${GREEN}pass${NC} ${t}"
done
fi
@@ -208,12 +194,12 @@ if [[ ${#FAILED[@]} -gt 0 ]]; then
echo ""
echo -e " ${RED}Failed (${#FAILED[@]}):${NC}"
for t in "${FAILED[@]}"; do
echo -e " ${RED}❌${NC} ${t}"
echo -e " ${RED}fail${NC} ${t}"
done
echo ""
exit 1
fi
echo ""
success "All tests passed! 🎉"
success "All tests passed!"
exit 0

View File

@@ -3,13 +3,10 @@
set -euo pipefail
DB_HOST="${SPROUT_DB_HOST:-127.0.0.1}"
DB_PORT="${SPROUT_DB_PORT:-3306}"
DB_PORT="${SPROUT_DB_PORT:-5432}"
DB_USER="${SPROUT_DB_USER:-sprout}"
DB_PASS="${SPROUT_DB_PASS:-sprout_dev}"
DB_NAME="${SPROUT_DB_NAME:-sprout}"
DOCKER_DB_HOST="${SPROUT_DOCKER_DB_HOST:-mysql}"
DOCKER_NETWORK="${SPROUT_DOCKER_NETWORK:-sprout-net}"
MYSQL_CLIENT_IMAGE="${SPROUT_DB_CLIENT_IMAGE:-mysql:8.0}"
SYSTEM_PUBKEY="0000000000000000000000000000000000000000000000000000000000000000"
ALICE_PUBKEY="953d3363262e86b770419834c53d2446409db6d918a57f8f339d495d54ab001f"
@@ -18,29 +15,28 @@ CHARLIE_PUBKEY="554cef57437abac34522ac2c9f0490d685b72c80478cf9f7ed6f9570ee8624ea
TYLER_PUBKEY="e5ebc6cdb579be112e336cc319b5989b4bb6af11786ea90dbe52b5f08d741b34"
AGENT_PUBKEY="db0b028cd36f4d3e36c8300cce87252c1f7fc9495ffecc53f393fcac341ffd36"
if command -v mysql >/dev/null 2>&1; then
run_mysql() { MYSQL_PWD="$DB_PASS" mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" "$DB_NAME" "$@"; }
elif docker exec sprout-mysql mysql --version >/dev/null 2>&1; then
run_mysql() {
docker run --rm -i --network "$DOCKER_NETWORK" \
-e MYSQL_PWD="$DB_PASS" \
"$MYSQL_CLIENT_IMAGE" \
mysql -h"$DOCKER_DB_HOST" -u"$DB_USER" "$DB_NAME" "$@"
if command -v psql >/dev/null 2>&1; then
run_psql() { PGPASSWORD="$DB_PASS" psql -h"$DB_HOST" -p"$DB_PORT" -U"$DB_USER" -d"$DB_NAME" -qtA "$@"; }
elif docker exec sprout-postgres psql --version >/dev/null 2>&1; then
run_psql() {
docker exec -e PGPASSWORD="$DB_PASS" sprout-postgres \
psql -U"$DB_USER" -d"$DB_NAME" -qtA "$@"
}
else
echo "No mysql client available. Start docker compose or install mysql." >&2
echo "No psql client available. Start docker compose or install postgresql-client." >&2
exit 1
fi
run_sql() {
run_mysql --silent --skip-column-names -e "$1"
run_psql -c "$1"
}
uuid5_hex() {
local slug="$1"
python3 - "$slug" <<'PYEOF'
import sys, uuid
print(uuid.uuid5(uuid.NAMESPACE_DNS, sys.argv[1]).hex)
# Format as UUID with hyphens for Postgres
print(str(uuid.uuid5(uuid.NAMESPACE_DNS, sys.argv[1])))
PYEOF
}
@@ -57,47 +53,47 @@ UUID_DM_ALICE_TYLER=$(uuid5_hex "sprout.channel.dm.alice-tyler")
UUID_DM_BOB_TYLER=$(uuid5_hex "sprout.channel.dm.bob-tyler")
UUID_DM_BOB_CHARLIE_TYLER=$(uuid5_hex "sprout.channel.dm.bob-charlie-tyler")
SYSTEM_HEX="UNHEX('${SYSTEM_PUBKEY}')"
run_sql "
INSERT IGNORE INTO channels
INSERT INTO channels
(id, name, channel_type, visibility, description, created_by, topic_required)
VALUES
(UNHEX('${UUID_GENERAL}'), 'general', 'stream', 'open', 'General discussion for everyone', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_RANDOM}'), 'random', 'stream', 'open', 'Off-topic, fun stuff', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_ENGINEERING}'), 'engineering', 'stream', 'open', 'Engineering discussions', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_AGENTS}'), 'agents', 'stream', 'open', 'AI agent testing and collaboration', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_WATERCOOLER}'), 'watercooler', 'forum', 'open', 'Casual forum for async discussions', ${SYSTEM_HEX}, 1),
(UNHEX('${UUID_ANNOUNCEMENTS}'), 'announcements', 'forum', 'open', 'Company announcements', ${SYSTEM_HEX}, 1),
(UNHEX('${UUID_DM_ALICE_TYLER}'), 'alice-tyler', 'dm', 'private', 'DM between alice and tyler', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_DM_BOB_TYLER}'), 'bob-tyler', 'dm', 'private', 'DM between bob and tyler', ${SYSTEM_HEX}, 0),
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), 'bob-charlie-tyler', 'dm', 'private', 'Group DM: bob, charlie, tyler', ${SYSTEM_HEX}, 0)
('${UUID_GENERAL}', 'general', 'stream', 'open', 'General discussion for everyone', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_RANDOM}', 'random', 'stream', 'open', 'Off-topic, fun stuff', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_ENGINEERING}', 'engineering', 'stream', 'open', 'Engineering discussions', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_AGENTS}', 'agents', 'stream', 'open', 'AI agent testing and collaboration', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_WATERCOOLER}', 'watercooler', 'forum', 'open', 'Casual forum for async discussions', decode('${SYSTEM_PUBKEY}','hex'), true),
('${UUID_ANNOUNCEMENTS}', 'announcements', 'forum', 'open', 'Company announcements', decode('${SYSTEM_PUBKEY}','hex'), true),
('${UUID_DM_ALICE_TYLER}', 'alice-tyler', 'dm', 'private', 'DM between alice and tyler', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_DM_BOB_TYLER}', 'bob-tyler', 'dm', 'private', 'DM between bob and tyler', decode('${SYSTEM_PUBKEY}','hex'), false),
('${UUID_DM_BOB_CHARLIE_TYLER}', 'bob-charlie-tyler', 'dm', 'private', 'Group DM: bob, charlie, tyler', decode('${SYSTEM_PUBKEY}','hex'), false)
ON CONFLICT DO NOTHING
;
"
run_sql "
INSERT IGNORE INTO channel_members
INSERT INTO channel_members
(channel_id, pubkey, role, invited_by)
VALUES
(UNHEX('${UUID_GENERAL}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_GENERAL}'), UNHEX('${ALICE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_GENERAL}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_RANDOM}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_ENGINEERING}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_AGENTS}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_WATERCOOLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_ANNOUNCEMENTS}'), UNHEX('${TYLER_PUBKEY}'), 'guest', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${ALICE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_ALICE_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_BOB_TYLER}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_BOB_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${BOB_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${CHARLIE_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_DM_BOB_CHARLIE_TYLER}'), UNHEX('${TYLER_PUBKEY}'), 'member', ${SYSTEM_HEX}),
(UNHEX('${UUID_GENERAL}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
(UNHEX('${UUID_RANDOM}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
(UNHEX('${UUID_ENGINEERING}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX}),
(UNHEX('${UUID_AGENTS}'), UNHEX('${AGENT_PUBKEY}'), 'bot', ${SYSTEM_HEX})
('${UUID_GENERAL}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_GENERAL}', decode('${ALICE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_GENERAL}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_RANDOM}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_ENGINEERING}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_AGENTS}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_WATERCOOLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_ANNOUNCEMENTS}', decode('${TYLER_PUBKEY}','hex'), 'guest', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_ALICE_TYLER}', decode('${ALICE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_ALICE_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_BOB_TYLER}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_BOB_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_BOB_CHARLIE_TYLER}', decode('${BOB_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_BOB_CHARLIE_TYLER}', decode('${CHARLIE_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_DM_BOB_CHARLIE_TYLER}', decode('${TYLER_PUBKEY}','hex'), 'member', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_GENERAL}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_RANDOM}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_ENGINEERING}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex')),
('${UUID_AGENTS}', decode('${AGENT_PUBKEY}','hex'), 'bot', decode('${SYSTEM_PUBKEY}','hex'))
ON CONFLICT DO NOTHING
;
"