mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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:
20
.env.example
20
.env.example
@@ -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)
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -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 \
|
||||
|
||||
@@ -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
1
bin/.biome-2.4.7.pkg
Symbolic link
@@ -0,0 +1 @@
|
||||
hermit
|
||||
1
bin/.pgschema-1.7.4.pkg
Symbolic link
1
bin/.pgschema-1.7.4.pkg
Symbolic link
@@ -0,0 +1 @@
|
||||
hermit
|
||||
@@ -1,4 +1 @@
|
||||
manage-git = false
|
||||
|
||||
github-token-auth {
|
||||
}
|
||||
manage-git = true
|
||||
|
||||
1
bin/pgschema
Symbolic link
1
bin/pgschema
Symbolic link
@@ -0,0 +1 @@
|
||||
.pgschema-1.7.4.pkg
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
"#;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
"#,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")))?;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(_)),
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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:
|
||||
|
||||
16
justfile
16
justfile
@@ -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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
CREATE INDEX idx_events_id ON events (id);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE events ADD COLUMN deleted_at DATETIME(6) DEFAULT NULL;
|
||||
CREATE INDEX idx_events_deleted ON events (deleted_at);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN about TEXT DEFAULT NULL AFTER avatar_url;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
3
rust-toolchain.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
profile = "default"
|
||||
316
schema/schema.sql
Normal file
316
schema/schema.sql
Normal 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
|
||||
);
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
;
|
||||
"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user