Skip to main content

vector_core/db/
schema.rs

1//! Database schema and migrations.
2
3pub const SQL_SCHEMA: &str = r#"
4-- Profiles table (plaintext - public data)
5CREATE TABLE IF NOT EXISTS profiles (
6    id INTEGER PRIMARY KEY AUTOINCREMENT,
7    npub TEXT UNIQUE NOT NULL,
8    name TEXT NOT NULL DEFAULT '',
9    display_name TEXT NOT NULL DEFAULT '',
10    nickname TEXT NOT NULL DEFAULT '',
11    lud06 TEXT NOT NULL DEFAULT '',
12    lud16 TEXT NOT NULL DEFAULT '',
13    banner TEXT NOT NULL DEFAULT '',
14    avatar TEXT NOT NULL DEFAULT '',
15    about TEXT NOT NULL DEFAULT '',
16    website TEXT NOT NULL DEFAULT '',
17    nip05 TEXT NOT NULL DEFAULT '',
18    status_content TEXT NOT NULL DEFAULT '',
19    status_url TEXT NOT NULL DEFAULT '',
20    muted INTEGER NOT NULL DEFAULT 0,
21    bot INTEGER NOT NULL DEFAULT 0,
22    avatar_cached TEXT NOT NULL DEFAULT '',
23    banner_cached TEXT NOT NULL DEFAULT ''
24);
25CREATE INDEX IF NOT EXISTS idx_profiles_npub ON profiles(npub);
26CREATE INDEX IF NOT EXISTS idx_profiles_name ON profiles(name);
27
28-- Chats table (plaintext - metadata)
29CREATE TABLE IF NOT EXISTS chats (
30    id INTEGER PRIMARY KEY AUTOINCREMENT,
31    chat_identifier TEXT UNIQUE NOT NULL,
32    chat_type INTEGER NOT NULL,
33    participants TEXT NOT NULL,
34    last_read TEXT NOT NULL DEFAULT '',
35    created_at INTEGER NOT NULL,
36    metadata TEXT NOT NULL DEFAULT '{}',
37    muted INTEGER NOT NULL DEFAULT 0
38);
39CREATE INDEX IF NOT EXISTS idx_chats_identifier ON chats(chat_identifier);
40CREATE INDEX IF NOT EXISTS idx_chats_created ON chats(created_at DESC);
41
42-- Messages table (content encrypted, metadata plaintext)
43CREATE TABLE IF NOT EXISTS messages (
44    id TEXT PRIMARY KEY,
45    chat_id INTEGER NOT NULL,
46    content_encrypted TEXT NOT NULL,
47    replied_to TEXT NOT NULL DEFAULT '',
48    preview_metadata TEXT,
49    attachments TEXT NOT NULL DEFAULT '[]',
50    reactions TEXT NOT NULL DEFAULT '[]',
51    at INTEGER NOT NULL,
52    mine INTEGER NOT NULL,
53    user_id INTEGER,
54    wrapper_event_id TEXT,
55    FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
56    FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE SET NULL
57);
58CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id, at);
59CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(at DESC);
60CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id);
61CREATE INDEX IF NOT EXISTS idx_messages_wrapper ON messages(wrapper_event_id);
62
63-- Settings table (key-value pairs)
64CREATE TABLE IF NOT EXISTS settings (
65    key TEXT PRIMARY KEY,
66    value TEXT NOT NULL
67);
68
69-- Events table: flat, protocol-aligned storage for all Nostr events
70CREATE TABLE IF NOT EXISTS events (
71    id TEXT PRIMARY KEY,
72    kind INTEGER NOT NULL,
73    chat_id INTEGER NOT NULL,
74    user_id INTEGER,
75    content TEXT NOT NULL,
76    tags TEXT NOT NULL DEFAULT '[]',
77    reference_id TEXT,
78    created_at INTEGER NOT NULL,
79    received_at INTEGER NOT NULL,
80    mine INTEGER NOT NULL DEFAULT 0,
81    pending INTEGER NOT NULL DEFAULT 0,
82    failed INTEGER NOT NULL DEFAULT 0,
83    wrapper_event_id TEXT,
84    npub TEXT,
85    preview_metadata TEXT,
86    FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
87    FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE SET NULL
88);
89CREATE INDEX IF NOT EXISTS idx_events_chat_time ON events(chat_id, created_at DESC);
90CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
91CREATE INDEX IF NOT EXISTS idx_events_reference ON events(reference_id) WHERE reference_id IS NOT NULL;
92CREATE INDEX IF NOT EXISTS idx_events_wrapper ON events(wrapper_event_id) WHERE wrapper_event_id IS NOT NULL;
93CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
94
95-- PIVX Promos table
96CREATE TABLE IF NOT EXISTS pivx_promos (
97    id INTEGER PRIMARY KEY AUTOINCREMENT,
98    gift_code TEXT NOT NULL UNIQUE,
99    address TEXT NOT NULL,
100    privkey_encrypted TEXT NOT NULL,
101    created_at INTEGER NOT NULL,
102    claimed_at INTEGER,
103    amount_piv REAL,
104    status TEXT NOT NULL DEFAULT 'active'
105);
106CREATE INDEX IF NOT EXISTS idx_pivx_promos_code ON pivx_promos(gift_code);
107CREATE INDEX IF NOT EXISTS idx_pivx_promos_address ON pivx_promos(address);
108CREATE INDEX IF NOT EXISTS idx_pivx_promos_status ON pivx_promos(status);
109
110-- Mini Apps history table
111CREATE TABLE IF NOT EXISTS miniapps_history (
112    id INTEGER PRIMARY KEY AUTOINCREMENT,
113    name TEXT NOT NULL UNIQUE,
114    src_url TEXT NOT NULL,
115    attachment_ref TEXT,
116    open_count INTEGER DEFAULT 1,
117    last_opened_at INTEGER NOT NULL,
118    is_favorite INTEGER NOT NULL DEFAULT 0,
119    categories TEXT NOT NULL DEFAULT '',
120    marketplace_id TEXT DEFAULT NULL,
121    installed_version TEXT DEFAULT NULL
122);
123
124-- Mini App permissions table
125CREATE TABLE IF NOT EXISTS miniapp_permissions (
126    id INTEGER PRIMARY KEY AUTOINCREMENT,
127    file_hash TEXT NOT NULL,
128    permission TEXT NOT NULL,
129    granted INTEGER NOT NULL DEFAULT 0,
130    granted_at INTEGER,
131    UNIQUE(file_hash, permission)
132);
133CREATE INDEX IF NOT EXISTS idx_miniapp_permissions_hash ON miniapp_permissions(file_hash);
134
135-- Processed wrappers table (NIP-59 gift wrap dedup + NIP-77 negentropy)
136-- Universal outer-event ledger across transports. The `transport` discriminator
137-- (0 = nip17 gift-wrap, 1 = concord channel envelope, …) is added by migration 42 so the
138-- dedup is shared but NIP-77 negentropy only fingerprints the nip17 (0) subset.
139CREATE TABLE IF NOT EXISTS processed_wrappers (
140    wrapper_id BLOB PRIMARY KEY,
141    wrapper_created_at INTEGER NOT NULL DEFAULT 0
142);
143
144-- The nip17_wrap_keys vault is introduced by migration 21. The legacy MLS
145-- tables (mls_wrap_keys / mls_pending_events from migrations 22/23) are dropped
146-- by migration 41, so on a fresh DB they're created in order and then removed.
147
148-- Schema migrations tracking table
149CREATE TABLE IF NOT EXISTS schema_migrations (
150    id INTEGER PRIMARY KEY,
151    applied_at INTEGER NOT NULL
152);
153"#;
154
155/// Check if a specific migration has already been applied
156pub fn migration_applied(conn: &rusqlite::Connection, migration_id: u32) -> bool {
157    conn.query_row(
158        "SELECT 1 FROM schema_migrations WHERE id = ?1",
159        rusqlite::params![migration_id],
160        |_| Ok(())
161    ).is_ok()
162}
163
164/// Mark a migration as applied (within a transaction)
165pub fn mark_migration_applied(tx: &rusqlite::Transaction, migration_id: u32) -> Result<(), String> {
166    let now = std::time::SystemTime::now()
167        .duration_since(std::time::UNIX_EPOCH)
168        .unwrap()
169        .as_secs() as i64;
170
171    tx.execute(
172        "INSERT INTO schema_migrations (id, applied_at) VALUES (?1, ?2)",
173        rusqlite::params![migration_id, now],
174    ).map_err(|e| format!("[DB] Migration {}: Failed to record: {}", migration_id, e))?;
175
176    Ok(())
177}
178
179/// Run a single migration atomically within a transaction.
180///
181/// GUARANTEES:
182/// - If the migration succeeds: all changes are committed, migration is marked as applied
183/// - If the migration fails: ALL changes are rolled back, database is unchanged
184/// - No partial state is ever possible
185///
186/// This is the ONLY way migrations should be run.
187fn run_atomic_migration<F>(
188    conn: &mut rusqlite::Connection,
189    id: u32,
190    name: &str,
191    migrate: F,
192) -> Result<(), String>
193where
194    F: FnOnce(&rusqlite::Transaction) -> Result<(), String>,
195{
196    // Check if this specific migration was already applied.
197    if migration_applied(conn, id) {
198        return Ok(());
199    }
200
201    println!("[DB] Migration {}: {}...", id, name);
202
203    // Start transaction - this is the atomicity boundary
204    let tx = conn.transaction()
205        .map_err(|e| format!("[DB] Migration {}: Failed to start transaction: {}", id, e))?;
206
207    // Run the migration within the transaction
208    match migrate(&tx) {
209        Ok(()) => {
210            // Mark as applied WITHIN the same transaction
211            mark_migration_applied(&tx, id)?;
212
213            // Commit - if this fails, everything rolls back
214            tx.commit()
215                .map_err(|e| format!("[DB] Migration {}: Failed to commit: {}", id, e))?;
216
217            println!("[DB] Migration {} complete", id);
218            Ok(())
219        }
220        Err(e) => {
221            // Transaction automatically rolls back on drop
222            eprintln!("[DB] Migration {} FAILED: {} - rolling back", id, e);
223            Err(e)
224        }
225    }
226}
227
228/// Ensure a column exists on a table, adding it if missing.
229/// This is a safety net for cases where ALTER TABLE inside a WAL-mode
230/// transaction silently fails (e.g., other connections hold read locks).
231#[allow(dead_code)]
232fn ensure_column_exists(
233    conn: &mut rusqlite::Connection,
234    table: &str,
235    column: &str,
236    col_type: &str,
237) -> Result<(), String> {
238    let exists: bool = conn.query_row(
239        &format!("SELECT COUNT(*) FROM pragma_table_info('{}') WHERE name='{}'", table, column),
240        [],
241        |row| row.get::<_, i32>(0),
242    ).map(|c| c > 0).unwrap_or(false);
243
244    if !exists {
245        println!("[DB] Safety net: adding missing column {}.{}", table, column);
246        conn.execute(
247            &format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, col_type),
248            [],
249        ).map_err(|e| format!("[DB] Failed to add column {}.{}: {}", table, column, e))?;
250    }
251    Ok(())
252}
253
254/// Run database migrations for schema updates
255///
256/// GUARANTEES:
257/// - Each migration runs in a transaction (atomic - all or nothing)
258/// - If any migration fails, changes are rolled back - no partial state
259/// - Migrations are tracked in schema_migrations table (idempotent - safe to re-run)
260/// - All errors are logged with [DB] prefix and propagated (no silent failures)
261pub fn run_migrations(conn: &mut rusqlite::Connection) -> Result<(), String> {
262    // Ensure schema_migrations table exists (bootstrap - must succeed before any migrations)
263    conn.execute(
264        "CREATE TABLE IF NOT EXISTS schema_migrations (
265            id INTEGER PRIMARY KEY,
266            applied_at INTEGER NOT NULL
267        )",
268        [],
269    ).map_err(|e| format!("[DB] Failed to create schema_migrations table: {}", e))?;
270
271    // =========================================================================
272    // Migration 19: Create marketplace_cache table for persistent Nexus cache
273    // =========================================================================
274    // Caches marketplace app listings in SQLite so they survive restarts.
275    // On login, the cache is loaded into MARKETPLACE_STATE immediately (so
276    // permission checks work before the user visits the Nexus tab), then a
277    // background network fetch refreshes the data.
278    run_atomic_migration(conn, 19, "Create marketplace_cache table", |tx| {
279        tx.execute_batch(
280            "CREATE TABLE IF NOT EXISTS marketplace_cache (
281                id TEXT PRIMARY KEY,
282                data TEXT NOT NULL,
283                fetched_at INTEGER NOT NULL
284            );"
285        ).map_err(|e| format!("Failed to create marketplace_cache table: {}", e))?;
286        Ok(())
287    })?;
288
289    // =========================================================================
290    // Migration 20: Add is_blocked column to profiles table
291    // =========================================================================
292    // Supports user blocking: blocked profiles have DM events dropped after
293    // decrypt (wrapper kept for negentropy), group messages filtered in UI.
294    run_atomic_migration(conn, 20, "Add is_blocked column to profiles", |tx| {
295        tx.execute_batch(
296            "ALTER TABLE profiles ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0;"
297        ).map_err(|e| format!("Failed to add is_blocked column: {}", e))?;
298        Ok(())
299    })?;
300
301    // =========================================================================
302    // Migration 21: NIP-17 ephemeral wrap-key vault for deletable DMs
303    // =========================================================================
304    // Stores the ephemeral secp256k1 secret used to sign each kind-1059
305    // gift-wrap so the user can later publish a NIP-09 deletion against
306    // the wrap event ID — actually removing the message from inbox relays.
307    // Encryption-at-rest is handled by Vector's per-account database
308    // envelope (ChaCha20 if the account has a password; plaintext otherwise).
309    // One row per published wrap; deletion uses (wrap_event_id, secret,
310    // relay_urls) to issue an author-signed NIP-09 to the same relay set.
311    run_atomic_migration(conn, 21, "Create nip17_wrap_keys table", |tx| {
312        tx.execute_batch(
313            "CREATE TABLE IF NOT EXISTS nip17_wrap_keys (
314                wrap_event_id    TEXT PRIMARY KEY,
315                rumor_id         TEXT NOT NULL,
316                recipient_pubkey TEXT NOT NULL,
317                role             INTEGER NOT NULL,
318                secret           BLOB NOT NULL,
319                relay_urls       TEXT NOT NULL,
320                created_at       INTEGER NOT NULL
321            );
322            CREATE INDEX IF NOT EXISTS idx_nip17_wrap_keys_rumor ON nip17_wrap_keys(rumor_id);"
323        ).map_err(|e| format!("Failed to create nip17_wrap_keys table: {}", e))?;
324        Ok(())
325    })?;
326
327    // =========================================================================
328    // Migration 22: MLS ephemeral wrap-key vault for deletable group messages
329    // =========================================================================
330    // Sibling of nip17_wrap_keys: every kind-445 MLS wrapper is signed by an
331    // ephemeral keypair that MDK normally discards. With our `create_message_retained`
332    // patch the sender retains the secret so a later NIP-09 deletion against
333    // the kind-445 event id is valid (NIP-09 requires `event.pubkey ==
334    // deletion.pubkey`). One row per published wrapper; retries write new rows.
335    run_atomic_migration(conn, 22, "Create mls_wrap_keys table", |tx| {
336        tx.execute_batch(
337            "CREATE TABLE IF NOT EXISTS mls_wrap_keys (
338                wrap_event_id TEXT PRIMARY KEY,
339                message_id    TEXT NOT NULL,
340                group_id      TEXT NOT NULL,
341                secret        BLOB NOT NULL,
342                relay_urls    TEXT NOT NULL,
343                created_at    INTEGER NOT NULL
344            );
345            CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_message ON mls_wrap_keys(message_id);
346            CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_group ON mls_wrap_keys(group_id);"
347        ).map_err(|e| format!("Failed to create mls_wrap_keys table: {}", e))?;
348        Ok(())
349    })?;
350
351    // =========================================================================
352    // Migration 23: MLS pending event queue for cross-sync retry
353    // =========================================================================
354    // When MDK can't process an MLS event because its prerequisite commit
355    // hasn't arrived, we previously marked it "processed" and advanced the
356    // cursor past it — losing it forever. This table persists such events
357    // so subsequent syncs can retry once the prerequisite shows up (possibly
358    // from a different relay, days or weeks later). Pruned at 90 days.
359    run_atomic_migration(conn, 23, "Create mls_pending_events table", |tx| {
360        tx.execute_batch(
361            "CREATE TABLE IF NOT EXISTS mls_pending_events (
362                event_id      TEXT PRIMARY KEY,
363                group_id      TEXT NOT NULL,
364                event_json    TEXT NOT NULL,
365                first_seen_at INTEGER NOT NULL,
366                last_retry_at INTEGER NOT NULL,
367                retry_count   INTEGER NOT NULL DEFAULT 0
368            );
369            CREATE INDEX IF NOT EXISTS idx_mls_pending_events_group ON mls_pending_events(group_id);
370            CREATE INDEX IF NOT EXISTS idx_mls_pending_events_first_seen ON mls_pending_events(first_seen_at);"
371        ).map_err(|e| format!("Failed to create mls_pending_events table: {}", e))?;
372        Ok(())
373    })?;
374
375    // =========================================================================
376    // Migration 24: Blossom capability cache — drives smart upload routing.
377    // =========================================================================
378    run_atomic_migration(conn, 24, "Create blossom_server_capabilities table", |tx| {
379        tx.execute_batch(
380            "CREATE TABLE IF NOT EXISTS blossom_server_capabilities (
381                server_url        TEXT    NOT NULL,
382                mime_type         TEXT    NOT NULL,
383                outcome           INTEGER NOT NULL,
384                max_accepted_size INTEGER NOT NULL DEFAULT 0,
385                updated_at        INTEGER NOT NULL,
386                PRIMARY KEY (server_url, mime_type)
387            );"
388        ).map_err(|e| format!("Failed to create blossom_server_capabilities table: {}", e))?;
389        Ok(())
390    })?;
391
392    // =========================================================================
393    // Migration 25: Add `min_rejected_size` (smallest observed 413).
394    // =========================================================================
395    run_atomic_migration(conn, 25, "Add min_rejected_size to blossom_server_capabilities", |tx| {
396        tx.execute_batch(
397            "ALTER TABLE blossom_server_capabilities ADD COLUMN min_rejected_size INTEGER;"
398        ).map_err(|e| format!("Failed to add min_rejected_size column: {}", e))?;
399        Ok(())
400    })?;
401
402    // =========================================================================
403    // Migration 26: Split capability rows by encrypted vs plaintext context.
404    // Same wire MIME means different things for ciphertext vs real bytes;
405    // pre-migration rows didn't track the distinction so they're dropped.
406    // =========================================================================
407    run_atomic_migration(conn, 26, "Add is_encrypted to capability cache PK", |tx| {
408        tx.execute_batch(
409            "DROP TABLE IF EXISTS blossom_server_capabilities;
410             CREATE TABLE blossom_server_capabilities (
411                server_url        TEXT    NOT NULL,
412                mime_type         TEXT    NOT NULL,
413                is_encrypted      INTEGER NOT NULL DEFAULT 0,
414                outcome           INTEGER NOT NULL,
415                max_accepted_size INTEGER NOT NULL DEFAULT 0,
416                min_rejected_size INTEGER,
417                updated_at        INTEGER NOT NULL,
418                PRIMARY KEY (server_url, mime_type, is_encrypted)
419             );"
420        ).map_err(|e| format!("Failed to recreate blossom_server_capabilities: {}", e))?;
421        Ok(())
422    })?;
423
424    // =========================================================================
425    // Migration 27: Mark NIP-46 remote-signer support landed.
426    //
427    // Settings is a KV — no schema change needed for the three new keys
428    // (`signer_type`, `bunker_url`, `bunker_remote_pubkey`). Pre-bunker
429    // accounts have no `signer_type` row at all; the loader treats missing
430    // as `local`. We backfill an explicit `signer_type='local'` row so every
431    // account has a discriminator on disk after this point — makes the
432    // discriminator query a clean `=` instead of a NULL-coalesce.
433    // =========================================================================
434    run_atomic_migration(conn, 27, "Backfill signer_type=local for pre-NIP-46 accounts", |tx| {
435        tx.execute(
436            "INSERT OR IGNORE INTO settings (key, value) VALUES ('signer_type', 'local')",
437            [],
438        ).map_err(|e| format!("Failed to backfill signer_type: {}", e))?;
439        Ok(())
440    })?;
441
442    // =========================================================================
443    // Migration 28: NIP-30 / NIP-51 custom emoji packs
444    // =========================================================================
445    // `emoji_packs`           — kind 30030 sets (own + subscribed), one row per addr.
446    // `emoji_pack_items`      — flattened emoji rows per pack; CASCADE deletes follow.
447    // `emoji_pack_subscriptions` — local mirror of kind 10030 `a` tags; fast startup
448    //                              read without re-fetching from relays.
449    run_atomic_migration(conn, 28, "Create emoji pack tables", |tx| {
450        tx.execute_batch(
451            "CREATE TABLE IF NOT EXISTS emoji_packs (
452                addr        TEXT PRIMARY KEY,
453                pubkey      TEXT NOT NULL,
454                identifier  TEXT NOT NULL,
455                title       TEXT NOT NULL DEFAULT '',
456                image_url   TEXT NOT NULL DEFAULT '',
457                description TEXT NOT NULL DEFAULT '',
458                is_own      INTEGER NOT NULL DEFAULT 0,
459                updated_at  INTEGER NOT NULL,
460                raw_event   TEXT NOT NULL DEFAULT ''
461            );
462            CREATE INDEX IF NOT EXISTS idx_emoji_packs_pubkey ON emoji_packs(pubkey);
463            CREATE INDEX IF NOT EXISTS idx_emoji_packs_is_own ON emoji_packs(is_own);
464
465            CREATE TABLE IF NOT EXISTS emoji_pack_items (
466                pack_addr  TEXT NOT NULL,
467                shortcode  TEXT NOT NULL,
468                url        TEXT NOT NULL,
469                sha256     TEXT,
470                position   INTEGER NOT NULL DEFAULT 0,
471                PRIMARY KEY (pack_addr, shortcode),
472                FOREIGN KEY (pack_addr) REFERENCES emoji_packs(addr) ON DELETE CASCADE
473            );
474            CREATE INDEX IF NOT EXISTS idx_emoji_pack_items_pack ON emoji_pack_items(pack_addr, position);
475
476            CREATE TABLE IF NOT EXISTS emoji_pack_subscriptions (
477                addr           TEXT PRIMARY KEY,
478                subscribed_at  INTEGER NOT NULL
479            );"
480        ).map_err(|e| format!("Failed to create emoji pack tables: {}", e))?;
481        Ok(())
482    })?;
483
484    // =========================================================================
485    // Migration 29: Add per-DM wallpaper columns to chats
486    // =========================================================================
487    // Wallpaper is the local cached file path (decrypted from the Blossom
488    // attachment carried by the most recent kind-30078 d=vector-wallpaper rumor
489    // for this chat). wallpaper_ts is the rumor created_at that produced it,
490    // used for latest-write-wins on concurrent sets.
491    run_atomic_migration(conn, 29, "Add wallpaper columns to chats", |tx| {
492        tx.execute_batch(
493            "ALTER TABLE chats ADD COLUMN wallpaper_path TEXT NOT NULL DEFAULT '';
494             ALTER TABLE chats ADD COLUMN wallpaper_ts INTEGER NOT NULL DEFAULT 0;"
495        ).map_err(|e| format!("Failed to add wallpaper columns: {}", e))?;
496        Ok(())
497    })?;
498
499    // =========================================================================
500    // Migration 30: Wallpaper customisation knobs (blur + brightness)
501    // =========================================================================
502    // blur: integer pixels, 0..=30 (0 = no blur).
503    // dim:  integer percent, 0..=100 (100 = no darkening, 0 = fully black).
504    // Defaults match the values applied when a rumor arrives without the
505    // optional tags — keeps older clients interoperable.
506    run_atomic_migration(conn, 30, "Add wallpaper blur/dim columns to chats", |tx| {
507        tx.execute_batch(
508            "ALTER TABLE chats ADD COLUMN wallpaper_blur INTEGER NOT NULL DEFAULT 0;
509             ALTER TABLE chats ADD COLUMN wallpaper_dim INTEGER NOT NULL DEFAULT 50;"
510        ).map_err(|e| format!("Failed to add wallpaper blur/dim columns: {}", e))?;
511        Ok(())
512    })?;
513
514    // =========================================================================
515    // Migration 31: Track wallpaper Blossom URL + uploader pubkey
516    // =========================================================================
517    // wallpaper_url is the Blossom blob URL of the current wallpaper.
518    // wallpaper_uploader is the npub (bech32) of whoever uploaded it. Together
519    // they let us DELETE the previous blob from Blossom when we (or another
520    // device of ours) replace the wallpaper — only the original uploader's
521    // signature satisfies the server's auth challenge.
522    run_atomic_migration(conn, 31, "Add wallpaper url/uploader columns to chats", |tx| {
523        tx.execute_batch(
524            "ALTER TABLE chats ADD COLUMN wallpaper_url TEXT NOT NULL DEFAULT '';
525             ALTER TABLE chats ADD COLUMN wallpaper_uploader TEXT NOT NULL DEFAULT '';"
526        ).map_err(|e| format!("Failed to add wallpaper url/uploader columns: {}", e))?;
527        Ok(())
528    })?;
529
530    // =========================================================================
531    // Migration 32: Drop mls_event_cursors — superseded by Total Negentropy
532    // =========================================================================
533    // MLS sync no longer tracks a per-group cursor. Possession of an event
534    // (mls_processed_events ∪ mls_pending_events) is the negentropy fingerprint
535    // set, and reconciliation derives the missing set directly. The cursor was
536    // a pre-negentropy resume mechanism that could only disagree with it.
537    run_atomic_migration(conn, 32, "Drop mls_event_cursors table", |tx| {
538        tx.execute_batch("DROP TABLE IF EXISTS mls_event_cursors;")
539            .map_err(|e| format!("Failed to drop mls_event_cursors: {}", e))?;
540        Ok(())
541    })?;
542
543    // =========================================================================
544    // GAP: migration ids 33-39 are PERMANENTLY BURNED — do not reuse.
545    // =========================================================================
546    // The distributed v0.4.0 "MLS edition" shipped MLS migrations in the 33-39 range that
547    // never made it into committed history (its release branch was later squashed to max 32).
548    // Migrations are tracked per-id (`schema_migrations`), not by a monotonic counter, so an
549    // MLS-edition DB has 33-39 recorded and would SKIP any new migration reusing those ids,
550    // silently never creating the table. Community state therefore starts at 40. Never fill
551    // the 33-39 gap, even though it looks tidy — those ids are spent forever.
552    //
553    // Migration 40: Community (Concord) protocol local state
554    // =========================================================================
555    // Per-account (the DB itself is account-scoped via account_dir(npub)). Holds the
556    // owner/member's held secrets (server-root key, epoch-tagged channel keys), the folded
557    // control-plane state, and local invite/dedup bookkeeping. Ids are hex. Authority is
558    // keyless: real-npub control editions + the owner attestation, never a shared secret.
559    run_atomic_migration(conn, 40, "Create community tables", |tx| {
560        tx.execute_batch(
561            "CREATE TABLE IF NOT EXISTS communities (
562                community_id          TEXT PRIMARY KEY,
563                server_root_key       BLOB NOT NULL,
564                name                  TEXT NOT NULL,
565                relays                TEXT NOT NULL,
566                created_at            INTEGER NOT NULL,
567                description           TEXT,
568                icon                  TEXT,
569                banner                TEXT,
570                banlist               TEXT NOT NULL DEFAULT '[]',
571                banlist_at            INTEGER NOT NULL DEFAULT 0,
572                owner_attestation     TEXT,
573                roles                 TEXT NOT NULL DEFAULT '{}',
574                roles_at              INTEGER NOT NULL DEFAULT 0,
575                server_root_epoch     INTEGER NOT NULL DEFAULT 0,
576                invite_registry       TEXT NOT NULL DEFAULT '[]',
577                read_cut_pending      INTEGER NOT NULL DEFAULT 0,
578                read_cut_target_epoch INTEGER NOT NULL DEFAULT 0,
579                dissolved             INTEGER NOT NULL DEFAULT 0
580            );
581            CREATE TABLE IF NOT EXISTS community_channels (
582                channel_id              TEXT PRIMARY KEY,
583                community_id            TEXT NOT NULL,
584                channel_key             BLOB NOT NULL,
585                epoch                   INTEGER NOT NULL,
586                name                    TEXT NOT NULL,
587                created_at              INTEGER NOT NULL,
588                rekeyed_at_server_epoch INTEGER NOT NULL DEFAULT 0
589            );
590            CREATE INDEX IF NOT EXISTS idx_community_channels_community
591                ON community_channels(community_id);
592            CREATE TABLE IF NOT EXISTS community_message_keys (
593                outer_event_id   TEXT PRIMARY KEY,
594                ephemeral_secret BLOB NOT NULL,
595                relays           TEXT NOT NULL,
596                created_at       INTEGER NOT NULL,
597                message_id       TEXT
598            );
599            CREATE INDEX IF NOT EXISTS idx_cmk_message_id
600                ON community_message_keys(message_id);
601            CREATE TABLE IF NOT EXISTS pending_community_invites (
602                community_id TEXT PRIMARY KEY,
603                bundle_json  TEXT NOT NULL,
604                inviter_npub TEXT NOT NULL,
605                received_at  INTEGER NOT NULL
606            );
607            CREATE TABLE IF NOT EXISTS community_public_invites (
608                token        TEXT PRIMARY KEY,
609                community_id TEXT NOT NULL,
610                url          TEXT NOT NULL,
611                expires_at   INTEGER,
612                created_at   INTEGER NOT NULL
613            );
614            CREATE INDEX IF NOT EXISTS idx_public_invites_community
615                ON community_public_invites(community_id);
616            CREATE TABLE IF NOT EXISTS community_edition_heads (
617                community_id TEXT NOT NULL,
618                entity_id    TEXT NOT NULL,
619                version      INTEGER NOT NULL,
620                self_hash    BLOB NOT NULL,
621                inner_id     BLOB,
622                epoch        INTEGER NOT NULL DEFAULT 0,
623                PRIMARY KEY (community_id, entity_id)
624            );
625            CREATE TABLE IF NOT EXISTS community_epoch_keys (
626                community_id TEXT NOT NULL,
627                scope_id     TEXT NOT NULL,
628                epoch        INTEGER NOT NULL,
629                key          BLOB NOT NULL,
630                created_at   INTEGER NOT NULL,
631                PRIMARY KEY (community_id, scope_id, epoch)
632            );
633            CREATE TABLE IF NOT EXISTS community_invite_link_sets (
634                community_id TEXT NOT NULL,
635                creator      TEXT NOT NULL,
636                locators     TEXT NOT NULL DEFAULT '[]',
637                version      INTEGER NOT NULL DEFAULT 0,
638                PRIMARY KEY (community_id, creator)
639            );",
640        )
641        .map_err(|e| format!("Failed to create community tables: {}", e))?;
642        Ok(())
643    })?;
644
645    // =========================================================================
646    // Migration 41: Purge legacy MLS data (MLS is fully removed)
647    // =========================================================================
648    // Drop the retired chat_type=1 (MlsGroup) chats + their events, then the MLS-only
649    // storage tables. chat_type 2 (Community) is untouched. Runs for accounts upgrading
650    // from an MLS build; a no-op on a fresh DB.
651    run_atomic_migration(conn, 41, "Purge legacy MLS data", |tx| {
652        tx.execute_batch(
653            "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_type = 1);
654             DELETE FROM chats WHERE chat_type = 1;
655             DROP TABLE IF EXISTS mls_groups;
656             DROP TABLE IF EXISTS mls_keypackages;
657             DROP TABLE IF EXISTS mls_processed_events;
658             DROP TABLE IF EXISTS mls_wrap_keys;
659             DROP TABLE IF EXISTS mls_pending_events;",
660        )
661        .map_err(|e| format!("Failed to purge legacy MLS data: {}", e))?;
662        Ok(())
663    })?;
664
665    // =========================================================================
666    // Migration 42: Make processed_wrappers a cross-transport dedup ledger
667    // =========================================================================
668    // A `transport` discriminator so every transport (NIP-17 DMs, Concord) shares ONE
669    // outer-event dedup store, while NIP-77 negentropy keeps fingerprinting only the 'nip17'
670    // subset. Existing rows are gift-wraps, so the default 0 ('nip17') is correct.
671    run_atomic_migration(conn, 42, "Add transport discriminator to processed_wrappers", |tx| {
672        tx.execute_batch("ALTER TABLE processed_wrappers ADD COLUMN transport INTEGER NOT NULL DEFAULT 0;")
673            .map_err(|e| format!("Failed to add transport column: {}", e))?;
674        Ok(())
675    })?;
676
677    // =========================================================================
678    // Migration 43: Persist the optional label on a minted public invite
679    // =========================================================================
680    // The label set at mint time rides in the relay-published bundle (join attribution) but wasn't
681    // stored locally, so the owner's invite-links list had no label to show. Encrypted-at-rest like
682    // the sibling columns; NULL when no label was set.
683    run_atomic_migration(conn, 43, "Add label to community_public_invites", |tx| {
684        tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
685            .map_err(|e| format!("Failed to add label column: {}", e))?;
686        Ok(())
687    })?;
688
689    // Migration 44: Per-account emoji "frecency" (most-used) table.
690    // =========================================================================
691    // `score` is a time-weighted log-space value: each use adds
692    // 2^((t-EPOCH)/half_life), so ranking is a plain `ORDER BY score DESC` (the
693    // uniform decay factor cancels) — no per-row decay math at read time. `kind`:
694    // 0=unicode, 1=custom. WITHOUT ROWID + (kind,id) PK so a reuse is an in-place
695    // upsert (one row per emoji), not an append.
696    run_atomic_migration(conn, 44, "Create emoji_usage table", |tx| {
697        tx.execute_batch(
698            "CREATE TABLE IF NOT EXISTS emoji_usage (
699                kind      INTEGER NOT NULL,
700                id        TEXT    NOT NULL,
701                url       TEXT,
702                score     REAL    NOT NULL,
703                last_used INTEGER NOT NULL,
704                PRIMARY KEY (kind, id)
705            ) WITHOUT ROWID;
706            CREATE INDEX IF NOT EXISTS idx_emoji_usage_score
707                ON emoji_usage(score DESC);",
708        )
709        .map_err(|e| format!("Failed to create emoji_usage table: {}", e))?;
710        Ok(())
711    })?;
712
713    // Migration 62: Repair — guarantee `label` exists on community_public_invites. Id 43 (which adds it)
714    // was burned on DBs created from an older baseline: recorded as applied without the ALTER ever landing,
715    // so `label` is silently absent and list_all_public_invites errors. Use a fresh id past every recorded
716    // one (DBs already hold up to 61) and add the column only if missing, so it's a no-op where 43 worked.
717    run_atomic_migration(conn, 62, "Repair: ensure label column on community_public_invites", |tx| {
718        let has_label: i64 = tx
719            .query_row(
720                "SELECT COUNT(*) FROM pragma_table_info('community_public_invites') WHERE name = 'label'",
721                [],
722                |r| r.get(0),
723            )
724            .map_err(|e| format!("Failed to inspect community_public_invites columns: {}", e))?;
725        if has_label == 0 {
726            tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
727                .map_err(|e| format!("Failed to add label column: {}", e))?;
728        }
729        Ok(())
730    })?;
731
732    // =========================================================================
733    // Migration 63: Emoji pack health (revocation / durable-absence tracking)
734    // =========================================================================
735    // `status`: 0 = active, 1 = revoked (a deterministic tombstone was seen: an
736    // empty kind-30030 replacement, or an author-signed kind-5 deletion),
737    // 2 = missing (absent across enough clean relay sweeps). The miss columns
738    // drive the promotion gauntlet in `emoji_packs::apply_pack_health`; a live
739    // fetch resets everything back to active.
740    run_atomic_migration(conn, 63, "Add health columns to emoji_packs", |tx| {
741        tx.execute_batch(
742            "ALTER TABLE emoji_packs ADD COLUMN status INTEGER NOT NULL DEFAULT 0;
743             ALTER TABLE emoji_packs ADD COLUMN miss_count INTEGER NOT NULL DEFAULT 0;
744             ALTER TABLE emoji_packs ADD COLUMN first_missed_at INTEGER NOT NULL DEFAULT 0;
745             ALTER TABLE emoji_packs ADD COLUMN last_miss_counted_at INTEGER NOT NULL DEFAULT 0;
746             ALTER TABLE emoji_packs ADD COLUMN status_changed_at INTEGER NOT NULL DEFAULT 0;",
747        )
748        .map_err(|e| format!("Failed to add emoji pack health columns: {}", e))?;
749        Ok(())
750    })?;
751
752    // =========================================================================
753    // Migration 64: Drop orphaned pending-id event rows
754    // =========================================================================
755    // Mid-flight persists could land a row under a message's optimistic
756    // "pending-…" id; the finalized message then saved under its REAL id,
757    // orphaning the pending-keyed row as a ghost duplicate that renders on
758    // reload. Rows still flagged pending/failed are live send-state (the
759    // retry UI needs them) and stay.
760    run_atomic_migration(conn, 64, "Drop orphaned pending-id event rows", |tx| {
761        tx.execute(
762            "DELETE FROM events WHERE id LIKE 'pending-%' AND pending = 0 AND failed = 0",
763            [],
764        )
765        .map_err(|e| format!("Failed to drop orphaned pending rows: {}", e))?;
766        Ok(())
767    })?;
768
769    // =========================================================================
770    // Migration 65: Add position to emoji pack subscriptions
771    // =========================================================================
772    // `subscribed_at` alone can't hold a user-defined order — save_subscriptions
773    // rewrites every row with the same `now`, so ties are unordered. `position`
774    // is the authoritative display order (cross-device synced via kind 10030).
775    // Backfill preserves the current rowid order so existing installs don't
776    // reshuffle on first launch.
777    run_atomic_migration(conn, 65, "Add position to emoji pack subscriptions", |tx| {
778        tx.execute_batch(
779            "ALTER TABLE emoji_pack_subscriptions ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
780             UPDATE emoji_pack_subscriptions SET position = (
781                 SELECT COUNT(*) FROM emoji_pack_subscriptions s2
782                 WHERE s2.rowid < emoji_pack_subscriptions.rowid
783             );",
784        )
785        .map_err(|e| format!("Failed to add position column: {}", e))?;
786        Ok(())
787    })?;
788
789    // Migration 66: Concord v2 dual-stack columns. A community is v1 (the shipped
790    // protocol) or v2 (the self-certifying-id CORD stack); the two coexist per
791    // account. Existing rows default to v1. v2 stores the owner commitment inputs
792    // (owner_pubkey + owner_salt reproduce the community_id) in place of v1's
793    // owner_attestation; server_root_key/server_root_epoch carry the v2
794    // community_root/root_epoch (same base-key role, reused columns). A channel's
795    // `private` flag selects v2 keying: public channels derive from the root (no
796    // stored key), private ones carry an independent key.
797    run_atomic_migration(conn, 66, "Concord v2 dual-stack columns", |tx| {
798        for (table, col, ddl) in [
799            ("communities", "protocol", "INTEGER NOT NULL DEFAULT 1"),
800            ("communities", "owner_pubkey", "TEXT"),
801            ("communities", "owner_salt", "TEXT"),
802            ("community_channels", "private", "INTEGER NOT NULL DEFAULT 0"),
803        ] {
804            // ADD COLUMN is not idempotent; tolerate a re-run (duplicate column).
805            let sql = format!("ALTER TABLE {table} ADD COLUMN {col} {ddl}");
806            if let Err(e) = tx.execute(&sql, []) {
807                let msg = e.to_string();
808                if !msg.contains("duplicate column name") {
809                    return Err(format!("add {table}.{col}: {msg}"));
810                }
811            }
812        }
813        Ok(())
814    })?;
815
816    // Migration 67: the persisted v2 Guestbook — the RAW membership events (one
817    // encrypted JSON blob per community; kick/snapshot validity is judged at fold
818    // time against CURRENT authority, so raw events are the correct stored form)
819    // plus the newest-seen cursor, so boot catches the plane up incrementally and
820    // the memberlist becomes a local read.
821    run_atomic_migration(conn, 67, "v2 guestbook store", |tx| {
822        tx.execute(
823            "CREATE TABLE IF NOT EXISTS community_guestbook (
824                community_id TEXT PRIMARY KEY,
825                events TEXT NOT NULL,
826                cursor_secs INTEGER NOT NULL DEFAULT 0
827            )",
828            [],
829        )
830        .map_err(|e| format!("create community_guestbook: {e}"))?;
831        Ok(())
832    })?;
833
834    // Migration 68: the CORD-02 §6 preservation stash — vsk fields Vector doesn't
835    // drive (voice, client `custom`, unknown `extra`) persist beside the entity so
836    // our own editions republish the FULL document instead of wiping them.
837    run_atomic_migration(conn, 68, "v2 metadata preservation stash", |tx| {
838        for (table, col) in [("communities", "meta_extra"), ("community_channels", "meta_extra")] {
839            let sql = format!("ALTER TABLE {table} ADD COLUMN {col} TEXT");
840            if let Err(e) = tx.execute(&sql, []) {
841                let msg = e.to_string();
842                if !msg.contains("duplicate column name") {
843                    return Err(format!("add {table}.{col}: {msg}"));
844                }
845            }
846        }
847        Ok(())
848    })?;
849
850    // Migration 69: last-known bot manifests (kind 10304) so the `/` command
851    // picker serves instantly from boot; a background refetch replaces a row
852    // only with a newer edition. Manifests are PUBLIC replaceable events, so
853    // rows are plaintext (unlike membership/community state).
854    run_atomic_migration(conn, 69, "bot manifest store", |tx| {
855        tx.execute(
856            "CREATE TABLE IF NOT EXISTS bot_manifests (
857                pubkey TEXT PRIMARY KEY,
858                manifest TEXT NOT NULL,
859                event_created_at INTEGER NOT NULL,
860                fetched_at INTEGER NOT NULL
861            )",
862            [],
863        )
864        .map_err(|e| format!("create bot_manifests: {e}"))?;
865        Ok(())
866    })?;
867
868    Ok(())
869}