diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index f1558a99f..4393d2614 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -9,7 +9,9 @@ use nostr::Event; use sqlx::{PgPool, QueryBuilder, Row}; use uuid::Uuid; -use buzz_core::kind::{event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH}; +use buzz_core::kind::{ + event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, +}; use buzz_core::StoredEvent; use crate::error::{DbError, Result}; @@ -96,6 +98,26 @@ pub fn extract_d_tag(event: &Event) -> Option { Some(val) } +/// Extract the `not_before` timestamp for materialization in the `events` table. +/// +/// Only applies to `kind:30300` (NIP-ER event reminders). Returns the first +/// valid `not_before` tag value as an `i64` Unix timestamp, or `None` if the +/// event is not a reminder or has no `not_before` tag. +pub fn extract_not_before(event: &Event) -> Option { + let kind_u32 = event.kind.as_u16() as u32; + if kind_u32 != KIND_EVENT_REMINDER { + return None; + } + event.tags.iter().find_map(|tag| { + let parts = tag.as_slice(); + if parts.len() >= 2 && parts[0] == "not_before" { + parts[1].parse::().ok() + } else { + None + } + }) +} + /// Insert a Nostr event. Rejects AUTH and ephemeral kinds. /// /// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate. @@ -125,10 +147,11 @@ pub async fn insert_event( .ok_or(DbError::InvalidTimestamp(created_at_secs))?; let received_at = Utc::now(); let d_tag = extract_d_tag(event); + let not_before = extract_not_before(event); let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING "#, ) @@ -142,6 +165,7 @@ pub async fn insert_event( .bind(received_at) .bind(channel_id) .bind(d_tag.as_deref()) + .bind(not_before) .execute(pool) .await?; @@ -842,13 +866,14 @@ pub async fn insert_event_with_thread_metadata( .ok_or(DbError::InvalidTimestamp(created_at_secs))?; let received_at = Utc::now(); let d_tag = extract_d_tag(event); + let not_before = extract_not_before(event); let mut tx = pool.begin().await?; // ── Insert event ────────────────────────────────────────────────────────── let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT DO NOTHING "#, ) @@ -862,6 +887,7 @@ pub async fn insert_event_with_thread_metadata( .bind(received_at) .bind(channel_id) .bind(d_tag.as_deref()) + .bind(not_before) .execute(&mut *tx) .await?; @@ -981,6 +1007,101 @@ pub async fn insert_event_with_thread_metadata( )) } +/// A due reminder row returned by [`query_due_reminders`]. +#[derive(Debug)] +pub struct DueReminder { + /// The event's raw ID bytes. + pub id: Vec, + /// The event's pubkey bytes. + pub pubkey: Vec, + /// The event's `created_at` timestamp. + pub created_at: DateTime, + /// The event's kind (always 30300). + pub kind: i32, + /// The event's JSONB tags. + pub tags: serde_json::Value, + /// The event's encrypted content. + pub content: String, + /// The event's signature bytes. + pub sig: Vec, + /// The channel ID (always None for reminders — global events). + pub channel_id: Option, +} + +/// Query due reminders: latest-per-address `kind:30300` rows where +/// `not_before <= now`, `deleted_at IS NULL`, `delivered_at IS NULL`. +/// +/// Returns the latest head per `(pubkey, d_tag)` using canonical NIP-16 +/// ordering (`created_at DESC, id ASC`). +pub async fn query_due_reminders( + pool: &PgPool, + now_secs: i64, + batch_limit: i64, +) -> Result> { + let kind_i32 = KIND_EVENT_REMINDER as i32; + let rows = sqlx::query( + r#" + SELECT DISTINCT ON (pubkey, d_tag) + id, pubkey, created_at, kind, tags, content, sig, channel_id + FROM events + WHERE kind = $1 + AND not_before IS NOT NULL + AND not_before <= $2 + AND deleted_at IS NULL + AND delivered_at IS NULL + ORDER BY pubkey, d_tag, created_at DESC, id ASC + LIMIT $3 + "#, + ) + .bind(kind_i32) + .bind(now_secs) + .bind(batch_limit) + .fetch_all(pool) + .await?; + + let results = rows + .into_iter() + .map(|row| DueReminder { + id: row.get("id"), + pubkey: row.get("pubkey"), + created_at: row.get("created_at"), + kind: row.get("kind"), + tags: row.get("tags"), + content: row.get("content"), + sig: row.get("sig"), + channel_id: row.get("channel_id"), + }) + .collect(); + + Ok(results) +} + +/// Atomically claim a due reminder for delivery. Returns `Some(id)` if this +/// caller won the claim (set `delivered_at`), or `None` if another pod already +/// claimed it. Mirrors the reaper's `archived_at IS NULL` guard for cross-pod +/// idempotency. +pub async fn claim_due_reminder( + pool: &PgPool, + event_id: &[u8], + event_created_at: DateTime, +) -> Result { + let now_epoch = Utc::now().timestamp(); + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = $1 + WHERE created_at = $2 AND id = $3 AND delivered_at IS NULL + "#, + ) + .bind(now_epoch) + .bind(event_created_at) + .bind(event_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() > 0) +} + #[cfg(test)] mod tests { use super::*; @@ -1082,4 +1203,43 @@ mod tests { assert_eq!(result.len(), 2048); assert_eq!(result, long_val); } + + #[test] + fn extract_not_before_from_reminder() { + let event = make_event_with_kind_and_tags( + KIND_EVENT_REMINDER as u16, + vec![Tag::parse(["not_before", "1717000000"]).unwrap()], + ); + assert_eq!(extract_not_before(&event), Some(1_717_000_000)); + } + + #[test] + fn extract_not_before_absent_returns_none() { + // A bookmark/terminal reminder carries no `not_before` tag. + let event = make_event_with_kind_and_tags( + KIND_EVENT_REMINDER as u16, + vec![Tag::parse(["d", "abc"]).unwrap()], + ); + assert_eq!(extract_not_before(&event), None); + } + + #[test] + fn extract_not_before_non_reminder_returns_none() { + // Only kind:30300 materializes `not_before`; other kinds stay NULL. + let event = make_event_with_kind_and_tags( + 30023, + vec![Tag::parse(["not_before", "1717000000"]).unwrap()], + ); + assert_eq!(extract_not_before(&event), None); + } + + #[test] + fn extract_not_before_non_numeric_returns_none() { + // Malformed values are rejected by ingest; materialization just skips them. + let event = make_event_with_kind_and_tags( + KIND_EVENT_REMINDER as u16, + vec![Tag::parse(["not_before", "not-a-number"]).unwrap()], + ); + assert_eq!(extract_not_before(&event), None); + } } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 84ffc5c67..4b27a0f29 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -532,6 +532,26 @@ impl Db { channel::reap_expired_ephemeral_channels(&self.pool).await } + // ── Reminder scheduler ─────────────────────────────────────────────────── + + /// Query due reminders ready for delivery. + pub async fn query_due_reminders( + &self, + now_secs: i64, + batch_limit: i64, + ) -> Result> { + event::query_due_reminders(&self.pool, now_secs, batch_limit).await + } + + /// Atomically claim a due reminder for delivery (cross-pod dedup). + pub async fn claim_due_reminder( + &self, + event_id: &[u8], + event_created_at: chrono::DateTime, + ) -> Result { + event::claim_due_reminder(&self.pool, event_id, event_created_at).await + } + // ── Users ──────────────────────────────────────────────────────────────── /// Ensure a user record exists (upsert). diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 26b62b633..bc04ae4dd 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -593,10 +593,8 @@ pub async fn count_events( match state.db.query_events(&q).await { Ok(stored_events) => { for se in stored_events { - if !buzz_core::filter::filters_match( - std::slice::from_ref(filter), - &se, - ) { + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) + { continue; } if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) @@ -642,10 +640,8 @@ pub async fn count_events( match state.db.query_events(&query).await { Ok(stored_events) => { for se in stored_events { - if !buzz_core::filter::filters_match( - std::slice::from_ref(filter), - &se, - ) { + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) + { continue; } if crate::handlers::req::is_author_only_event(&se.event, &pubkey_bytes) diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 86b58b09f..887dd45e0 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -120,10 +120,8 @@ pub async fn handle_count( match state.db.query_events(&q).await { Ok(stored_events) => { for se in stored_events { - if !buzz_core::filter::filters_match( - std::slice::from_ref(filter), - &se, - ) { + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) + { continue; } if is_author_only_event(&se.event, &pubkey_bytes) { @@ -173,10 +171,8 @@ pub async fn handle_count( match state.db.query_events(&query).await { Ok(stored_events) => { for se in stored_events { - if !buzz_core::filter::filters_match( - std::slice::from_ref(filter), - &se, - ) { + if !buzz_core::filter::filters_match(std::slice::from_ref(filter), &se) + { continue; } if is_author_only_event(&se.event, &pubkey_bytes) { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index dbb9f24d4..4a84ffe4e 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -61,6 +61,23 @@ pub async fn filter_fanout_by_access( stored_event: &StoredEvent, matches: Vec<(crate::subscription::ConnId, crate::subscription::SubId)>, ) -> Vec<(crate::subscription::ConnId, crate::subscription::SubId)> { + // Author-only kinds: only the event's author may receive fan-out. + // Checked before channel gating so it applies to all delivery paths + // (local dispatch, cross-pod subscribe_local, scheduler-published events). + let kind_u32 = event_kind_u32(&stored_event.event); + if AUTHOR_ONLY_KINDS.contains(&kind_u32) { + let author_bytes = stored_event.event.pubkey.to_bytes(); + return matches + .into_iter() + .filter(|(conn_id, _)| { + state + .conn_manager + .pubkey_for_conn(*conn_id) + .is_some_and(|pk| pk.as_slice() == author_bytes.as_slice()) + }) + .collect(); + } + let Some(channel_id) = stored_event.channel_id else { return matches; }; @@ -1129,5 +1146,31 @@ mod tests { filter_fanout_by_access(&state, &channel_event(Some(channel_id)), matches).await; assert_eq!(out, vec![(member, "m".to_string())]); } + + #[tokio::test] + async fn author_only_kind_delivers_only_to_author() { + let state = test_state().await; + let author_keys = Keys::generate(); + let author_pk = author_keys.public_key().to_bytes().to_vec(); + let other_pk = vec![99u8; 32]; + + let author_conn = register_conn(&state, Some(author_pk.clone())); + let other_conn = register_conn(&state, Some(other_pk)); + let unauthed_conn = register_conn(&state, None); + + // Build a kind:30300 (event reminder) — an author-only kind. + let event = EventBuilder::new(Kind::Custom(30300), "encrypted-content") + .sign_with_keys(&author_keys) + .expect("sign event"); + let stored = StoredEvent::new(event, None); + + let matches = vec![ + (author_conn, "a".to_string()), + (other_conn, "o".to_string()), + (unauthed_conn, "u".to_string()), + ]; + let out = filter_fanout_by_access(&state, &stored, matches).await; + assert_eq!(out, vec![(author_conn, "a".to_string())]); + } } } diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 8f1627bcb..3b3123e64 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -1037,6 +1037,20 @@ fn validate_event_reminder(event: &Event) -> Result<(), &'static str> { // `not_before` is optional — terminal states (done/cancelled) and bookmarks // omit it. The ordering check only applies when both are present. if let Some(nb) = not_before { + // Reject reminders scheduled beyond the configured horizon. The same + // SPROUT_MAX_NOT_BEFORE_DELTA env var is advertised in NIP-11. + let max_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(31_536_000); // 1 year default + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if nb > now + max_delta { + return Err("not_before too far in future"); + } + if let Some(exp) = expiration { if let Ok(exp) = exp.parse::() { if exp <= nb { @@ -2458,6 +2472,17 @@ mod tests { assert!(validate_event_reminder(&ev).is_ok()); } + #[test] + fn reminder_rejects_not_before_too_far_in_future() { + // `not_before` beyond the max horizon (default 1 year) is rejected. + let far_future = (chrono::Utc::now().timestamp() as u64) + 63_072_000; // ~2 years + let ev = make_reminder(&[&["d", "abc"], &["not_before", &far_future.to_string()]]); + assert_eq!( + validate_event_reminder(&ev), + Err("not_before too far in future") + ); + } + #[test] fn reminder_rejects_duplicate_not_before() { let ev = make_reminder(&[ diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index db60e3201..d107b664a 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,7 +1,7 @@ use std::sync::atomic::Ordering; use std::sync::Arc; -use tracing::{error, info}; +use tracing::{error, info, warn}; use tracing_subscriber::{fmt, prelude::*, EnvFilter}; use buzz_audit::AuditService; @@ -373,6 +373,86 @@ async fn main() -> anyhow::Result<()> { }); } + // NIP-ER reminder scheduler — polls for due reminders and publishes them + // to Redis pub/sub for cross-pod fan-out. Each pod's existing + // subscribe_local consumer picks them up and applies the author-only gate. + // Mirrors the channel reaper pattern. Cross-pod dedup via `delivered_at` + // column: only the pod that wins the atomic claim publishes. + { + let scheduler_state = Arc::clone(&state); + let scheduler_interval_secs: u64 = std::env::var("SPROUT_REMINDER_SCHEDULER_INTERVAL_SECS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10); + let scheduler_batch_limit: i64 = std::env::var("SPROUT_REMINDER_SCHEDULER_BATCH_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); + tokio::spawn(async move { + info!( + interval_secs = scheduler_interval_secs, + batch_limit = scheduler_batch_limit, + "NIP-ER reminder scheduler started" + ); + loop { + tokio::time::sleep(std::time::Duration::from_secs(scheduler_interval_secs)).await; + + let now_secs = chrono::Utc::now().timestamp(); + let due = match scheduler_state + .db + .query_due_reminders(now_secs, scheduler_batch_limit) + .await + { + Ok(reminders) => reminders, + Err(e) => { + error!("Reminder scheduler tick failed: {e}"); + continue; + } + }; + + if due.is_empty() { + continue; + } + + info!(count = due.len(), "Reminder scheduler: due reminders found"); + + for reminder in due { + // Publish first, then claim. If publish fails the reminder + // stays unclaimed and will be retried next tick. If claim + // fails after a successful publish, duplicate fan-out on the + // next tick is harmless (subscribers dedup by event ID). + if let Err(e) = scheduler_state + .pubsub + .publish_event(uuid::Uuid::nil(), &reminder_to_event(&reminder)) + .await + { + error!( + event_id = hex::encode(&reminder.id), + "Reminder scheduler: Redis publish failed, skipping claim: {e}" + ); + continue; + } + + // Atomic cross-pod claim — only the winner marks it delivered. + match scheduler_state + .db + .claim_due_reminder(&reminder.id, reminder.created_at) + .await + { + Ok(true) => {} + Ok(false) => {} // Another pod claimed it; duplicate publish is harmless. + Err(e) => { + warn!( + event_id = hex::encode(&reminder.id), + "Reminder scheduler: claim failed after publish (duplicate delivery possible): {e}" + ); + } + } + } + } + }); + } + // Multi-node fan-out consumer: receive events from Redis pub/sub // (published by other relay instances) and fan out to local WS subscribers. { @@ -604,3 +684,18 @@ async fn shutdown_signal() { tokio::signal::ctrl_c().await.ok(); } } + +/// Reconstruct a `nostr::Event` from a [`DueReminder`] row for Redis pub/sub. +fn reminder_to_event(reminder: &buzz_db::event::DueReminder) -> nostr::Event { + let event_json = serde_json::json!({ + "id": hex::encode(&reminder.id), + "pubkey": hex::encode(&reminder.pubkey), + "created_at": reminder.created_at.timestamp(), + "kind": reminder.kind as u16, + "tags": reminder.tags, + "content": reminder.content, + "sig": hex::encode(&reminder.sig), + }); + + serde_json::from_value(event_json).expect("valid event JSON from DB row") +} diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 7c0fdb4e6..0570998b6 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -32,6 +32,9 @@ pub struct RelayInfo { pub contact: Option, /// NIPs supported by this relay. pub supported_nips: Vec, + /// Draft/extension protocol identifiers supported by this relay. + #[serde(skip_serializing_if = "Option::is_none")] + pub supported_extensions: Option>, /// URL of the relay software repository. pub software: String, /// Relay software version string. @@ -65,6 +68,12 @@ pub struct RelayLimitation { pub payment_required: bool, /// Whether writes are restricted to authorized pubkeys. pub restricted_writes: bool, + /// NIP-ER: how the relay delivers due reminders ("push" or "lazy"). + #[serde(skip_serializing_if = "Option::is_none")] + pub due_delivery_mode: Option, + /// NIP-ER: maximum allowed `not_before` horizon in seconds from now. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_not_before_delta: Option, } /// Canonical `RelayLimitation` advertised by this relay. @@ -74,6 +83,11 @@ pub struct RelayLimitation { /// `AuthState::Authenticated`. This is independent of the REST API token /// toggle (`config.require_auth_token`). fn relay_limitation() -> RelayLimitation { + let max_not_before_delta: u64 = std::env::var("SPROUT_MAX_NOT_BEFORE_DELTA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(31_536_000); // 1 year default + RelayLimitation { max_message_length: Some(MAX_FRAME_BYTES as u64), max_subscriptions: Some(1024), @@ -84,6 +98,8 @@ fn relay_limitation() -> RelayLimitation { auth_required: true, payment_required: false, restricted_writes: true, + due_delivery_mode: Some("push".to_string()), + max_not_before_delta: Some(max_not_before_delta), } } @@ -119,6 +135,7 @@ impl RelayInfo { pubkey: None, contact: None, supported_nips, + supported_extensions: Some(vec!["nip-er".to_string()]), software: "https://github.com/block/sprout".to_string(), version: env!("CARGO_PKG_VERSION").to_string(), limitation: Some(relay_limitation()), diff --git a/crates/buzz-test-client/tests/e2e_event_reminder.rs b/crates/buzz-test-client/tests/e2e_event_reminder.rs index 66126c4e5..ae3a00f7a 100644 --- a/crates/buzz-test-client/tests/e2e_event_reminder.rs +++ b/crates/buzz-test-client/tests/e2e_event_reminder.rs @@ -1009,3 +1009,30 @@ async fn test_ws_count_returns_zero_for_other_users_reminders() { ws_other.disconnect().await.expect("disconnect"); } + +#[tokio::test] +#[ignore] +async fn test_reminder_rejected_not_before_too_far_in_future() { + let client = http_client(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + // Set not_before to 2 years from now (exceeds default 1-year max_not_before_delta) + let two_years_from_now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() + + 63_072_000; // ~2 years + + let event = build_reminder( + &keys, + &d_tag, + vec![Tag::parse(["not_before", &two_years_from_now.to_string()]).unwrap()], + ); + let (accepted, msg) = submit_event_http(&client, &keys, &event).await; + assert!(!accepted, "should reject not_before too far in future"); + assert!( + msg.contains("not_before too far in future"), + "unexpected message: {msg}" + ); +} diff --git a/docs/nips/NIP-ER.md b/docs/nips/NIP-ER.md index a52bb0e06..f384ff7ef 100644 --- a/docs/nips/NIP-ER.md +++ b/docs/nips/NIP-ER.md @@ -6,7 +6,7 @@ Event Reminders `draft` `optional` `relay` -This NIP defines encrypted, author-only reminders as `kind:30300` addressable events. A reminder carries a public `not_before` tag that tells supporting relays when the reminder is due, while the reminder target, note, and state are encrypted to the author with [NIP-44](44.md). +This NIP defines encrypted, author-only reminders as `kind:30300` addressable events. A pending reminder carries a public `not_before` tag that tells supporting relays when the reminder is due, while the reminder target, note, and state are encrypted to the author with [NIP-44](44.md). A reminder without `not_before` is a bookmark (saved item with no due time) or a terminal state (done/cancelled). The relay learns that an author has a reminder due at a time. It does not learn what the reminder is about. @@ -55,9 +55,18 @@ Required tags for a reminder that may become due: ] ``` +For bookmarks (saved items) or terminal states (done/cancelled), `not_before` is omitted: + +```jsonc +[ + ["d", ""], + ["alt", "Encrypted reminder"] +] +``` + `d` MUST be an opaque random value with at least 128 bits of entropy and MUST NOT be derived from the target event, reminder text, or reminder time. Events with no `d` tag, an empty `d` tag, or more than one `d` tag are invalid. -`not_before` MUST be a decimal Unix timestamp string. It MUST contain only ASCII digits, with no sign, whitespace, decimal point, or leading zero except `"0"`. It MUST parse exactly as an integer in the range 0 through 9007199254740991 inclusive. Implementations MUST NOT parse it through lossy floating-point conversion, and MUST treat values outside this range or values that overflow their parser as malformed. Events MUST contain at most one `not_before` tag. Supporting relays SHOULD reject events with an invalid or duplicate `not_before` tag using `invalid: malformed not_before`. Clients MUST ignore pending reminders without exactly one valid `not_before`. +`not_before` MUST be a decimal Unix timestamp string. It MUST contain only ASCII digits, with no sign, whitespace, decimal point, or leading zero except `"0"`. It MUST parse exactly as an integer in the range 0 through 9007199254740991 inclusive. Implementations MUST NOT parse it through lossy floating-point conversion, and MUST treat values outside this range or values that overflow their parser as malformed. Events MUST contain at most one `not_before` tag. Supporting relays SHOULD reject events with an invalid or duplicate `not_before` tag using `invalid: malformed not_before`. A pending reminder that may become due MUST include exactly one valid `not_before`. Bookmarks and terminal states (done/cancelled) MUST omit `not_before`. Clients MUST ignore pending reminders without exactly one valid `not_before`. `alt` is RECOMMENDED for [NIP-31](31.md) fallback text. diff --git a/schema/schema.sql b/schema/schema.sql index 8224c0cae..3fa7c4be4 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -103,6 +103,8 @@ CREATE TABLE events ( channel_id UUID, deleted_at TIMESTAMPTZ, d_tag TEXT, + not_before BIGINT, + delivered_at BIGINT, PRIMARY KEY (created_at, id) ) PARTITION BY RANGE (created_at); @@ -130,6 +132,8 @@ 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); CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL; +CREATE INDEX idx_events_not_before ON events (not_before) + WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; -- ── Event mentions ────────────────────────────────────────────────────────────