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