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