@@ -58,6 +58,10 @@ pub struct ChannelRecord {
5858 pub purpose_set_by : Option < Vec < u8 > > ,
5959 /// When the purpose was last set.
6060 pub purpose_set_at : Option < DateTime < Utc > > ,
61+ /// TTL in seconds for ephemeral channels. `None` means permanent.
62+ pub ttl_seconds : Option < i32 > ,
63+ /// Deadline by which a new message must arrive or the channel is auto-archived.
64+ pub ttl_deadline : Option < DateTime < Utc > > ,
6165}
6266
6367/// A channel membership row as returned from the database.
@@ -85,6 +89,7 @@ pub async fn create_channel(
8589 visibility : ChannelVisibility ,
8690 description : Option < & str > ,
8791 created_by : & [ u8 ] ,
92+ ttl_seconds : Option < i32 > ,
8893) -> Result < ChannelRecord > {
8994 if created_by. len ( ) != 32 {
9095 return Err ( DbError :: InvalidData ( format ! (
@@ -99,8 +104,9 @@ pub async fn create_channel(
99104
100105 sqlx:: query (
101106 r#"
102- INSERT INTO channels (id, name, channel_type, visibility, description, created_by)
103- VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6)
107+ INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
108+ VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7,
109+ CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END)
104110 "# ,
105111 )
106112 . bind ( id)
@@ -109,6 +115,7 @@ pub async fn create_channel(
109115 . bind ( visibility. as_str ( ) )
110116 . bind ( description)
111117 . bind ( created_by)
118+ . bind ( ttl_seconds)
112119 . execute ( & mut * tx)
113120 . await ?;
114121
@@ -135,7 +142,8 @@ pub async fn create_channel(
135142 created_by, created_at, updated_at, archived_at, deleted_at,
136143 nip29_group_id, topic_required, max_members,
137144 topic, topic_set_by, topic_set_at,
138- purpose, purpose_set_by, purpose_set_at
145+ purpose, purpose_set_by, purpose_set_at,
146+ ttl_seconds, ttl_deadline
139147 FROM channels WHERE id = $1
140148 "# ,
141149 )
@@ -152,6 +160,7 @@ pub async fn create_channel(
152160///
153161/// Returns `(record, true)` if the channel was newly created, or `(record, false)` if a
154162/// channel with `channel_id` already exists (duplicate — caller should reject the event).
163+ #[ allow( clippy:: too_many_arguments) ]
155164pub async fn create_channel_with_id (
156165 pool : & PgPool ,
157166 channel_id : Uuid ,
@@ -160,6 +169,7 @@ pub async fn create_channel_with_id(
160169 visibility : ChannelVisibility ,
161170 description : Option < & str > ,
162171 created_by : & [ u8 ] ,
172+ ttl_seconds : Option < i32 > ,
163173) -> Result < ( ChannelRecord , bool ) > {
164174 if created_by. len ( ) != 32 {
165175 return Err ( DbError :: InvalidData ( format ! (
@@ -172,8 +182,9 @@ pub async fn create_channel_with_id(
172182
173183 let rows_affected = sqlx:: query (
174184 r#"
175- INSERT INTO channels (id, name, channel_type, visibility, description, created_by)
176- VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6)
185+ INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
186+ VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7,
187+ CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END)
177188 ON CONFLICT (id) DO NOTHING
178189 "# ,
179190 )
@@ -183,6 +194,7 @@ pub async fn create_channel_with_id(
183194 . bind ( visibility. as_str ( ) )
184195 . bind ( description)
185196 . bind ( created_by)
197+ . bind ( ttl_seconds)
186198 . execute ( & mut * tx)
187199 . await ?
188200 . rows_affected ( ) ;
@@ -215,7 +227,8 @@ pub async fn create_channel_with_id(
215227 created_by, created_at, updated_at, archived_at, deleted_at,
216228 nip29_group_id, topic_required, max_members,
217229 topic, topic_set_by, topic_set_at,
218- purpose, purpose_set_by, purpose_set_at
230+ purpose, purpose_set_by, purpose_set_at,
231+ ttl_seconds, ttl_deadline
219232 FROM channels WHERE id = $1
220233 "# ,
221234 )
@@ -237,7 +250,8 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result<ChannelRecor
237250 created_by, created_at, updated_at, archived_at, deleted_at,
238251 nip29_group_id, topic_required, max_members,
239252 topic, topic_set_by, topic_set_at,
240- purpose, purpose_set_by, purpose_set_at
253+ purpose, purpose_set_by, purpose_set_at,
254+ ttl_seconds, ttl_deadline
241255 FROM channels WHERE id = $1 AND deleted_at IS NULL
242256 "# ,
243257 )
@@ -535,7 +549,8 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
535549 created_by, created_at, updated_at, archived_at, deleted_at,
536550 nip29_group_id, topic_required, max_members,
537551 topic, topic_set_by, topic_set_at,
538- purpose, purpose_set_by, purpose_set_at
552+ purpose, purpose_set_by, purpose_set_at,
553+ ttl_seconds, ttl_deadline
539554 FROM channels
540555 WHERE deleted_at IS NULL AND visibility::text = $1
541556 ORDER BY created_at DESC
@@ -553,7 +568,8 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result<Ve
553568 created_by, created_at, updated_at, archived_at, deleted_at,
554569 nip29_group_id, topic_required, max_members,
555570 topic, topic_set_by, topic_set_at,
556- purpose, purpose_set_by, purpose_set_at
571+ purpose, purpose_set_by, purpose_set_at,
572+ ttl_seconds, ttl_deadline
557573 FROM channels
558574 WHERE deleted_at IS NULL
559575 ORDER BY created_at DESC
@@ -596,7 +612,8 @@ async fn get_channel_tx(
596612 created_by, created_at, updated_at, archived_at, deleted_at,
597613 nip29_group_id, topic_required, max_members,
598614 topic, topic_set_by, topic_set_at,
599- purpose, purpose_set_by, purpose_set_at
615+ purpose, purpose_set_by, purpose_set_at,
616+ ttl_seconds, ttl_deadline
600617 FROM channels WHERE id = $1 AND deleted_at IS NULL
601618 "# ,
602619 )
@@ -685,6 +702,7 @@ pub async fn get_accessible_channels(
685702 c.nip29_group_id, c.topic_required, c.max_members,
686703 c.topic, c.topic_set_by, c.topic_set_at,
687704 c.purpose, c.purpose_set_by, c.purpose_set_at,
705+ c.ttl_seconds, c.ttl_deadline,
688706 (cm.channel_id IS NOT NULL) AS is_member
689707 FROM channels c
690708 LEFT JOIN channel_members cm
@@ -807,6 +825,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
807825 let purpose: Option < String > = row. try_get ( "purpose" ) . unwrap_or ( None ) ;
808826 let purpose_set_by: Option < Vec < u8 > > = row. try_get ( "purpose_set_by" ) . unwrap_or ( None ) ;
809827 let purpose_set_at: Option < DateTime < Utc > > = row. try_get ( "purpose_set_at" ) . unwrap_or ( None ) ;
828+ let ttl_seconds: Option < i32 > = row. try_get ( "ttl_seconds" ) . unwrap_or ( None ) ;
829+ let ttl_deadline: Option < DateTime < Utc > > = row. try_get ( "ttl_deadline" ) . unwrap_or ( None ) ;
810830
811831 Ok ( ChannelRecord {
812832 id,
@@ -829,6 +849,8 @@ fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
829849 purpose,
830850 purpose_set_by,
831851 purpose_set_at,
852+ ttl_seconds,
853+ ttl_deadline,
832854 } )
833855}
834856
@@ -1086,3 +1108,42 @@ pub async fn get_member_role(
10861108 . await ?;
10871109 Ok ( row. map ( |r| r. try_get ( "role" ) ) . transpose ( ) ?)
10881110}
1111+
1112+ /// Bump the TTL deadline for an ephemeral channel after a new message.
1113+ ///
1114+ /// No-op for permanent channels or channels that are already archived/deleted.
1115+ pub async fn bump_ttl_deadline ( pool : & PgPool , channel_id : Uuid ) -> Result < ( ) > {
1116+ sqlx:: query (
1117+ "UPDATE channels SET ttl_deadline = NOW() + (ttl_seconds || ' seconds')::interval \
1118+ WHERE id = $1 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL",
1119+ )
1120+ . bind ( channel_id)
1121+ . execute ( pool)
1122+ . await ?;
1123+ Ok ( ( ) )
1124+ }
1125+
1126+ /// Archive ephemeral channels whose TTL deadline has passed.
1127+ ///
1128+ /// Returns the list of channel IDs that were archived. Idempotent — the
1129+ /// `archived_at IS NULL` guard prevents double-archiving even if called
1130+ /// concurrently from multiple relay pods.
1131+ pub async fn reap_expired_ephemeral_channels ( pool : & PgPool ) -> Result < Vec < Uuid > > {
1132+ let rows = sqlx:: query (
1133+ "UPDATE channels SET archived_at = NOW() \
1134+ WHERE ttl_seconds IS NOT NULL \
1135+ AND ttl_deadline < NOW() \
1136+ AND archived_at IS NULL \
1137+ AND deleted_at IS NULL \
1138+ RETURNING id",
1139+ )
1140+ . fetch_all ( pool)
1141+ . await ?;
1142+
1143+ rows. into_iter ( )
1144+ . map ( |row| {
1145+ let id: Uuid = row. try_get ( "id" ) ?;
1146+ Ok ( id)
1147+ } )
1148+ . collect ( )
1149+ }
0 commit comments