mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: add pgschema apply step to CI before relay start
The relay no longer runs migrations itself — pgschema must be invoked explicitly after Postgres is healthy and before the relay starts. Co-authored-by: Claude Code <noreply@anthropic.com> Ai-assisted: true
This commit is contained in:
co-authored by
Claude Code
parent
688846e9bb
commit
2d24656a45
@@ -133,6 +133,14 @@ jobs:
|
||||
wait_healthy "Postgres" "sprout-postgres"
|
||||
wait_healthy "Redis" "sprout-redis"
|
||||
wait_healthy "Typesense" "sprout-typesense"
|
||||
- name: Apply database schema
|
||||
run: ./bin/pgschema apply --file schema/schema.sql --auto-approve
|
||||
env:
|
||||
PGHOST: localhost
|
||||
PGPORT: "5432"
|
||||
PGUSER: sprout
|
||||
PGPASSWORD: sprout_dev
|
||||
PGDATABASE: sprout
|
||||
- name: Build relay
|
||||
run: cargo build -p sprout-relay
|
||||
- name: Start relay
|
||||
|
||||
@@ -9,7 +9,6 @@ use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
/// Whether a channel is publicly visible or invite-only.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -214,7 +213,6 @@ pub async fn create_channel(
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let id_bytes = id.as_bytes().as_slice().to_vec();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
@@ -224,7 +222,7 @@ pub async fn create_channel(
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(channel_type.as_str())
|
||||
.bind(visibility.as_str())
|
||||
@@ -243,7 +241,7 @@ pub async fn create_channel(
|
||||
role = EXCLUDED.role
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.bind(created_by)
|
||||
.bind(created_by)
|
||||
.execute(&mut *tx)
|
||||
@@ -259,7 +257,7 @@ pub async fn create_channel(
|
||||
FROM channels WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -270,8 +268,6 @@ pub async fn create_channel(
|
||||
|
||||
/// Fetches a channel record by ID. Returns `ChannelNotFound` if missing or deleted.
|
||||
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(
|
||||
r#"
|
||||
SELECT id, name, channel_type, visibility, description, canvas,
|
||||
@@ -282,7 +278,7 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecor
|
||||
FROM channels WHERE id = $1 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
||||
@@ -292,9 +288,8 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecor
|
||||
|
||||
/// Returns the canvas content for a channel, if any.
|
||||
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 = $1 AND deleted_at IS NULL")
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
||||
@@ -303,10 +298,9 @@ pub async fn get_canvas(pool: &PgPool, channel_id: Uuid) -> Result<Option<String
|
||||
|
||||
/// Sets or clears the canvas content for a channel.
|
||||
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 = $1 WHERE id = $2 AND deleted_at IS NULL")
|
||||
.bind(canvas)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if rows.rows_affected() == 0 {
|
||||
@@ -340,8 +334,6 @@ pub async fn add_member(
|
||||
)));
|
||||
}
|
||||
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let channel = get_channel_tx(&mut tx, channel_id).await?;
|
||||
@@ -411,7 +403,7 @@ pub async fn add_member(
|
||||
role = EXCLUDED.role
|
||||
"#,
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.bind(effective_role.as_str())
|
||||
.bind(invited_by)
|
||||
@@ -424,7 +416,7 @@ pub async fn add_member(
|
||||
FROM channel_members WHERE channel_id = $1 AND pubkey = $2
|
||||
"#,
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
@@ -447,8 +439,6 @@ pub async fn remove_member(
|
||||
pubkey: &[u8],
|
||||
actor_pubkey: &[u8],
|
||||
) -> Result<()> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let is_self_remove = pubkey == actor_pubkey;
|
||||
@@ -475,7 +465,7 @@ pub async fn remove_member(
|
||||
"SELECT COUNT(*) as cnt FROM channel_members \
|
||||
WHERE channel_id = $1 AND role = 'owner' AND removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
let owner_count: i64 = row.try_get("cnt")?;
|
||||
@@ -494,7 +484,7 @@ pub async fn remove_member(
|
||||
"#,
|
||||
)
|
||||
.bind(actor_pubkey)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -509,13 +499,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: &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 = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
@@ -527,7 +516,6 @@ pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result
|
||||
///
|
||||
/// Returns an empty list if the channel has been soft-deleted.
|
||||
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
|
||||
@@ -538,7 +526,7 @@ pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result<Vec<MemberRe
|
||||
LIMIT 1000
|
||||
"#,
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_member_record).collect()
|
||||
@@ -567,10 +555,7 @@ pub async fn get_accessible_channel_ids(pool: &PgPool, pubkey: &[u8]) -> Result<
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| {
|
||||
let bytes: Vec<u8> = r.try_get("channel_id")?;
|
||||
uuid_from_bytes(&bytes)
|
||||
})
|
||||
.map(|r| Ok(r.try_get("channel_id")?))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -620,12 +605,11 @@ async fn get_active_role_tx(
|
||||
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 = $1 AND pubkey = $2 AND removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?;
|
||||
@@ -637,7 +621,6 @@ async fn get_channel_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
channel_id: Uuid,
|
||||
) -> Result<ChannelRecord> {
|
||||
let id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, channel_type, visibility, description, canvas,
|
||||
@@ -648,7 +631,7 @@ async fn get_channel_tx(
|
||||
FROM channels WHERE id = $1 AND deleted_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
||||
@@ -829,8 +812,7 @@ pub async fn get_users_bulk(pool: &PgPool, pubkeys: &[Vec<u8>]) -> Result<Vec<Us
|
||||
}
|
||||
|
||||
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 id: Uuid = row.try_get("id")?;
|
||||
let topic_required: bool = row.try_get("topic_required")?;
|
||||
|
||||
// topic/purpose fields are new — use try_get and fall back to None if the
|
||||
@@ -867,8 +849,7 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
|
||||
}
|
||||
|
||||
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)?;
|
||||
let channel_id: Uuid = row.try_get("channel_id")?;
|
||||
|
||||
Ok(MemberRecord {
|
||||
channel_id,
|
||||
@@ -905,8 +886,6 @@ 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.
|
||||
// Track parameter index for positional placeholders.
|
||||
let mut set_parts: Vec<String> = Vec::new();
|
||||
@@ -931,7 +910,7 @@ pub async fn update_channel(
|
||||
if let Some(ref desc) = updates.description {
|
||||
q = q.bind(desc);
|
||||
}
|
||||
q = q.bind(&id_bytes);
|
||||
q = q.bind(channel_id);
|
||||
|
||||
let result = q.execute(pool).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -943,14 +922,13 @@ pub async fn update_channel(
|
||||
|
||||
/// Sets the topic for a channel, recording who set it and when.
|
||||
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 = $1, topic_set_by = $2, topic_set_at = NOW() \
|
||||
WHERE id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(topic)
|
||||
.bind(set_by)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -966,14 +944,13 @@ pub async fn set_purpose(
|
||||
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 = $1, purpose_set_by = $2, purpose_set_at = NOW() \
|
||||
WHERE id = $3 AND deleted_at IS NULL",
|
||||
)
|
||||
.bind(purpose)
|
||||
.bind(set_by)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
@@ -987,11 +964,9 @@ 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: &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 = $1 AND deleted_at IS NULL")
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1011,7 +986,7 @@ pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> {
|
||||
"UPDATE channels SET archived_at = NOW() \
|
||||
WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NULL",
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1023,11 +998,9 @@ pub async fn archive_channel(pool: &PgPool, 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: &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 = $1 AND deleted_at IS NULL")
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1045,7 +1018,7 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> {
|
||||
"UPDATE channels SET archived_at = NULL \
|
||||
WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NOT NULL",
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1057,10 +1030,9 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> {
|
||||
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
|
||||
/// deleted or not found.
|
||||
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() WHERE id = $1 AND deleted_at IS NULL")
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -1069,11 +1041,10 @@ pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result<bool
|
||||
|
||||
/// Returns the count of active (non-removed) members in a channel.
|
||||
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 = $1 AND removed_at IS NULL",
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row.try_get("cnt")?)
|
||||
@@ -1087,8 +1058,6 @@ pub async fn get_member_counts_bulk(
|
||||
pool: &PgPool,
|
||||
channel_ids: &[Uuid],
|
||||
) -> Result<std::collections::HashMap<Uuid, i64>> {
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
if channel_ids.is_empty() {
|
||||
return Ok(std::collections::HashMap::new());
|
||||
}
|
||||
@@ -1099,7 +1068,7 @@ pub async fn get_member_counts_bulk(
|
||||
);
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push(") GROUP BY channel_id");
|
||||
|
||||
@@ -1107,8 +1076,7 @@ pub async fn get_member_counts_bulk(
|
||||
|
||||
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
let id: Uuid = row.try_get("channel_id")?;
|
||||
let cnt: i64 = row.try_get("cnt")?;
|
||||
map.insert(id, cnt);
|
||||
}
|
||||
@@ -1123,13 +1091,12 @@ pub async fn get_member_role(
|
||||
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 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 = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL",
|
||||
)
|
||||
.bind(&channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
@@ -10,7 +10,6 @@ use uuid::Uuid;
|
||||
|
||||
use crate::channel::ChannelRecord;
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
// -- Public structs -----------------------------------------------------------
|
||||
|
||||
@@ -154,7 +153,6 @@ pub async fn create_dm(
|
||||
};
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
let id_bytes = id.as_bytes().as_slice().to_vec();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -163,7 +161,7 @@ pub async fn create_dm(
|
||||
VALUES ($1, $2, 'dm', 'private', $3, $4)
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.bind(&name)
|
||||
.bind(created_by)
|
||||
.bind(hash.as_slice())
|
||||
@@ -182,7 +180,7 @@ pub async fn create_dm(
|
||||
role = EXCLUDED.role
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.bind(*pk)
|
||||
.bind(created_by)
|
||||
.execute(&mut *tx)
|
||||
@@ -199,7 +197,7 @@ pub async fn create_dm(
|
||||
FROM channels WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -222,9 +220,8 @@ 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 = $1")
|
||||
.bind(&cid_bytes)
|
||||
.bind(cid)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|r| r.try_get::<DateTime<Utc>, _>("updated_at"))
|
||||
@@ -279,8 +276,7 @@ pub async fn list_dms_for_user(
|
||||
let mut results = Vec::with_capacity(channel_rows.len());
|
||||
|
||||
for row in channel_rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("id")?;
|
||||
let channel_id = uuid_from_bytes(&id_bytes)?;
|
||||
let channel_id: Uuid = row.try_get("id")?;
|
||||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||||
let updated_at: DateTime<Utc> = row.try_get("updated_at")?;
|
||||
|
||||
@@ -295,7 +291,7 @@ pub async fn list_dms_for_user(
|
||||
ORDER BY cm.joined_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
@@ -363,8 +359,7 @@ pub async fn open_dm(
|
||||
// -- Row mapping --------------------------------------------------------------
|
||||
|
||||
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 id: Uuid = row.try_get("id")?;
|
||||
let topic_required: bool = row.try_get("topic_required")?;
|
||||
|
||||
Ok(ChannelRecord {
|
||||
|
||||
@@ -64,8 +64,6 @@ pub async fn insert_event(
|
||||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||||
let received_at = Utc::now();
|
||||
let channel_id_bytes: Option<[u8; 16]> = channel_id.map(|u| *u.as_bytes());
|
||||
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id)
|
||||
@@ -81,7 +79,7 @@ pub async fn insert_event(
|
||||
.bind(&event.content)
|
||||
.bind(sig_bytes.as_slice())
|
||||
.bind(received_at)
|
||||
.bind(channel_id_bytes.as_ref().map(|b| b.as_slice()))
|
||||
.bind(channel_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
@@ -129,7 +127,7 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result<Vec<StoredEve
|
||||
|
||||
if let Some(ch) = q.channel_id {
|
||||
qb.push(format!(" AND {col_prefix}channel_id = "))
|
||||
.push_bind(ch.as_bytes().to_vec());
|
||||
.push_bind(ch);
|
||||
}
|
||||
|
||||
if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) {
|
||||
@@ -179,8 +177,7 @@ pub(crate) fn row_to_stored_event(row: sqlx::postgres::PgRow) -> Result<Option<S
|
||||
let sig_bytes: Vec<u8> = row.try_get("sig")?;
|
||||
let received_at: DateTime<Utc> = row.try_get("received_at")?;
|
||||
|
||||
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()?;
|
||||
let channel_id: Option<Uuid> = row.try_get("channel_id")?;
|
||||
|
||||
// kind is stored as i32 (Postgres INT) but Nostr uses u16. Values > 65535 are corrupt.
|
||||
let kind_u16 = u16::try_from(kind_i32)
|
||||
@@ -282,13 +279,12 @@ pub async fn get_last_message_at(
|
||||
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 = $1 AND deleted_at IS NULL \
|
||||
ORDER BY created_at DESC LIMIT 1",
|
||||
)
|
||||
.bind(&id_bytes)
|
||||
.bind(channel_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
@@ -316,7 +312,7 @@ pub async fn get_last_message_at_bulk(
|
||||
);
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push(") GROUP BY channel_id");
|
||||
|
||||
@@ -324,8 +320,7 @@ pub async fn get_last_message_at_bulk(
|
||||
|
||||
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let id = uuid_from_bytes(&id_bytes)?;
|
||||
let id: Uuid = row.try_get("channel_id")?;
|
||||
let last_at: DateTime<Utc> = row.try_get("last_at")?;
|
||||
map.insert(id, last_at);
|
||||
}
|
||||
@@ -462,8 +457,6 @@ pub async fn insert_event_with_thread_metadata(
|
||||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||||
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
||||
let received_at = Utc::now();
|
||||
let channel_id_bytes: Option<[u8; 16]> = channel_id.map(|u| *u.as_bytes());
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// ── Insert event ──────────────────────────────────────────────────────────
|
||||
@@ -482,7 +475,7 @@ pub async fn insert_event_with_thread_metadata(
|
||||
.bind(&event.content)
|
||||
.bind(sig_bytes.as_slice())
|
||||
.bind(received_at)
|
||||
.bind(channel_id_bytes.as_ref().map(|b| b.as_slice()))
|
||||
.bind(channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -491,7 +484,6 @@ pub async fn insert_event_with_thread_metadata(
|
||||
// ── Insert thread metadata (if provided and event was actually inserted) ──
|
||||
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: bool = meta.broadcast;
|
||||
|
||||
let tm_result = sqlx::query(
|
||||
@@ -507,7 +499,7 @@ pub async fn insert_event_with_thread_metadata(
|
||||
)
|
||||
.bind(meta.event_created_at)
|
||||
.bind(meta.event_id)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.bind(meta.channel_id)
|
||||
.bind(meta.parent_event_id)
|
||||
.bind(meta.parent_event_created_at)
|
||||
.bind(meta.root_event_id)
|
||||
@@ -539,7 +531,7 @@ pub async fn insert_event_with_thread_metadata(
|
||||
)
|
||||
.bind(parent_ts)
|
||||
.bind(pid)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.bind(meta.channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -561,7 +553,7 @@ pub async fn insert_event_with_thread_metadata(
|
||||
)
|
||||
.bind(root_ts)
|
||||
.bind(root_id)
|
||||
.bind(ch_bytes.as_slice())
|
||||
.bind(meta.channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub async fn query_mentions(
|
||||
qb.push(" AND e.channel_id IN (");
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in accessible_channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push(")");
|
||||
}
|
||||
@@ -135,7 +135,7 @@ pub async fn query_needs_action(
|
||||
qb.push(" AND e.channel_id IN (");
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in accessible_channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push(")");
|
||||
}
|
||||
@@ -185,7 +185,7 @@ pub async fn query_activity(
|
||||
qb.push(" AND channel_id IN (");
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in accessible_channel_ids {
|
||||
sep.push_bind(id.as_bytes().to_vec());
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push(")");
|
||||
}
|
||||
|
||||
@@ -75,7 +75,6 @@ pub async fn insert_mentions(
|
||||
let created_at_secs = event.created_at.as_u64() as i64;
|
||||
let created_at = DateTime::from_timestamp(created_at_secs, 0)
|
||||
.ok_or(crate::error::DbError::InvalidTimestamp(created_at_secs))?;
|
||||
let channel_id_bytes = channel_id.map(|id| id.as_bytes().to_vec());
|
||||
let kind = event.kind.as_u16() as u32;
|
||||
|
||||
// Validate and normalize pubkeys, logging any malformed ones.
|
||||
@@ -110,7 +109,7 @@ pub async fn insert_mentions(
|
||||
b.push_bind(pubkey.as_str())
|
||||
.push_bind(event_id_bytes.as_slice())
|
||||
.push_bind(created_at)
|
||||
.push_bind(channel_id_bytes.as_deref())
|
||||
.push_bind(channel_id)
|
||||
.push_bind(kind as i32);
|
||||
});
|
||||
|
||||
@@ -1185,12 +1184,11 @@ impl Db {
|
||||
channel_id: Uuid,
|
||||
relay_pubkey: &[u8],
|
||||
) -> Result<u64> {
|
||||
let channel_id_bytes = channel_id.as_bytes().to_vec();
|
||||
let result = sqlx::query(
|
||||
"UPDATE events SET deleted_at = NOW() \
|
||||
WHERE channel_id = $1 AND pubkey = $2 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)",
|
||||
)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.bind(channel_id)
|
||||
.bind(relay_pubkey)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -1208,8 +1206,6 @@ impl Db {
|
||||
) -> Result<(StoredEvent, bool)> {
|
||||
let kind_i32 = sprout_core::kind::event_kind_i32(event);
|
||||
let pubkey_bytes = event.pubkey.to_bytes();
|
||||
let channel_id_bytes: Option<Vec<u8>> = channel_id.map(|u| u.as_bytes().to_vec());
|
||||
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// Soft-delete existing events with the same (kind, pubkey, channel_id).
|
||||
@@ -1220,7 +1216,7 @@ impl Db {
|
||||
)
|
||||
.bind(kind_i32)
|
||||
.bind(pubkey_bytes.as_slice())
|
||||
.bind(channel_id_bytes.as_deref())
|
||||
.bind(channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
// -- Structs ------------------------------------------------------------------
|
||||
|
||||
@@ -120,8 +119,6 @@ pub async fn insert_thread_metadata(
|
||||
depth: i32,
|
||||
broadcast: bool,
|
||||
) -> Result<()> {
|
||||
let channel_id_bytes = channel_id.as_bytes().as_slice().to_vec();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
let result = sqlx::query(
|
||||
@@ -137,7 +134,7 @@ pub async fn insert_thread_metadata(
|
||||
)
|
||||
.bind(event_created_at)
|
||||
.bind(event_id)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.bind(channel_id)
|
||||
.bind(parent_event_id)
|
||||
.bind(parent_event_created_at)
|
||||
.bind(root_event_id)
|
||||
@@ -168,7 +165,7 @@ pub async fn insert_thread_metadata(
|
||||
)
|
||||
.bind(parent_ts)
|
||||
.bind(pid)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.bind(channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -189,7 +186,7 @@ pub async fn insert_thread_metadata(
|
||||
)
|
||||
.bind(root_ts)
|
||||
.bind(root_id)
|
||||
.bind(channel_id_bytes.as_slice())
|
||||
.bind(channel_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
@@ -392,7 +389,7 @@ pub async fn get_thread_replies(
|
||||
let event_id: Vec<u8> = row.try_get("event_id")?;
|
||||
let parent_event_id: Option<Vec<u8>> = row.try_get("parent_event_id")?;
|
||||
let root_event_id_col: Option<Vec<u8>> = row.try_get("root_event_id")?;
|
||||
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let channel_id: Uuid = row.try_get("channel_id")?;
|
||||
let pubkey: Vec<u8> = row.try_get("pubkey")?;
|
||||
let tags: serde_json::Value = row.try_get("tags")?;
|
||||
let content: String = row.try_get("content")?;
|
||||
@@ -401,8 +398,6 @@ pub async fn get_thread_replies(
|
||||
let created_at: DateTime<Utc> = row.try_get("event_created_at")?;
|
||||
let broadcast_val: bool = row.try_get("broadcast")?;
|
||||
|
||||
let channel_id = uuid_from_bytes(&channel_id_bytes)?;
|
||||
|
||||
replies.push(ThreadReply {
|
||||
event_id,
|
||||
parent_event_id,
|
||||
@@ -495,8 +490,6 @@ pub async fn get_channel_messages_top_level(
|
||||
before_cursor: Option<DateTime<Utc>>,
|
||||
kind_filter: Option<&[u32]>,
|
||||
) -> 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#"
|
||||
@@ -540,7 +533,7 @@ pub async fn get_channel_messages_top_level(
|
||||
|
||||
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());
|
||||
let mut q = sqlx::query(&sql).bind(channel_id);
|
||||
|
||||
if let Some(cursor) = before_cursor {
|
||||
q = q.bind(cursor);
|
||||
@@ -557,8 +550,7 @@ pub async fn get_channel_messages_top_level(
|
||||
let content: String = row.try_get("content")?;
|
||||
let kind: i32 = row.try_get("kind")?;
|
||||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||||
let channel_id_col: Vec<u8> = row.try_get("channel_id_bytes")?;
|
||||
let ch_id = uuid_from_bytes(&channel_id_col)?;
|
||||
let ch_id: Uuid = row.try_get("channel_id_bytes")?;
|
||||
|
||||
messages.push(TopLevelMessage {
|
||||
event_id,
|
||||
@@ -611,7 +603,7 @@ pub async fn get_thread_metadata_by_event(
|
||||
|
||||
let event_id_col: Vec<u8> = row.try_get("event_id")?;
|
||||
let event_created_at: DateTime<Utc> = row.try_get("event_created_at")?;
|
||||
let channel_id_bytes: Vec<u8> = row.try_get("channel_id")?;
|
||||
let channel_id: Uuid = row.try_get("channel_id")?;
|
||||
let parent_event_id: Option<Vec<u8>> = row.try_get("parent_event_id")?;
|
||||
let root_event_id: Option<Vec<u8>> = row.try_get("root_event_id")?;
|
||||
let depth: i32 = row.try_get("depth")?;
|
||||
@@ -619,8 +611,6 @@ pub async fn get_thread_metadata_by_event(
|
||||
let descendant_count: i32 = row.try_get("descendant_count")?;
|
||||
let broadcast_val: bool = row.try_get("broadcast")?;
|
||||
|
||||
let channel_id = uuid_from_bytes(&channel_id_bytes)?;
|
||||
|
||||
Ok(Some(ThreadMetadataRecord {
|
||||
event_id: event_id_col,
|
||||
event_created_at,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Workflow CRUD -- workflows, workflow_runs, and workflow_approvals tables.
|
||||
//!
|
||||
//! All IDs are stored as BINARY(16) (UUID bytes). Never uses string interpolation
|
||||
//! All IDs are stored as native UUID columns. Never uses string interpolation
|
||||
//! for query values -- all user data goes through bind parameters.
|
||||
//!
|
||||
//! Security notes:
|
||||
@@ -16,7 +16,6 @@ use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
use crate::event::uuid_from_bytes;
|
||||
|
||||
// -- Token hashing ------------------------------------------------------------
|
||||
|
||||
@@ -252,7 +251,6 @@ pub async fn create_workflow(
|
||||
definition_hash: &[u8],
|
||||
) -> Result<Uuid> {
|
||||
let id = Uuid::new_v4();
|
||||
let channel_id_bytes: Option<Vec<u8>> = channel_id.map(|u| u.as_bytes().to_vec());
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
@@ -261,10 +259,10 @@ pub async fn create_workflow(
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'active', TRUE)
|
||||
"#,
|
||||
)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.bind(name)
|
||||
.bind(owner_pubkey)
|
||||
.bind(channel_id_bytes)
|
||||
.bind(channel_id)
|
||||
.bind(definition_json)
|
||||
.bind(definition_hash)
|
||||
.execute(pool)
|
||||
@@ -283,7 +281,7 @@ pub async fn get_workflow(pool: &PgPool, id: Uuid) -> Result<WorkflowRecord> {
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("workflow {id}")))?;
|
||||
@@ -314,7 +312,7 @@ pub async fn list_channel_workflows(
|
||||
LIMIT $2 OFFSET $3
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id.as_bytes().to_vec())
|
||||
.bind(channel_id)
|
||||
.bind(limit)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
@@ -345,7 +343,7 @@ pub async fn list_enabled_channel_workflows(
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(channel_id.as_bytes().to_vec())
|
||||
.bind(channel_id)
|
||||
.bind(LIST_MAX_LIMIT)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
@@ -396,7 +394,7 @@ pub async fn update_workflow(
|
||||
.bind(name)
|
||||
.bind(definition_json)
|
||||
.bind(definition_hash)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
@@ -417,7 +415,7 @@ pub async fn update_workflow_status(pool: &PgPool, id: Uuid, status: WorkflowSta
|
||||
"#,
|
||||
)
|
||||
.bind(status.to_string())
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
@@ -438,7 +436,7 @@ pub async fn set_workflow_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Res
|
||||
"#,
|
||||
)
|
||||
.bind(enabled)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
@@ -452,7 +450,7 @@ pub async fn set_workflow_enabled(pool: &PgPool, id: Uuid, enabled: bool) -> Res
|
||||
/// Delete a workflow and all its runs/approvals (CASCADE).
|
||||
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())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
@@ -485,8 +483,8 @@ pub async fn create_workflow_run(
|
||||
VALUES ($1, $2, 'pending', $3, 0, '[]', $4)
|
||||
"#,
|
||||
)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(workflow_id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.bind(workflow_id)
|
||||
.bind(trigger_event_id)
|
||||
.bind(trigger_context)
|
||||
.execute(pool)
|
||||
@@ -505,7 +503,7 @@ pub async fn get_workflow_run(pool: &PgPool, id: Uuid) -> Result<WorkflowRunReco
|
||||
WHERE id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.ok_or_else(|| DbError::NotFound(format!("workflow_run {id}")))?;
|
||||
@@ -530,7 +528,7 @@ pub async fn list_workflow_runs(
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(workflow_id.as_bytes().to_vec())
|
||||
.bind(workflow_id)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
@@ -573,7 +571,7 @@ pub async fn update_workflow_run(
|
||||
.bind(error)
|
||||
.bind(&status_str) // for started_at CASE
|
||||
.bind(&status_str) // for completed_at CASE
|
||||
.bind(id.as_bytes().to_vec())
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected();
|
||||
@@ -628,8 +626,8 @@ pub async fn create_approval(pool: &PgPool, params: CreateApprovalParams<'_>) ->
|
||||
"#,
|
||||
)
|
||||
.bind(token_hash)
|
||||
.bind(workflow_id.as_bytes().to_vec())
|
||||
.bind(run_id.as_bytes().to_vec())
|
||||
.bind(workflow_id)
|
||||
.bind(run_id)
|
||||
.bind(step_id)
|
||||
.bind(step_index)
|
||||
.bind(approver_spec)
|
||||
@@ -710,13 +708,8 @@ pub async fn update_approval(
|
||||
// -- Row mappers --------------------------------------------------------------
|
||||
|
||||
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)?;
|
||||
|
||||
let channel_id: Option<Uuid> = {
|
||||
let raw: Option<Vec<u8>> = row.try_get("channel_id")?;
|
||||
raw.map(|b| uuid_from_bytes(&b)).transpose()?
|
||||
};
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
let channel_id: Option<Uuid> = row.try_get("channel_id")?;
|
||||
|
||||
let status_str: String = row.try_get("status")?;
|
||||
let status = status_str.parse::<WorkflowStatus>()?;
|
||||
@@ -738,11 +731,8 @@ fn row_to_workflow_record(row: sqlx::postgres::PgRow) -> Result<WorkflowRecord>
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
let wf_bytes: Vec<u8> = row.try_get("workflow_id")?;
|
||||
let workflow_id = uuid_from_bytes(&wf_bytes)?;
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
let workflow_id: Uuid = row.try_get("workflow_id")?;
|
||||
|
||||
let status_str: String = row.try_get("status")?;
|
||||
let status = status_str.parse::<RunStatus>()?;
|
||||
@@ -763,11 +753,8 @@ fn row_to_run_record(row: sqlx::postgres::PgRow) -> Result<WorkflowRunRecord> {
|
||||
}
|
||||
|
||||
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)?;
|
||||
|
||||
let run_bytes: Vec<u8> = row.try_get("run_id")?;
|
||||
let run_id = uuid_from_bytes(&run_bytes)?;
|
||||
let workflow_id: Uuid = row.try_get("workflow_id")?;
|
||||
let run_id: Uuid = row.try_get("run_id")?;
|
||||
|
||||
let status_str: String = row.try_get("status")?;
|
||||
let status = status_str.parse::<ApprovalStatus>()?;
|
||||
@@ -1240,37 +1227,4 @@ mod tests {
|
||||
assert_eq!(record.status, ApprovalStatus::Pending);
|
||||
assert_eq!(cloned.status, ApprovalStatus::Granted);
|
||||
}
|
||||
|
||||
// -- uuid_from_bytes (helper) ---------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn uuid_from_bytes_round_trips() {
|
||||
let original = Uuid::new_v4();
|
||||
let bytes = original.as_bytes().to_vec();
|
||||
let recovered = uuid_from_bytes(&bytes).expect("uuid_from_bytes failed");
|
||||
assert_eq!(original, recovered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_from_bytes_rejects_wrong_length() {
|
||||
let bad_bytes = vec![0u8; 10]; // UUID requires exactly 16 bytes
|
||||
let err = uuid_from_bytes(&bad_bytes).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, DbError::InvalidData(_)),
|
||||
"expected InvalidData, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_from_bytes_rejects_empty() {
|
||||
let err = uuid_from_bytes(&[]).unwrap_err();
|
||||
assert!(matches!(err, DbError::InvalidData(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uuid_from_bytes_accepts_nil_uuid() {
|
||||
let nil_bytes = [0u8; 16];
|
||||
let result = uuid_from_bytes(&nil_bytes).expect("nil UUID should parse");
|
||||
assert_eq!(result, Uuid::nil());
|
||||
}
|
||||
}
|
||||
|
||||
+32
-32
@@ -2,27 +2,17 @@
|
||||
--
|
||||
-- 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');
|
||||
--
|
||||
-- All "enum" columns use TEXT + CHECK constraints instead of CREATE TYPE so that
|
||||
-- sqlx runtime queries can decode them as plain String without custom type registration.
|
||||
|
||||
-- ── 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',
|
||||
channel_type TEXT NOT NULL DEFAULT 'stream',
|
||||
visibility TEXT NOT NULL DEFAULT 'open',
|
||||
description TEXT,
|
||||
canvas TEXT,
|
||||
created_by BYTEA NOT NULL,
|
||||
@@ -39,7 +29,9 @@ CREATE TABLE channels (
|
||||
purpose TEXT,
|
||||
purpose_set_by BYTEA,
|
||||
purpose_set_at TIMESTAMPTZ,
|
||||
participant_hash BYTEA
|
||||
participant_hash BYTEA,
|
||||
CONSTRAINT chk_channel_type CHECK (channel_type IN ('stream', 'forum', 'dm', 'workflow')),
|
||||
CONSTRAINT chk_channel_visibility CHECK (visibility IN ('open', 'private'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_channels_type ON channels (channel_type);
|
||||
@@ -52,12 +44,13 @@ CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash);
|
||||
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',
|
||||
role TEXT 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)
|
||||
PRIMARY KEY (channel_id, pubkey),
|
||||
CONSTRAINT chk_member_role CHECK (role IN ('owner', 'admin', 'member', 'guest', 'bot'))
|
||||
);
|
||||
|
||||
-- ── Users ─────────────────────────────────────────────────────────────────────
|
||||
@@ -76,8 +69,9 @@ CREATE TABLE users (
|
||||
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)
|
||||
channel_add_policy TEXT NOT NULL DEFAULT 'anyone',
|
||||
CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32),
|
||||
CONSTRAINT chk_channel_add_policy CHECK (channel_add_policy IN ('anyone', 'owner_only', 'nobody'))
|
||||
);
|
||||
|
||||
-- ── Events (partitioned by month on created_at) ──────────────────────────────
|
||||
@@ -142,14 +136,17 @@ CREATE TABLE subscriptions (
|
||||
filter_channel_ids JSONB,
|
||||
filter_since TIMESTAMPTZ,
|
||||
filter_until TIMESTAMPTZ,
|
||||
delivery_method delivery_method NOT NULL DEFAULT 'webhook',
|
||||
delivery_method TEXT NOT NULL DEFAULT 'webhook',
|
||||
delivery_url TEXT,
|
||||
status subscription_status NOT NULL DEFAULT 'active',
|
||||
pause_reason pause_reason,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
pause_reason TEXT,
|
||||
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()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_delivery_method CHECK (delivery_method IN ('webhook', 'websocket')),
|
||||
CONSTRAINT chk_subscription_status CHECK (status IN ('active', 'paused', 'deleted')),
|
||||
CONSTRAINT chk_pause_reason CHECK (pause_reason IS NULL OR pause_reason IN ('user', 'system', 'rate_limit'))
|
||||
);
|
||||
|
||||
-- ── Delivery log (partitioned by month on delivered_at) ──────────────────────
|
||||
@@ -158,7 +155,7 @@ CREATE TABLE delivery_log (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY,
|
||||
subscription_id VARCHAR(255),
|
||||
event_id BYTEA,
|
||||
method delivery_method,
|
||||
method TEXT,
|
||||
delivered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
success BOOLEAN,
|
||||
http_status INT,
|
||||
@@ -187,10 +184,11 @@ CREATE TABLE workflows (
|
||||
channel_id UUID REFERENCES channels(id),
|
||||
definition JSONB NOT NULL,
|
||||
definition_hash BYTEA NOT NULL,
|
||||
status workflow_status NOT NULL DEFAULT 'active',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
enabled BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_workflow_status CHECK (status IN ('active', 'disabled', 'archived'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled);
|
||||
@@ -200,7 +198,7 @@ CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enab
|
||||
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',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
trigger_event_id BYTEA,
|
||||
current_step INT NOT NULL DEFAULT 0,
|
||||
execution_trace JSONB NOT NULL DEFAULT '[]',
|
||||
@@ -208,7 +206,8 @@ CREATE TABLE workflow_runs (
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_run_status CHECK (status IN ('pending', 'running', 'waiting_approval', 'completed', 'failed', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id);
|
||||
@@ -223,13 +222,14 @@ CREATE TABLE workflow_approvals (
|
||||
step_id VARCHAR(64) NOT NULL,
|
||||
step_index INT NOT NULL,
|
||||
approver_spec TEXT NOT NULL,
|
||||
status approval_status NOT NULL DEFAULT 'pending',
|
||||
status TEXT 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()
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_approval_status CHECK (status IN ('pending', 'granted', 'denied', 'expired'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id);
|
||||
@@ -239,7 +239,7 @@ CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status);
|
||||
-- ── API tokens ────────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE api_tokens (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
id BYTEA PRIMARY KEY,
|
||||
token_hash BYTEA NOT NULL UNIQUE,
|
||||
owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user