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
43-- Settings table (key-value pairs)
44CREATE TABLE IF NOT EXISTS settings (
45    key TEXT PRIMARY KEY,
46    value TEXT NOT NULL
47);
48
49-- Events table: flat, protocol-aligned storage for all Nostr events
50CREATE TABLE IF NOT EXISTS events (
51    id TEXT PRIMARY KEY,
52    kind INTEGER NOT NULL,
53    chat_id INTEGER NOT NULL,
54    user_id INTEGER,
55    content TEXT NOT NULL,
56    tags TEXT NOT NULL DEFAULT '[]',
57    reference_id TEXT,
58    created_at INTEGER NOT NULL,
59    received_at INTEGER NOT NULL,
60    mine INTEGER NOT NULL DEFAULT 0,
61    pending INTEGER NOT NULL DEFAULT 0,
62    failed INTEGER NOT NULL DEFAULT 0,
63    wrapper_event_id TEXT,
64    npub TEXT,
65    preview_metadata TEXT,
66    FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
67    FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE SET NULL
68);
69CREATE INDEX IF NOT EXISTS idx_events_chat_time ON events(chat_id, created_at DESC);
70CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
71CREATE INDEX IF NOT EXISTS idx_events_reference ON events(reference_id) WHERE reference_id IS NOT NULL;
72CREATE INDEX IF NOT EXISTS idx_events_wrapper ON events(wrapper_event_id) WHERE wrapper_event_id IS NOT NULL;
73
74-- PIVX Promos table
75CREATE TABLE IF NOT EXISTS pivx_promos (
76    id INTEGER PRIMARY KEY AUTOINCREMENT,
77    gift_code TEXT NOT NULL UNIQUE,
78    address TEXT NOT NULL,
79    privkey_encrypted TEXT NOT NULL,
80    created_at INTEGER NOT NULL,
81    claimed_at INTEGER,
82    amount_piv REAL,
83    status TEXT NOT NULL DEFAULT 'active'
84);
85CREATE INDEX IF NOT EXISTS idx_pivx_promos_code ON pivx_promos(gift_code);
86CREATE INDEX IF NOT EXISTS idx_pivx_promos_address ON pivx_promos(address);
87CREATE INDEX IF NOT EXISTS idx_pivx_promos_status ON pivx_promos(status);
88
89-- Mini Apps history table
90CREATE TABLE IF NOT EXISTS miniapps_history (
91    id INTEGER PRIMARY KEY AUTOINCREMENT,
92    name TEXT NOT NULL UNIQUE,
93    src_url TEXT NOT NULL,
94    attachment_ref TEXT,
95    open_count INTEGER DEFAULT 1,
96    last_opened_at INTEGER NOT NULL,
97    is_favorite INTEGER NOT NULL DEFAULT 0,
98    categories TEXT NOT NULL DEFAULT '',
99    marketplace_id TEXT DEFAULT NULL,
100    installed_version TEXT DEFAULT NULL
101);
102
103-- Mini App permissions table
104CREATE TABLE IF NOT EXISTS miniapp_permissions (
105    id INTEGER PRIMARY KEY AUTOINCREMENT,
106    file_hash TEXT NOT NULL,
107    permission TEXT NOT NULL,
108    granted INTEGER NOT NULL DEFAULT 0,
109    granted_at INTEGER,
110    UNIQUE(file_hash, permission)
111);
112CREATE INDEX IF NOT EXISTS idx_miniapp_permissions_hash ON miniapp_permissions(file_hash);
113
114-- Processed wrappers table (NIP-59 gift wrap dedup + NIP-77 negentropy)
115-- Universal outer-event ledger across transports. The `transport` discriminator
116-- (0 = nip17 gift-wrap, 1 = concord channel envelope, …) is added by migration 42 so the
117-- dedup is shared but NIP-77 negentropy only fingerprints the nip17 (0) subset.
118CREATE TABLE IF NOT EXISTS processed_wrappers (
119    wrapper_id BLOB PRIMARY KEY,
120    wrapper_created_at INTEGER NOT NULL DEFAULT 0
121);
122
123-- The nip17_wrap_keys vault is introduced by migration 21. The legacy MLS
124-- tables (mls_wrap_keys / mls_pending_events from migrations 22/23) are dropped
125-- by migration 41, so on a fresh DB they're created in order and then removed.
126
127-- Schema migrations tracking table
128CREATE TABLE IF NOT EXISTS schema_migrations (
129    id INTEGER PRIMARY KEY,
130    applied_at INTEGER NOT NULL
131);
132"#;
133
134/// Highest migration id this build knows how to apply.
135///
136/// **Bump this whenever you add a migration below.** A DB carrying anything
137/// above it was written by a newer Vector, so its schema holds changes this
138/// build cannot see and opening it corrupts data.
139///
140/// Leaving it behind is the one way the guard misfires: the new migration
141/// applies on first run, then this build reads its own database as newer and
142/// refuses to open it. The `debug_assert` in [`run_atomic_migration`] and
143/// `highest_migration_id_matches_the_runner` both catch that before release.
144pub const HIGHEST_MIGRATION_ID: u32 = 84;
145
146/// Highest migration id recorded in this DB; 0 for a fresh or pre-tracking one.
147///
148/// Nothing else reads the high-water mark: `schema_migrations` is a *set* of
149/// applied ids and every migration probes its own id, which is exactly why an
150/// older build slides past newer schema without noticing it exists.
151pub fn applied_migration_high_water(conn: &rusqlite::Connection) -> u32 {
152    conn.query_row("SELECT MAX(id) FROM schema_migrations", [], |row| {
153        row.get::<_, Option<u32>>(0)
154    })
155    .ok()
156    .flatten()
157    .unwrap_or(0)
158}
159
160/// Check if a specific migration has already been applied
161pub fn migration_applied(conn: &rusqlite::Connection, migration_id: u32) -> bool {
162    conn.query_row(
163        "SELECT 1 FROM schema_migrations WHERE id = ?1",
164        rusqlite::params![migration_id],
165        |_| Ok(())
166    ).is_ok()
167}
168
169/// Mark a migration as applied (within a transaction)
170pub fn mark_migration_applied(tx: &rusqlite::Transaction, migration_id: u32) -> Result<(), String> {
171    let now = std::time::SystemTime::now()
172        .duration_since(std::time::UNIX_EPOCH)
173        .unwrap()
174        .as_secs() as i64;
175
176    tx.execute(
177        "INSERT INTO schema_migrations (id, applied_at) VALUES (?1, ?2)",
178        rusqlite::params![migration_id, now],
179    ).map_err(|e| format!("[DB] Migration {}: Failed to record: {}", migration_id, e))?;
180
181    Ok(())
182}
183
184/// Run a single migration atomically within a transaction.
185///
186/// GUARANTEES:
187/// - If the migration succeeds: all changes are committed, migration is marked as applied
188/// - If the migration fails: ALL changes are rolled back, database is unchanged
189/// - No partial state is ever possible
190///
191/// This is the ONLY way migrations should be run.
192fn run_atomic_migration<F>(
193    conn: &mut rusqlite::Connection,
194    id: u32,
195    name: &str,
196    migrate: F,
197) -> Result<(), String>
198where
199    F: FnOnce(&rusqlite::Transaction) -> Result<(), String>,
200{
201    // A migration above the constant would apply fine on first run, then be
202    // read as a downgrade on the next one and lock the user out of their own
203    // account. Fires on any debug run, so it lands long before a release even
204    // if nobody ran the test suite.
205    debug_assert!(
206        id <= HIGHEST_MIGRATION_ID,
207        "migration {id} exceeds HIGHEST_MIGRATION_ID ({HIGHEST_MIGRATION_ID}); bump the constant \
208         or this build will refuse the database it just wrote"
209    );
210
211    // Check if this specific migration was already applied.
212    if migration_applied(conn, id) {
213        return Ok(());
214    }
215
216    println!("[DB] Migration {}: {}...", id, name);
217
218    // Start transaction - this is the atomicity boundary
219    let tx = conn.transaction()
220        .map_err(|e| format!("[DB] Migration {}: Failed to start transaction: {}", id, e))?;
221
222    // Run the migration within the transaction
223    match migrate(&tx) {
224        Ok(()) => {
225            // Mark as applied WITHIN the same transaction
226            mark_migration_applied(&tx, id)?;
227
228            // Commit - if this fails, everything rolls back
229            tx.commit()
230                .map_err(|e| format!("[DB] Migration {}: Failed to commit: {}", id, e))?;
231
232            println!("[DB] Migration {} complete", id);
233            Ok(())
234        }
235        Err(e) => {
236            // Transaction automatically rolls back on drop
237            eprintln!("[DB] Migration {} FAILED: {} - rolling back", id, e);
238            Err(e)
239        }
240    }
241}
242
243/// Ensure a column exists on a table, adding it if missing.
244/// This is a safety net for cases where ALTER TABLE inside a WAL-mode
245/// transaction silently fails (e.g., other connections hold read locks).
246#[allow(dead_code)]
247fn ensure_column_exists(
248    conn: &mut rusqlite::Connection,
249    table: &str,
250    column: &str,
251    col_type: &str,
252) -> Result<(), String> {
253    let exists: bool = conn.query_row(
254        &format!("SELECT COUNT(*) FROM pragma_table_info('{}') WHERE name='{}'", table, column),
255        [],
256        |row| row.get::<_, i32>(0),
257    ).map(|c| c > 0).unwrap_or(false);
258
259    if !exists {
260        println!("[DB] Safety net: adding missing column {}.{}", table, column);
261        conn.execute(
262            &format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, col_type),
263            [],
264        ).map_err(|e| format!("[DB] Failed to add column {}.{}: {}", table, column, e))?;
265    }
266    Ok(())
267}
268
269/// Run database migrations for schema updates
270///
271/// GUARANTEES:
272/// - Each migration runs in a transaction (atomic - all or nothing)
273/// - If any migration fails, changes are rolled back - no partial state
274/// - Migrations are tracked in schema_migrations table (idempotent - safe to re-run)
275/// - All errors are logged with [DB] prefix and propagated (no silent failures)
276pub fn run_migrations(conn: &mut rusqlite::Connection) -> Result<(), String> {
277    // Ensure schema_migrations table exists (bootstrap - must succeed before any migrations)
278    conn.execute(
279        "CREATE TABLE IF NOT EXISTS schema_migrations (
280            id INTEGER PRIMARY KEY,
281            applied_at INTEGER NOT NULL
282        )",
283        [],
284    ).map_err(|e| format!("[DB] Failed to create schema_migrations table: {}", e))?;
285
286    // =========================================================================
287    // Migration 19: Create marketplace_cache table for persistent Nexus cache
288    // =========================================================================
289    // Caches marketplace app listings in SQLite so they survive restarts.
290    // On login, the cache is loaded into MARKETPLACE_STATE immediately (so
291    // permission checks work before the user visits the Nexus tab), then a
292    // background network fetch refreshes the data.
293    run_atomic_migration(conn, 19, "Create marketplace_cache table", |tx| {
294        tx.execute_batch(
295            "CREATE TABLE IF NOT EXISTS marketplace_cache (
296                id TEXT PRIMARY KEY,
297                data TEXT NOT NULL,
298                fetched_at INTEGER NOT NULL
299            );"
300        ).map_err(|e| format!("Failed to create marketplace_cache table: {}", e))?;
301        Ok(())
302    })?;
303
304    // =========================================================================
305    // Migration 20: Add is_blocked column to profiles table
306    // =========================================================================
307    // Supports user blocking: blocked profiles have DM events dropped after
308    // decrypt (wrapper kept for negentropy), group messages filtered in UI.
309    run_atomic_migration(conn, 20, "Add is_blocked column to profiles", |tx| {
310        tx.execute_batch(
311            "ALTER TABLE profiles ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0;"
312        ).map_err(|e| format!("Failed to add is_blocked column: {}", e))?;
313        Ok(())
314    })?;
315
316    // =========================================================================
317    // Migration 21: NIP-17 ephemeral wrap-key vault for deletable DMs
318    // =========================================================================
319    // Stores the ephemeral secp256k1 secret used to sign each kind-1059
320    // gift-wrap so the user can later publish a NIP-09 deletion against
321    // the wrap event ID — actually removing the message from inbox relays.
322    // Encryption-at-rest is handled by Vector's per-account database
323    // envelope (ChaCha20 if the account has a password; plaintext otherwise).
324    // One row per published wrap; deletion uses (wrap_event_id, secret,
325    // relay_urls) to issue an author-signed NIP-09 to the same relay set.
326    run_atomic_migration(conn, 21, "Create nip17_wrap_keys table", |tx| {
327        tx.execute_batch(
328            "CREATE TABLE IF NOT EXISTS nip17_wrap_keys (
329                wrap_event_id    TEXT PRIMARY KEY,
330                rumor_id         TEXT NOT NULL,
331                recipient_pubkey TEXT NOT NULL,
332                role             INTEGER NOT NULL,
333                secret           BLOB NOT NULL,
334                relay_urls       TEXT NOT NULL,
335                created_at       INTEGER NOT NULL
336            );
337            CREATE INDEX IF NOT EXISTS idx_nip17_wrap_keys_rumor ON nip17_wrap_keys(rumor_id);"
338        ).map_err(|e| format!("Failed to create nip17_wrap_keys table: {}", e))?;
339        Ok(())
340    })?;
341
342    // =========================================================================
343    // Migration 22: MLS ephemeral wrap-key vault for deletable group messages
344    // =========================================================================
345    // Sibling of nip17_wrap_keys: every kind-445 MLS wrapper is signed by an
346    // ephemeral keypair that MDK normally discards. With our `create_message_retained`
347    // patch the sender retains the secret so a later NIP-09 deletion against
348    // the kind-445 event id is valid (NIP-09 requires `event.pubkey ==
349    // deletion.pubkey`). One row per published wrapper; retries write new rows.
350    run_atomic_migration(conn, 22, "Create mls_wrap_keys table", |tx| {
351        tx.execute_batch(
352            "CREATE TABLE IF NOT EXISTS mls_wrap_keys (
353                wrap_event_id TEXT PRIMARY KEY,
354                message_id    TEXT NOT NULL,
355                group_id      TEXT NOT NULL,
356                secret        BLOB NOT NULL,
357                relay_urls    TEXT NOT NULL,
358                created_at    INTEGER NOT NULL
359            );
360            CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_message ON mls_wrap_keys(message_id);
361            CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_group ON mls_wrap_keys(group_id);"
362        ).map_err(|e| format!("Failed to create mls_wrap_keys table: {}", e))?;
363        Ok(())
364    })?;
365
366    // =========================================================================
367    // Migration 23: MLS pending event queue for cross-sync retry
368    // =========================================================================
369    // When MDK can't process an MLS event because its prerequisite commit
370    // hasn't arrived, we previously marked it "processed" and advanced the
371    // cursor past it — losing it forever. This table persists such events
372    // so subsequent syncs can retry once the prerequisite shows up (possibly
373    // from a different relay, days or weeks later). Pruned at 90 days.
374    run_atomic_migration(conn, 23, "Create mls_pending_events table", |tx| {
375        tx.execute_batch(
376            "CREATE TABLE IF NOT EXISTS mls_pending_events (
377                event_id      TEXT PRIMARY KEY,
378                group_id      TEXT NOT NULL,
379                event_json    TEXT NOT NULL,
380                first_seen_at INTEGER NOT NULL,
381                last_retry_at INTEGER NOT NULL,
382                retry_count   INTEGER NOT NULL DEFAULT 0
383            );
384            CREATE INDEX IF NOT EXISTS idx_mls_pending_events_group ON mls_pending_events(group_id);
385            CREATE INDEX IF NOT EXISTS idx_mls_pending_events_first_seen ON mls_pending_events(first_seen_at);"
386        ).map_err(|e| format!("Failed to create mls_pending_events table: {}", e))?;
387        Ok(())
388    })?;
389
390    // =========================================================================
391    // Migration 24: Blossom capability cache — drives smart upload routing.
392    // =========================================================================
393    run_atomic_migration(conn, 24, "Create blossom_server_capabilities table", |tx| {
394        tx.execute_batch(
395            "CREATE TABLE IF NOT EXISTS blossom_server_capabilities (
396                server_url        TEXT    NOT NULL,
397                mime_type         TEXT    NOT NULL,
398                outcome           INTEGER NOT NULL,
399                max_accepted_size INTEGER NOT NULL DEFAULT 0,
400                updated_at        INTEGER NOT NULL,
401                PRIMARY KEY (server_url, mime_type)
402            );"
403        ).map_err(|e| format!("Failed to create blossom_server_capabilities table: {}", e))?;
404        Ok(())
405    })?;
406
407    // =========================================================================
408    // Migration 25: Add `min_rejected_size` (smallest observed 413).
409    // =========================================================================
410    run_atomic_migration(conn, 25, "Add min_rejected_size to blossom_server_capabilities", |tx| {
411        tx.execute_batch(
412            "ALTER TABLE blossom_server_capabilities ADD COLUMN min_rejected_size INTEGER;"
413        ).map_err(|e| format!("Failed to add min_rejected_size column: {}", e))?;
414        Ok(())
415    })?;
416
417    // =========================================================================
418    // Migration 26: Split capability rows by encrypted vs plaintext context.
419    // Same wire MIME means different things for ciphertext vs real bytes;
420    // pre-migration rows didn't track the distinction so they're dropped.
421    // =========================================================================
422    run_atomic_migration(conn, 26, "Add is_encrypted to capability cache PK", |tx| {
423        tx.execute_batch(
424            "DROP TABLE IF EXISTS blossom_server_capabilities;
425             CREATE TABLE blossom_server_capabilities (
426                server_url        TEXT    NOT NULL,
427                mime_type         TEXT    NOT NULL,
428                is_encrypted      INTEGER NOT NULL DEFAULT 0,
429                outcome           INTEGER NOT NULL,
430                max_accepted_size INTEGER NOT NULL DEFAULT 0,
431                min_rejected_size INTEGER,
432                updated_at        INTEGER NOT NULL,
433                PRIMARY KEY (server_url, mime_type, is_encrypted)
434             );"
435        ).map_err(|e| format!("Failed to recreate blossom_server_capabilities: {}", e))?;
436        Ok(())
437    })?;
438
439    // =========================================================================
440    // Migration 27: Mark NIP-46 remote-signer support landed.
441    //
442    // Settings is a KV — no schema change needed for the three new keys
443    // (`signer_type`, `bunker_url`, `bunker_remote_pubkey`). Pre-bunker
444    // accounts have no `signer_type` row at all; the loader treats missing
445    // as `local`. We backfill an explicit `signer_type='local'` row so every
446    // account has a discriminator on disk after this point — makes the
447    // discriminator query a clean `=` instead of a NULL-coalesce.
448    // =========================================================================
449    run_atomic_migration(conn, 27, "Backfill signer_type=local for pre-NIP-46 accounts", |tx| {
450        tx.execute(
451            "INSERT OR IGNORE INTO settings (key, value) VALUES ('signer_type', 'local')",
452            [],
453        ).map_err(|e| format!("Failed to backfill signer_type: {}", e))?;
454        Ok(())
455    })?;
456
457    // =========================================================================
458    // Migration 28: NIP-30 / NIP-51 custom emoji packs
459    // =========================================================================
460    // `emoji_packs`           — kind 30030 sets (own + subscribed), one row per addr.
461    // `emoji_pack_items`      — flattened emoji rows per pack; CASCADE deletes follow.
462    // `emoji_pack_subscriptions` — local mirror of kind 10030 `a` tags; fast startup
463    //                              read without re-fetching from relays.
464    run_atomic_migration(conn, 28, "Create emoji pack tables", |tx| {
465        tx.execute_batch(
466            "CREATE TABLE IF NOT EXISTS emoji_packs (
467                addr        TEXT PRIMARY KEY,
468                pubkey      TEXT NOT NULL,
469                identifier  TEXT NOT NULL,
470                title       TEXT NOT NULL DEFAULT '',
471                image_url   TEXT NOT NULL DEFAULT '',
472                description TEXT NOT NULL DEFAULT '',
473                is_own      INTEGER NOT NULL DEFAULT 0,
474                updated_at  INTEGER NOT NULL,
475                raw_event   TEXT NOT NULL DEFAULT ''
476            );
477            CREATE INDEX IF NOT EXISTS idx_emoji_packs_pubkey ON emoji_packs(pubkey);
478            CREATE INDEX IF NOT EXISTS idx_emoji_packs_is_own ON emoji_packs(is_own);
479
480            CREATE TABLE IF NOT EXISTS emoji_pack_items (
481                pack_addr  TEXT NOT NULL,
482                shortcode  TEXT NOT NULL,
483                url        TEXT NOT NULL,
484                sha256     TEXT,
485                position   INTEGER NOT NULL DEFAULT 0,
486                PRIMARY KEY (pack_addr, shortcode),
487                FOREIGN KEY (pack_addr) REFERENCES emoji_packs(addr) ON DELETE CASCADE
488            );
489            CREATE INDEX IF NOT EXISTS idx_emoji_pack_items_pack ON emoji_pack_items(pack_addr, position);
490
491            CREATE TABLE IF NOT EXISTS emoji_pack_subscriptions (
492                addr           TEXT PRIMARY KEY,
493                subscribed_at  INTEGER NOT NULL
494            );"
495        ).map_err(|e| format!("Failed to create emoji pack tables: {}", e))?;
496        Ok(())
497    })?;
498
499    // =========================================================================
500    // Migration 29: Add per-DM wallpaper columns to chats
501    // =========================================================================
502    // Wallpaper is the local cached file path (decrypted from the Blossom
503    // attachment carried by the most recent kind-30078 d=vector-wallpaper rumor
504    // for this chat). wallpaper_ts is the rumor created_at that produced it,
505    // used for latest-write-wins on concurrent sets.
506    run_atomic_migration(conn, 29, "Add wallpaper columns to chats", |tx| {
507        tx.execute_batch(
508            "ALTER TABLE chats ADD COLUMN wallpaper_path TEXT NOT NULL DEFAULT '';
509             ALTER TABLE chats ADD COLUMN wallpaper_ts INTEGER NOT NULL DEFAULT 0;"
510        ).map_err(|e| format!("Failed to add wallpaper columns: {}", e))?;
511        Ok(())
512    })?;
513
514    // =========================================================================
515    // Migration 30: Wallpaper customisation knobs (blur + brightness)
516    // =========================================================================
517    // blur: integer pixels, 0..=30 (0 = no blur).
518    // dim:  integer percent, 0..=100 (100 = no darkening, 0 = fully black).
519    // Defaults match the values applied when a rumor arrives without the
520    // optional tags — keeps older clients interoperable.
521    run_atomic_migration(conn, 30, "Add wallpaper blur/dim columns to chats", |tx| {
522        tx.execute_batch(
523            "ALTER TABLE chats ADD COLUMN wallpaper_blur INTEGER NOT NULL DEFAULT 0;
524             ALTER TABLE chats ADD COLUMN wallpaper_dim INTEGER NOT NULL DEFAULT 50;"
525        ).map_err(|e| format!("Failed to add wallpaper blur/dim columns: {}", e))?;
526        Ok(())
527    })?;
528
529    // =========================================================================
530    // Migration 31: Track wallpaper Blossom URL + uploader pubkey
531    // =========================================================================
532    // wallpaper_url is the Blossom blob URL of the current wallpaper.
533    // wallpaper_uploader is the npub (bech32) of whoever uploaded it. Together
534    // they let us DELETE the previous blob from Blossom when we (or another
535    // device of ours) replace the wallpaper — only the original uploader's
536    // signature satisfies the server's auth challenge.
537    run_atomic_migration(conn, 31, "Add wallpaper url/uploader columns to chats", |tx| {
538        tx.execute_batch(
539            "ALTER TABLE chats ADD COLUMN wallpaper_url TEXT NOT NULL DEFAULT '';
540             ALTER TABLE chats ADD COLUMN wallpaper_uploader TEXT NOT NULL DEFAULT '';"
541        ).map_err(|e| format!("Failed to add wallpaper url/uploader columns: {}", e))?;
542        Ok(())
543    })?;
544
545    // =========================================================================
546    // Migration 32: Drop mls_event_cursors — superseded by Total Negentropy
547    // =========================================================================
548    // MLS sync no longer tracks a per-group cursor. Possession of an event
549    // (mls_processed_events ∪ mls_pending_events) is the negentropy fingerprint
550    // set, and reconciliation derives the missing set directly. The cursor was
551    // a pre-negentropy resume mechanism that could only disagree with it.
552    run_atomic_migration(conn, 32, "Drop mls_event_cursors table", |tx| {
553        tx.execute_batch("DROP TABLE IF EXISTS mls_event_cursors;")
554            .map_err(|e| format!("Failed to drop mls_event_cursors: {}", e))?;
555        Ok(())
556    })?;
557
558    // =========================================================================
559    // GAP: migration ids 33-39 are PERMANENTLY BURNED — do not reuse.
560    // =========================================================================
561    // The distributed v0.4.0 "MLS edition" shipped MLS migrations in the 33-39 range that
562    // never made it into committed history (its release branch was later squashed to max 32).
563    // Migrations are tracked per-id (`schema_migrations`), not by a monotonic counter, so an
564    // MLS-edition DB has 33-39 recorded and would SKIP any new migration reusing those ids,
565    // silently never creating the table. Community state therefore starts at 40. Never fill
566    // the 33-39 gap, even though it looks tidy — those ids are spent forever.
567    //
568    // Migration 40: Community (Concord) protocol local state
569    // =========================================================================
570    // Per-account (the DB itself is account-scoped via account_dir(npub)). Holds the
571    // owner/member's held secrets (server-root key, epoch-tagged channel keys), the folded
572    // control-plane state, and local invite/dedup bookkeeping. Ids are hex. Authority is
573    // keyless: real-npub control editions + the owner attestation, never a shared secret.
574    run_atomic_migration(conn, 40, "Create community tables", |tx| {
575        tx.execute_batch(
576            "CREATE TABLE IF NOT EXISTS communities (
577                community_id          TEXT PRIMARY KEY,
578                server_root_key       BLOB NOT NULL,
579                name                  TEXT NOT NULL,
580                relays                TEXT NOT NULL,
581                created_at            INTEGER NOT NULL,
582                description           TEXT,
583                icon                  TEXT,
584                banner                TEXT,
585                banlist               TEXT NOT NULL DEFAULT '[]',
586                banlist_at            INTEGER NOT NULL DEFAULT 0,
587                owner_attestation     TEXT,
588                roles                 TEXT NOT NULL DEFAULT '{}',
589                roles_at              INTEGER NOT NULL DEFAULT 0,
590                server_root_epoch     INTEGER NOT NULL DEFAULT 0,
591                invite_registry       TEXT NOT NULL DEFAULT '[]',
592                read_cut_pending      INTEGER NOT NULL DEFAULT 0,
593                read_cut_target_epoch INTEGER NOT NULL DEFAULT 0,
594                dissolved             INTEGER NOT NULL DEFAULT 0
595            );
596            CREATE TABLE IF NOT EXISTS community_channels (
597                channel_id              TEXT PRIMARY KEY,
598                community_id            TEXT NOT NULL,
599                channel_key             BLOB NOT NULL,
600                epoch                   INTEGER NOT NULL,
601                name                    TEXT NOT NULL,
602                created_at              INTEGER NOT NULL,
603                rekeyed_at_server_epoch INTEGER NOT NULL DEFAULT 0
604            );
605            CREATE INDEX IF NOT EXISTS idx_community_channels_community
606                ON community_channels(community_id);
607            CREATE TABLE IF NOT EXISTS community_message_keys (
608                outer_event_id   TEXT PRIMARY KEY,
609                ephemeral_secret BLOB NOT NULL,
610                relays           TEXT NOT NULL,
611                created_at       INTEGER NOT NULL,
612                message_id       TEXT
613            );
614            CREATE INDEX IF NOT EXISTS idx_cmk_message_id
615                ON community_message_keys(message_id);
616            CREATE TABLE IF NOT EXISTS pending_community_invites (
617                community_id TEXT PRIMARY KEY,
618                bundle_json  TEXT NOT NULL,
619                inviter_npub TEXT NOT NULL,
620                received_at  INTEGER NOT NULL
621            );
622            CREATE TABLE IF NOT EXISTS community_public_invites (
623                token        TEXT PRIMARY KEY,
624                community_id TEXT NOT NULL,
625                url          TEXT NOT NULL,
626                expires_at   INTEGER,
627                created_at   INTEGER NOT NULL
628            );
629            CREATE INDEX IF NOT EXISTS idx_public_invites_community
630                ON community_public_invites(community_id);
631            CREATE TABLE IF NOT EXISTS community_edition_heads (
632                community_id TEXT NOT NULL,
633                entity_id    TEXT NOT NULL,
634                version      INTEGER NOT NULL,
635                self_hash    BLOB NOT NULL,
636                inner_id     BLOB,
637                epoch        INTEGER NOT NULL DEFAULT 0,
638                PRIMARY KEY (community_id, entity_id)
639            );
640            CREATE TABLE IF NOT EXISTS community_epoch_keys (
641                community_id TEXT NOT NULL,
642                scope_id     TEXT NOT NULL,
643                epoch        INTEGER NOT NULL,
644                key          BLOB NOT NULL,
645                created_at   INTEGER NOT NULL,
646                PRIMARY KEY (community_id, scope_id, epoch)
647            );
648            CREATE TABLE IF NOT EXISTS community_invite_link_sets (
649                community_id TEXT NOT NULL,
650                creator      TEXT NOT NULL,
651                locators     TEXT NOT NULL DEFAULT '[]',
652                version      INTEGER NOT NULL DEFAULT 0,
653                PRIMARY KEY (community_id, creator)
654            );",
655        )
656        .map_err(|e| format!("Failed to create community tables: {}", e))?;
657        Ok(())
658    })?;
659
660    // =========================================================================
661    // Migration 41: Purge legacy MLS data (MLS is fully removed)
662    // =========================================================================
663    // Drop the retired chat_type=1 (MlsGroup) chats + their events, then the MLS-only
664    // storage tables. chat_type 2 (Community) is untouched. Runs for accounts upgrading
665    // from an MLS build; a no-op on a fresh DB.
666    run_atomic_migration(conn, 41, "Purge legacy MLS data", |tx| {
667        tx.execute_batch(
668            "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_type = 1);
669             DELETE FROM chats WHERE chat_type = 1;
670             DROP TABLE IF EXISTS mls_groups;
671             DROP TABLE IF EXISTS mls_keypackages;
672             DROP TABLE IF EXISTS mls_processed_events;
673             DROP TABLE IF EXISTS mls_wrap_keys;
674             DROP TABLE IF EXISTS mls_pending_events;",
675        )
676        .map_err(|e| format!("Failed to purge legacy MLS data: {}", e))?;
677        Ok(())
678    })?;
679
680    // =========================================================================
681    // Migration 42: Make processed_wrappers a cross-transport dedup ledger
682    // =========================================================================
683    // A `transport` discriminator so every transport (NIP-17 DMs, Concord) shares ONE
684    // outer-event dedup store, while NIP-77 negentropy keeps fingerprinting only the 'nip17'
685    // subset. Existing rows are gift-wraps, so the default 0 ('nip17') is correct.
686    run_atomic_migration(conn, 42, "Add transport discriminator to processed_wrappers", |tx| {
687        tx.execute_batch("ALTER TABLE processed_wrappers ADD COLUMN transport INTEGER NOT NULL DEFAULT 0;")
688            .map_err(|e| format!("Failed to add transport column: {}", e))?;
689        Ok(())
690    })?;
691
692    // =========================================================================
693    // Migration 43: Persist the optional label on a minted public invite
694    // =========================================================================
695    // The label set at mint time rides in the relay-published bundle (join attribution) but wasn't
696    // stored locally, so the owner's invite-links list had no label to show. Encrypted-at-rest like
697    // the sibling columns; NULL when no label was set.
698    run_atomic_migration(conn, 43, "Add label to community_public_invites", |tx| {
699        tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
700            .map_err(|e| format!("Failed to add label column: {}", e))?;
701        Ok(())
702    })?;
703
704    // Migration 44: Per-account emoji "frecency" (most-used) table.
705    // =========================================================================
706    // `score` is a time-weighted log-space value: each use adds
707    // 2^((t-EPOCH)/half_life), so ranking is a plain `ORDER BY score DESC` (the
708    // uniform decay factor cancels) — no per-row decay math at read time. `kind`:
709    // 0=unicode, 1=custom. WITHOUT ROWID + (kind,id) PK so a reuse is an in-place
710    // upsert (one row per emoji), not an append.
711    run_atomic_migration(conn, 44, "Create emoji_usage table", |tx| {
712        tx.execute_batch(
713            "CREATE TABLE IF NOT EXISTS emoji_usage (
714                kind      INTEGER NOT NULL,
715                id        TEXT    NOT NULL,
716                url       TEXT,
717                score     REAL    NOT NULL,
718                last_used INTEGER NOT NULL,
719                PRIMARY KEY (kind, id)
720            ) WITHOUT ROWID;
721            CREATE INDEX IF NOT EXISTS idx_emoji_usage_score
722                ON emoji_usage(score DESC);",
723        )
724        .map_err(|e| format!("Failed to create emoji_usage table: {}", e))?;
725        Ok(())
726    })?;
727
728    // Migration 62: Repair — guarantee `label` exists on community_public_invites. Id 43 (which adds it)
729    // was burned on DBs created from an older baseline: recorded as applied without the ALTER ever landing,
730    // so `label` is silently absent and list_all_public_invites errors. Use a fresh id past every recorded
731    // one (DBs already hold up to 61) and add the column only if missing, so it's a no-op where 43 worked.
732    run_atomic_migration(conn, 62, "Repair: ensure label column on community_public_invites", |tx| {
733        let has_label: i64 = tx
734            .query_row(
735                "SELECT COUNT(*) FROM pragma_table_info('community_public_invites') WHERE name = 'label'",
736                [],
737                |r| r.get(0),
738            )
739            .map_err(|e| format!("Failed to inspect community_public_invites columns: {}", e))?;
740        if has_label == 0 {
741            tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
742                .map_err(|e| format!("Failed to add label column: {}", e))?;
743        }
744        Ok(())
745    })?;
746
747    // =========================================================================
748    // Migration 63: Emoji pack health (revocation / durable-absence tracking)
749    // =========================================================================
750    // `status`: 0 = active, 1 = revoked (a deterministic tombstone was seen: an
751    // empty kind-30030 replacement, or an author-signed kind-5 deletion),
752    // 2 = missing (absent across enough clean relay sweeps). The miss columns
753    // drive the promotion gauntlet in `emoji_packs::apply_pack_health`; a live
754    // fetch resets everything back to active.
755    run_atomic_migration(conn, 63, "Add health columns to emoji_packs", |tx| {
756        tx.execute_batch(
757            "ALTER TABLE emoji_packs ADD COLUMN status INTEGER NOT NULL DEFAULT 0;
758             ALTER TABLE emoji_packs ADD COLUMN miss_count INTEGER NOT NULL DEFAULT 0;
759             ALTER TABLE emoji_packs ADD COLUMN first_missed_at INTEGER NOT NULL DEFAULT 0;
760             ALTER TABLE emoji_packs ADD COLUMN last_miss_counted_at INTEGER NOT NULL DEFAULT 0;
761             ALTER TABLE emoji_packs ADD COLUMN status_changed_at INTEGER NOT NULL DEFAULT 0;",
762        )
763        .map_err(|e| format!("Failed to add emoji pack health columns: {}", e))?;
764        Ok(())
765    })?;
766
767    // =========================================================================
768    // Migration 64: Drop orphaned pending-id event rows
769    // =========================================================================
770    // Mid-flight persists could land a row under a message's optimistic
771    // "pending-…" id; the finalized message then saved under its REAL id,
772    // orphaning the pending-keyed row as a ghost duplicate that renders on
773    // reload. Rows still flagged pending/failed are live send-state (the
774    // retry UI needs them) and stay.
775    run_atomic_migration(conn, 64, "Drop orphaned pending-id event rows", |tx| {
776        tx.execute(
777            "DELETE FROM events WHERE id LIKE 'pending-%' AND pending = 0 AND failed = 0",
778            [],
779        )
780        .map_err(|e| format!("Failed to drop orphaned pending rows: {}", e))?;
781        Ok(())
782    })?;
783
784    // =========================================================================
785    // Migration 65: Add position to emoji pack subscriptions
786    // =========================================================================
787    // `subscribed_at` alone can't hold a user-defined order — save_subscriptions
788    // rewrites every row with the same `now`, so ties are unordered. `position`
789    // is the authoritative display order (cross-device synced via kind 10030).
790    // Backfill preserves the current rowid order so existing installs don't
791    // reshuffle on first launch.
792    run_atomic_migration(conn, 65, "Add position to emoji pack subscriptions", |tx| {
793        tx.execute_batch(
794            "ALTER TABLE emoji_pack_subscriptions ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
795             UPDATE emoji_pack_subscriptions SET position = (
796                 SELECT COUNT(*) FROM emoji_pack_subscriptions s2
797                 WHERE s2.rowid < emoji_pack_subscriptions.rowid
798             );",
799        )
800        .map_err(|e| format!("Failed to add position column: {}", e))?;
801        Ok(())
802    })?;
803
804    // Migration 66: Concord v2 dual-stack columns. A community is v1 (the shipped
805    // protocol) or v2 (the self-certifying-id CORD stack); the two coexist per
806    // account. Existing rows default to v1. v2 stores the owner commitment inputs
807    // (owner_pubkey + owner_salt reproduce the community_id) in place of v1's
808    // owner_attestation; server_root_key/server_root_epoch carry the v2
809    // community_root/root_epoch (same base-key role, reused columns). A channel's
810    // `private` flag selects v2 keying: public channels derive from the root (no
811    // stored key), private ones carry an independent key.
812    run_atomic_migration(conn, 66, "Concord v2 dual-stack columns", |tx| {
813        for (table, col, ddl) in [
814            ("communities", "protocol", "INTEGER NOT NULL DEFAULT 1"),
815            ("communities", "owner_pubkey", "TEXT"),
816            ("communities", "owner_salt", "TEXT"),
817            ("community_channels", "private", "INTEGER NOT NULL DEFAULT 0"),
818        ] {
819            // ADD COLUMN is not idempotent; tolerate a re-run (duplicate column).
820            let sql = format!("ALTER TABLE {table} ADD COLUMN {col} {ddl}");
821            if let Err(e) = tx.execute(&sql, []) {
822                let msg = e.to_string();
823                if !msg.contains("duplicate column name") {
824                    return Err(format!("add {table}.{col}: {msg}"));
825                }
826            }
827        }
828        Ok(())
829    })?;
830
831    // Migration 67: the persisted v2 Guestbook — the RAW membership events (one
832    // encrypted JSON blob per community; kick/snapshot validity is judged at fold
833    // time against CURRENT authority, so raw events are the correct stored form)
834    // plus the newest-seen cursor, so boot catches the plane up incrementally and
835    // the memberlist becomes a local read.
836    run_atomic_migration(conn, 67, "v2 guestbook store", |tx| {
837        tx.execute(
838            "CREATE TABLE IF NOT EXISTS community_guestbook (
839                community_id TEXT PRIMARY KEY,
840                events TEXT NOT NULL,
841                cursor_secs INTEGER NOT NULL DEFAULT 0
842            )",
843            [],
844        )
845        .map_err(|e| format!("create community_guestbook: {e}"))?;
846        Ok(())
847    })?;
848
849    // Migration 68: the CORD-02 §6 preservation stash — vsk fields Vector doesn't
850    // drive (voice, client `custom`, unknown `extra`) persist beside the entity so
851    // our own editions republish the FULL document instead of wiping them.
852    run_atomic_migration(conn, 68, "v2 metadata preservation stash", |tx| {
853        for (table, col) in [("communities", "meta_extra"), ("community_channels", "meta_extra")] {
854            let sql = format!("ALTER TABLE {table} ADD COLUMN {col} TEXT");
855            if let Err(e) = tx.execute(&sql, []) {
856                let msg = e.to_string();
857                if !msg.contains("duplicate column name") {
858                    return Err(format!("add {table}.{col}: {msg}"));
859                }
860            }
861        }
862        Ok(())
863    })?;
864
865    // Migration 69: last-known bot manifests (kind 10304) so the `/` command
866    // picker serves instantly from boot; a background refetch replaces a row
867    // only with a newer edition. Manifests are PUBLIC replaceable events, so
868    // rows are plaintext (unlike membership/community state).
869    run_atomic_migration(conn, 69, "bot manifest store", |tx| {
870        tx.execute(
871            "CREATE TABLE IF NOT EXISTS bot_manifests (
872                pubkey TEXT PRIMARY KEY,
873                manifest TEXT NOT NULL,
874                event_created_at INTEGER NOT NULL,
875                fetched_at INTEGER NOT NULL
876            )",
877            [],
878        )
879        .map_err(|e| format!("create bot_manifests: {e}"))?;
880        Ok(())
881    })?;
882
883    // =========================================================================
884    // Migration 70: Retained gift-wrap body for idempotent manual retry
885    // =========================================================================
886    // A failed (red) DM whose wrap silently landed would double-post on manual
887    // Retry, because Retry rebuilt a fresh wrap with a new outer id. Retaining
888    // the exact recipient wrap event (+ its rumor, + the local pending id to
889    // look it up by) lets Retry republish the byte-identical event: relays
890    // no-op the duplicate, so duplication is impossible regardless of client.
891    // The body columns are nulled the instant the send is confirmed (a relay
892    // OK), so steady-state they are NULL — only unsent messages carry a body.
893    run_atomic_migration(conn, 70, "Retained gift-wrap body for idempotent retry", |tx| {
894        tx.execute_batch(
895            "ALTER TABLE nip17_wrap_keys ADD COLUMN wrap_json  TEXT;
896             ALTER TABLE nip17_wrap_keys ADD COLUMN rumor_json TEXT;
897             ALTER TABLE nip17_wrap_keys ADD COLUMN pending_id TEXT;
898             CREATE INDEX IF NOT EXISTS idx_nip17_wrap_keys_pending ON nip17_wrap_keys(pending_id);"
899        ).map_err(|e| format!("Failed to add resend-payload columns: {}", e))?;
900        Ok(())
901    })?;
902
903    // =========================================================================
904    // Migration 71: Covering index for the unread-count query
905    // =========================================================================
906    // Column order = (chat_id, mine, kind) equality then a created_at range; the four columns
907    // cover both the per-chat anchor MAX and the count, so neither touches the table.
908    run_atomic_migration(conn, 71, "Covering index for unread counts", |tx| {
909        tx.execute_batch(
910            "CREATE INDEX IF NOT EXISTS idx_events_unread ON events(chat_id, mine, kind, created_at);"
911        ).map_err(|e| format!("Failed to create unread covering index: {}", e))?;
912        Ok(())
913    })?;
914
915    // =========================================================================
916    // Migration 72: Drop the unused events(user_id) index
917    // =========================================================================
918    // No query filters, joins, or orders by events.user_id, so the index only
919    // cost a b-tree write on every event insert. Authors resolve via the
920    // denormalized npub column instead.
921    run_atomic_migration(conn, 72, "Drop unused events user_id index", |tx| {
922        tx.execute_batch("DROP INDEX IF EXISTS idx_events_user;")
923            .map_err(|e| format!("Failed to drop idx_events_user: {}", e))?;
924        Ok(())
925    })?;
926
927    // =========================================================================
928    // Migration 73: Drop the legacy `messages` table
929    // =========================================================================
930    // Superseded by `events` at v0.3.1 (its data + attachment metadata were
931    // copied over then). The public app has shipped on `events` since v0.4.0,
932    // so no live account writes or reads `messages`. DROP takes its indexes too.
933    run_atomic_migration(conn, 73, "Drop legacy messages table", |tx| {
934        tx.execute_batch("DROP TABLE IF EXISTS messages;")
935            .map_err(|e| format!("Failed to drop legacy messages table: {}", e))?;
936        Ok(())
937    })?;
938
939    // =========================================================================
940    // Migration 74: Dedicated attachments table + backfill from event tags
941    // =========================================================================
942    // Attachments lived as a `["attachments", <json>]` entry inside events.tags,
943    // making dedup a LIKE scan, the integrity check a per-event JSON parse, and
944    // every download flip a read-modify-write of the whole tags blob. Normalize
945    // into one row per attachment, keyed to its event (cascade on delete) and
946    // indexed by content hash. Backfill from the existing tags in this same
947    // transaction; the original tag is LEFT IN PLACE as a safety net (the table
948    // is authoritative, but no data is destroyed) until a later release strips it.
949    // Tags are plaintext at rest (only content is encrypted), so no decrypt here.
950    run_atomic_migration(conn, 74, "Attachments table + backfill", |tx| {
951        tx.execute_batch(
952            "CREATE TABLE IF NOT EXISTS attachments (
953                id           INTEGER PRIMARY KEY,
954                event_id     TEXT NOT NULL,
955                att_index    INTEGER NOT NULL,
956                hash         TEXT NOT NULL,
957                key          TEXT NOT NULL DEFAULT '',
958                nonce        TEXT NOT NULL DEFAULT '',
959                extension    TEXT NOT NULL DEFAULT '',
960                name         TEXT NOT NULL DEFAULT '',
961                url          TEXT NOT NULL DEFAULT '',
962                path         TEXT NOT NULL DEFAULT '',
963                size         INTEGER NOT NULL DEFAULT 0,
964                img_meta     TEXT,
965                downloaded   INTEGER NOT NULL DEFAULT 0,
966                webxdc_topic TEXT, group_id TEXT, original_hash TEXT, scheme_version TEXT, mls_filename TEXT,
967                UNIQUE(event_id, att_index),
968                FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE
969            );
970            CREATE INDEX IF NOT EXISTS idx_attachments_hash       ON attachments(hash);
971            CREATE INDEX IF NOT EXISTS idx_attachments_downloaded ON attachments(downloaded) WHERE downloaded = 1;"
972        ).map_err(|e| format!("Failed to create attachments table: {}", e))?;
973
974        // Backfill: parse each event's attachments tag and insert one row per attachment.
975        let events: Vec<(String, String)> = {
976            let mut stmt = tx.prepare("SELECT id, tags FROM events WHERE tags LIKE '%attachments%'")
977                .map_err(|e| format!("prepare attachment backfill: {}", e))?;
978            let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
979                .map_err(|e| format!("query attachment backfill: {}", e))?;
980            mapped.filter_map(|r| r.ok()).collect()
981        };
982        for (event_id, tags_json) in events {
983            let tags: Vec<Vec<String>> = match serde_json::from_str(&tags_json) {
984                Ok(t) => t,
985                Err(_) => continue,
986            };
987            let att_json = tags.iter()
988                .find(|t| t.first().map(|s| s.as_str()) == Some("attachments"))
989                .and_then(|t| t.get(1));
990            let Some(att_json) = att_json else { continue };
991            let atts: Vec<crate::types::Attachment> = match serde_json::from_str(att_json) {
992                Ok(a) => a,
993                Err(_) => continue,
994            };
995            for (i, a) in atts.iter().enumerate() {
996                let img_meta_json = a.img_meta.as_ref().and_then(|m| serde_json::to_string(m).ok());
997                tx.execute(
998                    "INSERT INTO attachments (event_id, att_index, hash, key, nonce, extension, name, url, \
999                     path, size, img_meta, downloaded, webxdc_topic, group_id, original_hash) \
1000                     VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
1001                    rusqlite::params![
1002                        event_id, i as i64, a.id, a.key, a.nonce, a.extension, a.name, a.url,
1003                        a.path, a.size as i64, img_meta_json, a.downloaded as i64,
1004                        a.webxdc_topic, a.group_id, a.original_hash,
1005                    ],
1006                ).map_err(|e| format!("insert backfilled attachment: {}", e))?;
1007            }
1008        }
1009        Ok(())
1010    })?;
1011
1012    // =========================================================================
1013    // Migration 75: Strip the vestigial `attachments` tag from backfilled events
1014    // =========================================================================
1015    // Migration 74 copied the attachments into the table but left the source tag
1016    // in place as a fallback. Now reclaim that dead JSON from every event whose
1017    // attachments are PROVABLY in the table — 74's backfill is all-or-nothing per
1018    // event, so a matching table row means the whole vec was copied. A tag that 74
1019    // could not parse (no row) keeps its raw bytes, so this is lossless. Smaller
1020    // event rows also mean the message-load queries (which read `tags`) touch fewer
1021    // bytes. The read fallback stays for any un-backfilled remnants.
1022    run_atomic_migration(conn, 75, "Strip backfilled attachment tags", |tx| {
1023        let events: Vec<(String, String)> = {
1024            let mut stmt = tx.prepare(
1025                "SELECT id, tags FROM events WHERE tags LIKE '%attachments%' \
1026                 AND id IN (SELECT DISTINCT event_id FROM attachments)"
1027            ).map_err(|e| format!("prepare tag strip: {}", e))?;
1028            let mapped = stmt.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))
1029                .map_err(|e| format!("query tag strip: {}", e))?;
1030            mapped.flatten().collect()
1031        };
1032        for (id, tags_json) in events {
1033            let Ok(mut tags) = serde_json::from_str::<Vec<Vec<String>>>(&tags_json) else { continue };
1034            let before = tags.len();
1035            tags.retain(|t| t.first().map(|s| s.as_str()) != Some("attachments"));
1036            if tags.len() == before {
1037                continue; // false-positive LIKE match; no actual attachments tag
1038            }
1039            let new_tags = serde_json::to_string(&tags).unwrap_or(tags_json);
1040            tx.execute("UPDATE events SET tags=?1 WHERE id=?2", rusqlite::params![new_tags, id])
1041                .map_err(|e| format!("strip attachments tag: {}", e))?;
1042        }
1043        Ok(())
1044    })?;
1045
1046    // Drop the two attachment columns that never carried production data: `mls_filename`
1047    // (a vestige of the removed MLS feature) and `scheme_version` (unused MIP-04 plumbing).
1048    // Neither is read anywhere; both were always NULL.
1049    run_atomic_migration(conn, 76, "Drop dead attachment columns (mls_filename, scheme_version)", |tx| {
1050        tx.execute("ALTER TABLE attachments DROP COLUMN scheme_version", [])
1051            .map_err(|e| format!("drop scheme_version: {}", e))?;
1052        tx.execute("ALTER TABLE attachments DROP COLUMN mls_filename", [])
1053            .map_err(|e| format!("drop mls_filename: {}", e))?;
1054        Ok(())
1055    })?;
1056
1057    // v1→v2 community migration (task #10). `migrated_to` is the terminal per-community
1058    // fence: set inside the flip transaction, checked by every v1 write path. `migration_pointer`
1059    // persists the extracted dissolution payload (signpost + sealed key material) so the flip
1060    // survives restarts; `migration_checked` stops the boot sweep re-probing a community whose
1061    // tombstone turned out to be a plain payload-less dissolution. `community_migrations` is the
1062    // owner wizard's resumable ledger — `twin` carries the created channel key material because
1063    // the pre-flip v2 twin has zero channel rows locally (the hijack guard skips v1-owned rows).
1064    run_atomic_migration(conn, 77, "v1->v2 migration: pointer columns + wizard ledger", |tx| {
1065        tx.execute_batch(
1066            "ALTER TABLE communities ADD COLUMN migrated_to TEXT;
1067             ALTER TABLE communities ADD COLUMN migration_pointer TEXT;
1068             ALTER TABLE communities ADD COLUMN migration_checked INTEGER NOT NULL DEFAULT 0;
1069             CREATE TABLE IF NOT EXISTS community_migrations (
1070                 community_id    TEXT PRIMARY KEY,
1071                 v2_community_id TEXT NOT NULL,
1072                 phase           INTEGER NOT NULL DEFAULT 0,
1073                 twin            TEXT NOT NULL DEFAULT '',
1074                 updated_at      INTEGER NOT NULL DEFAULT 0
1075             );",
1076        )
1077        .map_err(|e| format!("migration 77: {}", e))?;
1078        Ok(())
1079    })?;
1080
1081    // Direct Invites carry a NIP-40 24h expiry. Relay support for NIP-40 is optional, so the
1082    // recipient enforces it locally too: the sender's declared expiry is persisted per parked
1083    // invite and filtered on read. 0 = no expiry declared (a pre-expiry sender), which stays
1084    // permanent — an invite whose sender never promised a deadline isn't ours to revoke.
1085    run_atomic_migration(conn, 78, "Pending invite expiry (NIP-40)", |tx| {
1086        tx.execute(
1087            "ALTER TABLE pending_community_invites ADD COLUMN expires_at INTEGER NOT NULL DEFAULT 0",
1088            [],
1089        )
1090        .map_err(|e| format!("migration 78: {}", e))?;
1091        Ok(())
1092    })?;
1093
1094    // =========================================================================
1095    // Migration 79: Per-npub ban history for phantom-member suppression
1096    // =========================================================================
1097    // CORD-02 §5 counts observation FORWARD of a member's latest Leave, Kick OR
1098    // BAN. The banlist alone is a timeless set, so lifting it let a pre-ban Join
1099    // (or old message) resurrect the npub as a member of a community they hold no
1100    // key to. Armada folds this from live control history; Vector caches the
1101    // banlist, so the per-npub mark has to persist alongside it.
1102    run_atomic_migration(conn, 79, "Per-npub ban marks (phantom-member suppression)", |tx| {
1103        // Presence-checked, not blind: a build that briefly carried this column in the
1104        // CREATE TABLE too would leave a DB holding the column with the migration rolled
1105        // back, and a bare ALTER then fails on every boot forever with no way out.
1106        let present: i32 = tx
1107            .query_row("SELECT COUNT(*) FROM pragma_table_info('communities') WHERE name='banlist_marks'", [], |r| r.get(0))
1108            .map_err(|e| format!("migration 79: {}", e))?;
1109        if present == 0 {
1110            tx.execute(
1111                "ALTER TABLE communities ADD COLUMN banlist_marks TEXT NOT NULL DEFAULT '{}'",
1112                [],
1113            )
1114            .map_err(|e| format!("migration 79: {}", e))?;
1115        }
1116        Ok(())
1117    })?;
1118
1119    // =========================================================================
1120    // Migration 80: Attachment mirror URLs (BUD-04 fallbacks)
1121    // =========================================================================
1122    // NIP-17 / imeta `fallback` sources: the same ciphertext mirrored on other
1123    // Blossom servers, tried in order when the primary URL dies. Space-joined
1124    // (URLs cannot contain spaces); empty = no mirrors.
1125    run_atomic_migration(conn, 80, "Attachment fallback URLs (Blossom mirrors)", |tx| {
1126        tx.execute(
1127            "ALTER TABLE attachments ADD COLUMN fallback_urls TEXT NOT NULL DEFAULT ''",
1128            [],
1129        )
1130        .map_err(|e| format!("migration 80: {}", e))?;
1131        Ok(())
1132    })?;
1133
1134    // =========================================================================
1135    // Migration 81: window index for the dedup-cache preload
1136    // =========================================================================
1137    // The preload reads processed_wrappers bounded by the reconcile cursors;
1138    // without this index the bounded query still scans the full ledger.
1139    run_atomic_migration(conn, 81, "processed_wrappers window index", |tx| {
1140        tx.execute(
1141            "CREATE INDEX IF NOT EXISTS idx_processed_wrappers_window \
1142             ON processed_wrappers(transport, wrapper_created_at)",
1143            [],
1144        )
1145        .map_err(|e| format!("migration 81: {}", e))?;
1146        Ok(())
1147    })?;
1148
1149    // =========================================================================
1150    // Migration 82: parked Private-Channel key vends (CORD-03/05 §6)
1151    // =========================================================================
1152    // A grant's key vend can arrive before the control fold that proves the
1153    // grant, so it parks here and is re-judged after every control follow.
1154    // Durable rather than in-RAM by necessity: the vend rides a 24h NIP-40 wrap
1155    // that relays delete, so a restart before the fold catches up would lose the
1156    // only copy. One row per (community, channel) — the newest epoch wins, and a
1157    // vend at or below the held epoch is superseded.
1158    run_atomic_migration(conn, 82, "Parked private-channel key vends", |tx| {
1159        tx.execute(
1160            "CREATE TABLE IF NOT EXISTS pending_channel_keys (
1161                community_id TEXT NOT NULL,
1162                channel_id   TEXT NOT NULL,
1163                epoch        INTEGER NOT NULL,
1164                channel_key  BLOB NOT NULL,
1165                sender       TEXT NOT NULL,
1166                received_at  INTEGER NOT NULL,
1167                PRIMARY KEY (community_id, channel_id)
1168            )",
1169            [],
1170        )
1171        .map_err(|e| format!("migration 82: {}", e))?;
1172        Ok(())
1173    })?;
1174
1175    // =========================================================================
1176    // Migration 83: parked vends become CANDIDATES, not a single slot
1177    // =========================================================================
1178    // 82 keyed the table on (community, channel) and only replaced on a higher
1179    // epoch. Parking is reachable by any npub that can gift-wrap us (the bundle
1180    // self-certifies, and its inputs are public for a public community), so a
1181    // stranger could pre-park a high-epoch row and make the genuine vend a silent
1182    // no-op — the member simply stays keyless with no retry.
1183    //
1184    // Now every vend is its own row and the judge tries them all, so an
1185    // unprovable row can never displace a provable one. Caps bound what an
1186    // arbitrary sender can make us store (and decrypt on every follow pass).
1187    run_atomic_migration(conn, 83, "Parked channel-key vends as candidates", |tx| {
1188        tx.execute("DROP TABLE IF EXISTS pending_channel_keys", [])
1189            .map_err(|e| format!("migration 83: {}", e))?;
1190        tx.execute(
1191            "CREATE TABLE pending_channel_keys (
1192                id           INTEGER PRIMARY KEY AUTOINCREMENT,
1193                community_id TEXT NOT NULL,
1194                channel_id   TEXT NOT NULL,
1195                epoch        INTEGER NOT NULL,
1196                channel_key  BLOB NOT NULL,
1197                sender       TEXT NOT NULL,
1198                received_at  INTEGER NOT NULL
1199            )",
1200            [],
1201        )
1202        .map_err(|e| format!("migration 83: {}", e))?;
1203        tx.execute(
1204            "CREATE INDEX IF NOT EXISTS idx_pending_channel_keys_scope \
1205             ON pending_channel_keys(community_id, channel_id)",
1206            [],
1207        )
1208        .map_err(|e| format!("migration 83: {}", e))?;
1209        Ok(())
1210    })?;
1211
1212    // CORD-04 §7: one Pin List per channel — the folded head's RAW content
1213    // (both self-describing forms), never a re-serialization, so republishing
1214    // carries the exact bytes and the byte cap judges what the wire carried.
1215    run_atomic_migration(conn, 84, "Per-channel pin lists", |tx| {
1216        tx.execute(
1217            "CREATE TABLE IF NOT EXISTS community_pins (
1218                community_id TEXT NOT NULL,
1219                channel_id   TEXT NOT NULL,
1220                content      TEXT NOT NULL,
1221                version      INTEGER NOT NULL,
1222                PRIMARY KEY (community_id, channel_id)
1223            )",
1224            [],
1225        )
1226        .map_err(|e| format!("migration 84: {}", e))?;
1227        Ok(())
1228    })?;
1229
1230    Ok(())
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235    use super::HIGHEST_MIGRATION_ID;
1236
1237    /// Parses this very file so the constant cannot drift from `run_migrations`.
1238    /// Without it, adding a migration and forgetting the bump would silently
1239    /// re-open the downgrade hole the constant exists to close.
1240    #[test]
1241    fn highest_migration_id_matches_the_runner() {
1242        let src = include_str!("schema.rs");
1243        let mut highest = 0u32;
1244        let mut seen = 0usize;
1245
1246        for (at, _) in src.match_indices("run_atomic_migration(") {
1247            let tail = src[at + "run_atomic_migration(".len()..].trim_start();
1248            // Skips this test's own mention of the name, which is not a call.
1249            let Some(args) = tail.strip_prefix("conn,") else {
1250                continue;
1251            };
1252            let id: String = args
1253                .trim_start()
1254                .chars()
1255                .take_while(char::is_ascii_digit)
1256                .collect();
1257            if let Ok(id) = id.parse::<u32>() {
1258                seen += 1;
1259                highest = highest.max(id);
1260            }
1261        }
1262
1263        assert!(seen > 0, "parsed no migrations; the call shape must have changed");
1264        assert_eq!(
1265            HIGHEST_MIGRATION_ID, highest,
1266            "bump HIGHEST_MIGRATION_ID to {highest} when adding a migration"
1267        );
1268    }
1269}