1pub const SQL_SCHEMA: &str = r#"
4-- Profiles table (plaintext - public data)
5CREATE TABLE IF NOT EXISTS profiles (
6 id INTEGER PRIMARY KEY AUTOINCREMENT,
7 npub TEXT UNIQUE NOT NULL,
8 name TEXT NOT NULL DEFAULT '',
9 display_name TEXT NOT NULL DEFAULT '',
10 nickname TEXT NOT NULL DEFAULT '',
11 lud06 TEXT NOT NULL DEFAULT '',
12 lud16 TEXT NOT NULL DEFAULT '',
13 banner TEXT NOT NULL DEFAULT '',
14 avatar TEXT NOT NULL DEFAULT '',
15 about TEXT NOT NULL DEFAULT '',
16 website TEXT NOT NULL DEFAULT '',
17 nip05 TEXT NOT NULL DEFAULT '',
18 status_content TEXT NOT NULL DEFAULT '',
19 status_url TEXT NOT NULL DEFAULT '',
20 muted INTEGER NOT NULL DEFAULT 0,
21 bot INTEGER NOT NULL DEFAULT 0,
22 avatar_cached TEXT NOT NULL DEFAULT '',
23 banner_cached TEXT NOT NULL DEFAULT ''
24);
25CREATE INDEX IF NOT EXISTS idx_profiles_npub ON profiles(npub);
26CREATE INDEX IF NOT EXISTS idx_profiles_name ON profiles(name);
27
28-- Chats table (plaintext - metadata)
29CREATE TABLE IF NOT EXISTS chats (
30 id INTEGER PRIMARY KEY AUTOINCREMENT,
31 chat_identifier TEXT UNIQUE NOT NULL,
32 chat_type INTEGER NOT NULL,
33 participants TEXT NOT NULL,
34 last_read TEXT NOT NULL DEFAULT '',
35 created_at INTEGER NOT NULL,
36 metadata TEXT NOT NULL DEFAULT '{}',
37 muted INTEGER NOT NULL DEFAULT 0
38);
39CREATE INDEX IF NOT EXISTS idx_chats_identifier ON chats(chat_identifier);
40CREATE INDEX IF NOT EXISTS idx_chats_created ON chats(created_at DESC);
41
42-- Messages table (content encrypted, metadata plaintext)
43CREATE TABLE IF NOT EXISTS messages (
44 id TEXT PRIMARY KEY,
45 chat_id INTEGER NOT NULL,
46 content_encrypted TEXT NOT NULL,
47 replied_to TEXT NOT NULL DEFAULT '',
48 preview_metadata TEXT,
49 attachments TEXT NOT NULL DEFAULT '[]',
50 reactions TEXT NOT NULL DEFAULT '[]',
51 at INTEGER NOT NULL,
52 mine INTEGER NOT NULL,
53 user_id INTEGER,
54 wrapper_event_id TEXT,
55 FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
56 FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE SET NULL
57);
58CREATE INDEX IF NOT EXISTS idx_messages_chat ON messages(chat_id, at);
59CREATE INDEX IF NOT EXISTS idx_messages_time ON messages(at DESC);
60CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id);
61CREATE INDEX IF NOT EXISTS idx_messages_wrapper ON messages(wrapper_event_id);
62
63-- Settings table (key-value pairs)
64CREATE TABLE IF NOT EXISTS settings (
65 key TEXT PRIMARY KEY,
66 value TEXT NOT NULL
67);
68
69-- Events table: flat, protocol-aligned storage for all Nostr events
70CREATE TABLE IF NOT EXISTS events (
71 id TEXT PRIMARY KEY,
72 kind INTEGER NOT NULL,
73 chat_id INTEGER NOT NULL,
74 user_id INTEGER,
75 content TEXT NOT NULL,
76 tags TEXT NOT NULL DEFAULT '[]',
77 reference_id TEXT,
78 created_at INTEGER NOT NULL,
79 received_at INTEGER NOT NULL,
80 mine INTEGER NOT NULL DEFAULT 0,
81 pending INTEGER NOT NULL DEFAULT 0,
82 failed INTEGER NOT NULL DEFAULT 0,
83 wrapper_event_id TEXT,
84 npub TEXT,
85 preview_metadata TEXT,
86 FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE,
87 FOREIGN KEY (user_id) REFERENCES profiles(id) ON DELETE SET NULL
88);
89CREATE INDEX IF NOT EXISTS idx_events_chat_time ON events(chat_id, created_at DESC);
90CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
91CREATE INDEX IF NOT EXISTS idx_events_reference ON events(reference_id) WHERE reference_id IS NOT NULL;
92CREATE INDEX IF NOT EXISTS idx_events_wrapper ON events(wrapper_event_id) WHERE wrapper_event_id IS NOT NULL;
93CREATE INDEX IF NOT EXISTS idx_events_user ON events(user_id);
94
95-- PIVX Promos table
96CREATE TABLE IF NOT EXISTS pivx_promos (
97 id INTEGER PRIMARY KEY AUTOINCREMENT,
98 gift_code TEXT NOT NULL UNIQUE,
99 address TEXT NOT NULL,
100 privkey_encrypted TEXT NOT NULL,
101 created_at INTEGER NOT NULL,
102 claimed_at INTEGER,
103 amount_piv REAL,
104 status TEXT NOT NULL DEFAULT 'active'
105);
106CREATE INDEX IF NOT EXISTS idx_pivx_promos_code ON pivx_promos(gift_code);
107CREATE INDEX IF NOT EXISTS idx_pivx_promos_address ON pivx_promos(address);
108CREATE INDEX IF NOT EXISTS idx_pivx_promos_status ON pivx_promos(status);
109
110-- Mini Apps history table
111CREATE TABLE IF NOT EXISTS miniapps_history (
112 id INTEGER PRIMARY KEY AUTOINCREMENT,
113 name TEXT NOT NULL UNIQUE,
114 src_url TEXT NOT NULL,
115 attachment_ref TEXT,
116 open_count INTEGER DEFAULT 1,
117 last_opened_at INTEGER NOT NULL,
118 is_favorite INTEGER NOT NULL DEFAULT 0,
119 categories TEXT NOT NULL DEFAULT '',
120 marketplace_id TEXT DEFAULT NULL,
121 installed_version TEXT DEFAULT NULL
122);
123
124-- Mini App permissions table
125CREATE TABLE IF NOT EXISTS miniapp_permissions (
126 id INTEGER PRIMARY KEY AUTOINCREMENT,
127 file_hash TEXT NOT NULL,
128 permission TEXT NOT NULL,
129 granted INTEGER NOT NULL DEFAULT 0,
130 granted_at INTEGER,
131 UNIQUE(file_hash, permission)
132);
133CREATE INDEX IF NOT EXISTS idx_miniapp_permissions_hash ON miniapp_permissions(file_hash);
134
135-- Processed wrappers table (NIP-59 gift wrap dedup + NIP-77 negentropy)
136-- Universal outer-event ledger across transports. The `transport` discriminator
137-- (0 = nip17 gift-wrap, 1 = concord channel envelope, …) is added by migration 42 so the
138-- dedup is shared but NIP-77 negentropy only fingerprints the nip17 (0) subset.
139CREATE TABLE IF NOT EXISTS processed_wrappers (
140 wrapper_id BLOB PRIMARY KEY,
141 wrapper_created_at INTEGER NOT NULL DEFAULT 0
142);
143
144-- The nip17_wrap_keys vault is introduced by migration 21. The legacy MLS
145-- tables (mls_wrap_keys / mls_pending_events from migrations 22/23) are dropped
146-- by migration 41, so on a fresh DB they're created in order and then removed.
147
148-- Schema migrations tracking table
149CREATE TABLE IF NOT EXISTS schema_migrations (
150 id INTEGER PRIMARY KEY,
151 applied_at INTEGER NOT NULL
152);
153"#;
154
155pub fn migration_applied(conn: &rusqlite::Connection, migration_id: u32) -> bool {
157 conn.query_row(
158 "SELECT 1 FROM schema_migrations WHERE id = ?1",
159 rusqlite::params![migration_id],
160 |_| Ok(())
161 ).is_ok()
162}
163
164pub fn mark_migration_applied(tx: &rusqlite::Transaction, migration_id: u32) -> Result<(), String> {
166 let now = std::time::SystemTime::now()
167 .duration_since(std::time::UNIX_EPOCH)
168 .unwrap()
169 .as_secs() as i64;
170
171 tx.execute(
172 "INSERT INTO schema_migrations (id, applied_at) VALUES (?1, ?2)",
173 rusqlite::params![migration_id, now],
174 ).map_err(|e| format!("[DB] Migration {}: Failed to record: {}", migration_id, e))?;
175
176 Ok(())
177}
178
179fn run_atomic_migration<F>(
188 conn: &mut rusqlite::Connection,
189 id: u32,
190 name: &str,
191 migrate: F,
192) -> Result<(), String>
193where
194 F: FnOnce(&rusqlite::Transaction) -> Result<(), String>,
195{
196 if migration_applied(conn, id) {
198 return Ok(());
199 }
200
201 println!("[DB] Migration {}: {}...", id, name);
202
203 let tx = conn.transaction()
205 .map_err(|e| format!("[DB] Migration {}: Failed to start transaction: {}", id, e))?;
206
207 match migrate(&tx) {
209 Ok(()) => {
210 mark_migration_applied(&tx, id)?;
212
213 tx.commit()
215 .map_err(|e| format!("[DB] Migration {}: Failed to commit: {}", id, e))?;
216
217 println!("[DB] Migration {} complete", id);
218 Ok(())
219 }
220 Err(e) => {
221 eprintln!("[DB] Migration {} FAILED: {} - rolling back", id, e);
223 Err(e)
224 }
225 }
226}
227
228#[allow(dead_code)]
232fn ensure_column_exists(
233 conn: &mut rusqlite::Connection,
234 table: &str,
235 column: &str,
236 col_type: &str,
237) -> Result<(), String> {
238 let exists: bool = conn.query_row(
239 &format!("SELECT COUNT(*) FROM pragma_table_info('{}') WHERE name='{}'", table, column),
240 [],
241 |row| row.get::<_, i32>(0),
242 ).map(|c| c > 0).unwrap_or(false);
243
244 if !exists {
245 println!("[DB] Safety net: adding missing column {}.{}", table, column);
246 conn.execute(
247 &format!("ALTER TABLE {} ADD COLUMN {} {}", table, column, col_type),
248 [],
249 ).map_err(|e| format!("[DB] Failed to add column {}.{}: {}", table, column, e))?;
250 }
251 Ok(())
252}
253
254pub fn run_migrations(conn: &mut rusqlite::Connection) -> Result<(), String> {
262 conn.execute(
264 "CREATE TABLE IF NOT EXISTS schema_migrations (
265 id INTEGER PRIMARY KEY,
266 applied_at INTEGER NOT NULL
267 )",
268 [],
269 ).map_err(|e| format!("[DB] Failed to create schema_migrations table: {}", e))?;
270
271 run_atomic_migration(conn, 19, "Create marketplace_cache table", |tx| {
279 tx.execute_batch(
280 "CREATE TABLE IF NOT EXISTS marketplace_cache (
281 id TEXT PRIMARY KEY,
282 data TEXT NOT NULL,
283 fetched_at INTEGER NOT NULL
284 );"
285 ).map_err(|e| format!("Failed to create marketplace_cache table: {}", e))?;
286 Ok(())
287 })?;
288
289 run_atomic_migration(conn, 20, "Add is_blocked column to profiles", |tx| {
295 tx.execute_batch(
296 "ALTER TABLE profiles ADD COLUMN is_blocked INTEGER NOT NULL DEFAULT 0;"
297 ).map_err(|e| format!("Failed to add is_blocked column: {}", e))?;
298 Ok(())
299 })?;
300
301 run_atomic_migration(conn, 21, "Create nip17_wrap_keys table", |tx| {
312 tx.execute_batch(
313 "CREATE TABLE IF NOT EXISTS nip17_wrap_keys (
314 wrap_event_id TEXT PRIMARY KEY,
315 rumor_id TEXT NOT NULL,
316 recipient_pubkey TEXT NOT NULL,
317 role INTEGER NOT NULL,
318 secret BLOB NOT NULL,
319 relay_urls TEXT NOT NULL,
320 created_at INTEGER NOT NULL
321 );
322 CREATE INDEX IF NOT EXISTS idx_nip17_wrap_keys_rumor ON nip17_wrap_keys(rumor_id);"
323 ).map_err(|e| format!("Failed to create nip17_wrap_keys table: {}", e))?;
324 Ok(())
325 })?;
326
327 run_atomic_migration(conn, 22, "Create mls_wrap_keys table", |tx| {
336 tx.execute_batch(
337 "CREATE TABLE IF NOT EXISTS mls_wrap_keys (
338 wrap_event_id TEXT PRIMARY KEY,
339 message_id TEXT NOT NULL,
340 group_id TEXT NOT NULL,
341 secret BLOB NOT NULL,
342 relay_urls TEXT NOT NULL,
343 created_at INTEGER NOT NULL
344 );
345 CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_message ON mls_wrap_keys(message_id);
346 CREATE INDEX IF NOT EXISTS idx_mls_wrap_keys_group ON mls_wrap_keys(group_id);"
347 ).map_err(|e| format!("Failed to create mls_wrap_keys table: {}", e))?;
348 Ok(())
349 })?;
350
351 run_atomic_migration(conn, 23, "Create mls_pending_events table", |tx| {
360 tx.execute_batch(
361 "CREATE TABLE IF NOT EXISTS mls_pending_events (
362 event_id TEXT PRIMARY KEY,
363 group_id TEXT NOT NULL,
364 event_json TEXT NOT NULL,
365 first_seen_at INTEGER NOT NULL,
366 last_retry_at INTEGER NOT NULL,
367 retry_count INTEGER NOT NULL DEFAULT 0
368 );
369 CREATE INDEX IF NOT EXISTS idx_mls_pending_events_group ON mls_pending_events(group_id);
370 CREATE INDEX IF NOT EXISTS idx_mls_pending_events_first_seen ON mls_pending_events(first_seen_at);"
371 ).map_err(|e| format!("Failed to create mls_pending_events table: {}", e))?;
372 Ok(())
373 })?;
374
375 run_atomic_migration(conn, 24, "Create blossom_server_capabilities table", |tx| {
379 tx.execute_batch(
380 "CREATE TABLE IF NOT EXISTS blossom_server_capabilities (
381 server_url TEXT NOT NULL,
382 mime_type TEXT NOT NULL,
383 outcome INTEGER NOT NULL,
384 max_accepted_size INTEGER NOT NULL DEFAULT 0,
385 updated_at INTEGER NOT NULL,
386 PRIMARY KEY (server_url, mime_type)
387 );"
388 ).map_err(|e| format!("Failed to create blossom_server_capabilities table: {}", e))?;
389 Ok(())
390 })?;
391
392 run_atomic_migration(conn, 25, "Add min_rejected_size to blossom_server_capabilities", |tx| {
396 tx.execute_batch(
397 "ALTER TABLE blossom_server_capabilities ADD COLUMN min_rejected_size INTEGER;"
398 ).map_err(|e| format!("Failed to add min_rejected_size column: {}", e))?;
399 Ok(())
400 })?;
401
402 run_atomic_migration(conn, 26, "Add is_encrypted to capability cache PK", |tx| {
408 tx.execute_batch(
409 "DROP TABLE IF EXISTS blossom_server_capabilities;
410 CREATE TABLE blossom_server_capabilities (
411 server_url TEXT NOT NULL,
412 mime_type TEXT NOT NULL,
413 is_encrypted INTEGER NOT NULL DEFAULT 0,
414 outcome INTEGER NOT NULL,
415 max_accepted_size INTEGER NOT NULL DEFAULT 0,
416 min_rejected_size INTEGER,
417 updated_at INTEGER NOT NULL,
418 PRIMARY KEY (server_url, mime_type, is_encrypted)
419 );"
420 ).map_err(|e| format!("Failed to recreate blossom_server_capabilities: {}", e))?;
421 Ok(())
422 })?;
423
424 run_atomic_migration(conn, 27, "Backfill signer_type=local for pre-NIP-46 accounts", |tx| {
435 tx.execute(
436 "INSERT OR IGNORE INTO settings (key, value) VALUES ('signer_type', 'local')",
437 [],
438 ).map_err(|e| format!("Failed to backfill signer_type: {}", e))?;
439 Ok(())
440 })?;
441
442 run_atomic_migration(conn, 28, "Create emoji pack tables", |tx| {
450 tx.execute_batch(
451 "CREATE TABLE IF NOT EXISTS emoji_packs (
452 addr TEXT PRIMARY KEY,
453 pubkey TEXT NOT NULL,
454 identifier TEXT NOT NULL,
455 title TEXT NOT NULL DEFAULT '',
456 image_url TEXT NOT NULL DEFAULT '',
457 description TEXT NOT NULL DEFAULT '',
458 is_own INTEGER NOT NULL DEFAULT 0,
459 updated_at INTEGER NOT NULL,
460 raw_event TEXT NOT NULL DEFAULT ''
461 );
462 CREATE INDEX IF NOT EXISTS idx_emoji_packs_pubkey ON emoji_packs(pubkey);
463 CREATE INDEX IF NOT EXISTS idx_emoji_packs_is_own ON emoji_packs(is_own);
464
465 CREATE TABLE IF NOT EXISTS emoji_pack_items (
466 pack_addr TEXT NOT NULL,
467 shortcode TEXT NOT NULL,
468 url TEXT NOT NULL,
469 sha256 TEXT,
470 position INTEGER NOT NULL DEFAULT 0,
471 PRIMARY KEY (pack_addr, shortcode),
472 FOREIGN KEY (pack_addr) REFERENCES emoji_packs(addr) ON DELETE CASCADE
473 );
474 CREATE INDEX IF NOT EXISTS idx_emoji_pack_items_pack ON emoji_pack_items(pack_addr, position);
475
476 CREATE TABLE IF NOT EXISTS emoji_pack_subscriptions (
477 addr TEXT PRIMARY KEY,
478 subscribed_at INTEGER NOT NULL
479 );"
480 ).map_err(|e| format!("Failed to create emoji pack tables: {}", e))?;
481 Ok(())
482 })?;
483
484 run_atomic_migration(conn, 29, "Add wallpaper columns to chats", |tx| {
492 tx.execute_batch(
493 "ALTER TABLE chats ADD COLUMN wallpaper_path TEXT NOT NULL DEFAULT '';
494 ALTER TABLE chats ADD COLUMN wallpaper_ts INTEGER NOT NULL DEFAULT 0;"
495 ).map_err(|e| format!("Failed to add wallpaper columns: {}", e))?;
496 Ok(())
497 })?;
498
499 run_atomic_migration(conn, 30, "Add wallpaper blur/dim columns to chats", |tx| {
507 tx.execute_batch(
508 "ALTER TABLE chats ADD COLUMN wallpaper_blur INTEGER NOT NULL DEFAULT 0;
509 ALTER TABLE chats ADD COLUMN wallpaper_dim INTEGER NOT NULL DEFAULT 50;"
510 ).map_err(|e| format!("Failed to add wallpaper blur/dim columns: {}", e))?;
511 Ok(())
512 })?;
513
514 run_atomic_migration(conn, 31, "Add wallpaper url/uploader columns to chats", |tx| {
523 tx.execute_batch(
524 "ALTER TABLE chats ADD COLUMN wallpaper_url TEXT NOT NULL DEFAULT '';
525 ALTER TABLE chats ADD COLUMN wallpaper_uploader TEXT NOT NULL DEFAULT '';"
526 ).map_err(|e| format!("Failed to add wallpaper url/uploader columns: {}", e))?;
527 Ok(())
528 })?;
529
530 run_atomic_migration(conn, 32, "Drop mls_event_cursors table", |tx| {
538 tx.execute_batch("DROP TABLE IF EXISTS mls_event_cursors;")
539 .map_err(|e| format!("Failed to drop mls_event_cursors: {}", e))?;
540 Ok(())
541 })?;
542
543 run_atomic_migration(conn, 40, "Create community tables", |tx| {
560 tx.execute_batch(
561 "CREATE TABLE IF NOT EXISTS communities (
562 community_id TEXT PRIMARY KEY,
563 server_root_key BLOB NOT NULL,
564 name TEXT NOT NULL,
565 relays TEXT NOT NULL,
566 created_at INTEGER NOT NULL,
567 description TEXT,
568 icon TEXT,
569 banner TEXT,
570 banlist TEXT NOT NULL DEFAULT '[]',
571 banlist_at INTEGER NOT NULL DEFAULT 0,
572 owner_attestation TEXT,
573 roles TEXT NOT NULL DEFAULT '{}',
574 roles_at INTEGER NOT NULL DEFAULT 0,
575 server_root_epoch INTEGER NOT NULL DEFAULT 0,
576 invite_registry TEXT NOT NULL DEFAULT '[]',
577 read_cut_pending INTEGER NOT NULL DEFAULT 0,
578 read_cut_target_epoch INTEGER NOT NULL DEFAULT 0,
579 dissolved INTEGER NOT NULL DEFAULT 0
580 );
581 CREATE TABLE IF NOT EXISTS community_channels (
582 channel_id TEXT PRIMARY KEY,
583 community_id TEXT NOT NULL,
584 channel_key BLOB NOT NULL,
585 epoch INTEGER NOT NULL,
586 name TEXT NOT NULL,
587 created_at INTEGER NOT NULL,
588 rekeyed_at_server_epoch INTEGER NOT NULL DEFAULT 0
589 );
590 CREATE INDEX IF NOT EXISTS idx_community_channels_community
591 ON community_channels(community_id);
592 CREATE TABLE IF NOT EXISTS community_message_keys (
593 outer_event_id TEXT PRIMARY KEY,
594 ephemeral_secret BLOB NOT NULL,
595 relays TEXT NOT NULL,
596 created_at INTEGER NOT NULL,
597 message_id TEXT
598 );
599 CREATE INDEX IF NOT EXISTS idx_cmk_message_id
600 ON community_message_keys(message_id);
601 CREATE TABLE IF NOT EXISTS pending_community_invites (
602 community_id TEXT PRIMARY KEY,
603 bundle_json TEXT NOT NULL,
604 inviter_npub TEXT NOT NULL,
605 received_at INTEGER NOT NULL
606 );
607 CREATE TABLE IF NOT EXISTS community_public_invites (
608 token TEXT PRIMARY KEY,
609 community_id TEXT NOT NULL,
610 url TEXT NOT NULL,
611 expires_at INTEGER,
612 created_at INTEGER NOT NULL
613 );
614 CREATE INDEX IF NOT EXISTS idx_public_invites_community
615 ON community_public_invites(community_id);
616 CREATE TABLE IF NOT EXISTS community_edition_heads (
617 community_id TEXT NOT NULL,
618 entity_id TEXT NOT NULL,
619 version INTEGER NOT NULL,
620 self_hash BLOB NOT NULL,
621 inner_id BLOB,
622 epoch INTEGER NOT NULL DEFAULT 0,
623 PRIMARY KEY (community_id, entity_id)
624 );
625 CREATE TABLE IF NOT EXISTS community_epoch_keys (
626 community_id TEXT NOT NULL,
627 scope_id TEXT NOT NULL,
628 epoch INTEGER NOT NULL,
629 key BLOB NOT NULL,
630 created_at INTEGER NOT NULL,
631 PRIMARY KEY (community_id, scope_id, epoch)
632 );
633 CREATE TABLE IF NOT EXISTS community_invite_link_sets (
634 community_id TEXT NOT NULL,
635 creator TEXT NOT NULL,
636 locators TEXT NOT NULL DEFAULT '[]',
637 version INTEGER NOT NULL DEFAULT 0,
638 PRIMARY KEY (community_id, creator)
639 );",
640 )
641 .map_err(|e| format!("Failed to create community tables: {}", e))?;
642 Ok(())
643 })?;
644
645 run_atomic_migration(conn, 41, "Purge legacy MLS data", |tx| {
652 tx.execute_batch(
653 "DELETE FROM events WHERE chat_id IN (SELECT id FROM chats WHERE chat_type = 1);
654 DELETE FROM chats WHERE chat_type = 1;
655 DROP TABLE IF EXISTS mls_groups;
656 DROP TABLE IF EXISTS mls_keypackages;
657 DROP TABLE IF EXISTS mls_processed_events;
658 DROP TABLE IF EXISTS mls_wrap_keys;
659 DROP TABLE IF EXISTS mls_pending_events;",
660 )
661 .map_err(|e| format!("Failed to purge legacy MLS data: {}", e))?;
662 Ok(())
663 })?;
664
665 run_atomic_migration(conn, 42, "Add transport discriminator to processed_wrappers", |tx| {
672 tx.execute_batch("ALTER TABLE processed_wrappers ADD COLUMN transport INTEGER NOT NULL DEFAULT 0;")
673 .map_err(|e| format!("Failed to add transport column: {}", e))?;
674 Ok(())
675 })?;
676
677 run_atomic_migration(conn, 43, "Add label to community_public_invites", |tx| {
684 tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
685 .map_err(|e| format!("Failed to add label column: {}", e))?;
686 Ok(())
687 })?;
688
689 run_atomic_migration(conn, 44, "Create emoji_usage table", |tx| {
697 tx.execute_batch(
698 "CREATE TABLE IF NOT EXISTS emoji_usage (
699 kind INTEGER NOT NULL,
700 id TEXT NOT NULL,
701 url TEXT,
702 score REAL NOT NULL,
703 last_used INTEGER NOT NULL,
704 PRIMARY KEY (kind, id)
705 ) WITHOUT ROWID;
706 CREATE INDEX IF NOT EXISTS idx_emoji_usage_score
707 ON emoji_usage(score DESC);",
708 )
709 .map_err(|e| format!("Failed to create emoji_usage table: {}", e))?;
710 Ok(())
711 })?;
712
713 run_atomic_migration(conn, 62, "Repair: ensure label column on community_public_invites", |tx| {
718 let has_label: i64 = tx
719 .query_row(
720 "SELECT COUNT(*) FROM pragma_table_info('community_public_invites') WHERE name = 'label'",
721 [],
722 |r| r.get(0),
723 )
724 .map_err(|e| format!("Failed to inspect community_public_invites columns: {}", e))?;
725 if has_label == 0 {
726 tx.execute_batch("ALTER TABLE community_public_invites ADD COLUMN label TEXT;")
727 .map_err(|e| format!("Failed to add label column: {}", e))?;
728 }
729 Ok(())
730 })?;
731
732 run_atomic_migration(conn, 63, "Add health columns to emoji_packs", |tx| {
741 tx.execute_batch(
742 "ALTER TABLE emoji_packs ADD COLUMN status INTEGER NOT NULL DEFAULT 0;
743 ALTER TABLE emoji_packs ADD COLUMN miss_count INTEGER NOT NULL DEFAULT 0;
744 ALTER TABLE emoji_packs ADD COLUMN first_missed_at INTEGER NOT NULL DEFAULT 0;
745 ALTER TABLE emoji_packs ADD COLUMN last_miss_counted_at INTEGER NOT NULL DEFAULT 0;
746 ALTER TABLE emoji_packs ADD COLUMN status_changed_at INTEGER NOT NULL DEFAULT 0;",
747 )
748 .map_err(|e| format!("Failed to add emoji pack health columns: {}", e))?;
749 Ok(())
750 })?;
751
752 run_atomic_migration(conn, 64, "Drop orphaned pending-id event rows", |tx| {
761 tx.execute(
762 "DELETE FROM events WHERE id LIKE 'pending-%' AND pending = 0 AND failed = 0",
763 [],
764 )
765 .map_err(|e| format!("Failed to drop orphaned pending rows: {}", e))?;
766 Ok(())
767 })?;
768
769 run_atomic_migration(conn, 65, "Add position to emoji pack subscriptions", |tx| {
778 tx.execute_batch(
779 "ALTER TABLE emoji_pack_subscriptions ADD COLUMN position INTEGER NOT NULL DEFAULT 0;
780 UPDATE emoji_pack_subscriptions SET position = (
781 SELECT COUNT(*) FROM emoji_pack_subscriptions s2
782 WHERE s2.rowid < emoji_pack_subscriptions.rowid
783 );",
784 )
785 .map_err(|e| format!("Failed to add position column: {}", e))?;
786 Ok(())
787 })?;
788
789 run_atomic_migration(conn, 66, "Concord v2 dual-stack columns", |tx| {
798 for (table, col, ddl) in [
799 ("communities", "protocol", "INTEGER NOT NULL DEFAULT 1"),
800 ("communities", "owner_pubkey", "TEXT"),
801 ("communities", "owner_salt", "TEXT"),
802 ("community_channels", "private", "INTEGER NOT NULL DEFAULT 0"),
803 ] {
804 let sql = format!("ALTER TABLE {table} ADD COLUMN {col} {ddl}");
806 if let Err(e) = tx.execute(&sql, []) {
807 let msg = e.to_string();
808 if !msg.contains("duplicate column name") {
809 return Err(format!("add {table}.{col}: {msg}"));
810 }
811 }
812 }
813 Ok(())
814 })?;
815
816 run_atomic_migration(conn, 67, "v2 guestbook store", |tx| {
822 tx.execute(
823 "CREATE TABLE IF NOT EXISTS community_guestbook (
824 community_id TEXT PRIMARY KEY,
825 events TEXT NOT NULL,
826 cursor_secs INTEGER NOT NULL DEFAULT 0
827 )",
828 [],
829 )
830 .map_err(|e| format!("create community_guestbook: {e}"))?;
831 Ok(())
832 })?;
833
834 run_atomic_migration(conn, 68, "v2 metadata preservation stash", |tx| {
838 for (table, col) in [("communities", "meta_extra"), ("community_channels", "meta_extra")] {
839 let sql = format!("ALTER TABLE {table} ADD COLUMN {col} TEXT");
840 if let Err(e) = tx.execute(&sql, []) {
841 let msg = e.to_string();
842 if !msg.contains("duplicate column name") {
843 return Err(format!("add {table}.{col}: {msg}"));
844 }
845 }
846 }
847 Ok(())
848 })?;
849
850 run_atomic_migration(conn, 69, "bot manifest store", |tx| {
855 tx.execute(
856 "CREATE TABLE IF NOT EXISTS bot_manifests (
857 pubkey TEXT PRIMARY KEY,
858 manifest TEXT NOT NULL,
859 event_created_at INTEGER NOT NULL,
860 fetched_at INTEGER NOT NULL
861 )",
862 [],
863 )
864 .map_err(|e| format!("create bot_manifests: {e}"))?;
865 Ok(())
866 })?;
867
868 Ok(())
869}