@runwingman/flightdeck-cli 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/db.js ADDED
@@ -0,0 +1,653 @@
1
+ import { ensureStateDir, getDbPath } from './config.js';
2
+ import { loadBetterSqlite3 } from './sqlite-runtime.js';
3
+
4
+ export function openDb() {
5
+ ensureStateDir();
6
+ const Database = loadBetterSqlite3();
7
+ const db = new Database(getDbPath());
8
+ db.pragma('journal_mode = WAL');
9
+ migrateGroupCacheTables(db);
10
+ db.exec(`
11
+ CREATE TABLE IF NOT EXISTS app_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
12
+ CREATE TABLE IF NOT EXISTS channels (
13
+ record_id TEXT PRIMARY KEY,
14
+ owner_npub TEXT NOT NULL,
15
+ title TEXT,
16
+ group_ids_json TEXT NOT NULL,
17
+ participant_npubs_json TEXT NOT NULL,
18
+ record_state TEXT,
19
+ version INTEGER NOT NULL,
20
+ updated_at TEXT NOT NULL,
21
+ raw_json TEXT NOT NULL
22
+ );
23
+ CREATE TABLE IF NOT EXISTS messages (
24
+ record_id TEXT PRIMARY KEY,
25
+ owner_npub TEXT NOT NULL,
26
+ channel_id TEXT NOT NULL,
27
+ parent_message_id TEXT,
28
+ body TEXT,
29
+ attachments_json TEXT NOT NULL,
30
+ sender_npub TEXT,
31
+ record_state TEXT,
32
+ version INTEGER NOT NULL,
33
+ updated_at TEXT NOT NULL,
34
+ raw_json TEXT NOT NULL
35
+ );
36
+ CREATE TABLE IF NOT EXISTS tasks (
37
+ record_id TEXT PRIMARY KEY,
38
+ owner_npub TEXT NOT NULL,
39
+ title TEXT,
40
+ description TEXT,
41
+ state TEXT,
42
+ priority TEXT,
43
+ assigned_to_npub TEXT,
44
+ parent_task_id TEXT,
45
+ board_group_id TEXT,
46
+ scheduled_for TEXT,
47
+ tags TEXT,
48
+ scope_id TEXT,
49
+ scope_product_id TEXT,
50
+ scope_project_id TEXT,
51
+ scope_deliverable_id TEXT,
52
+ group_ids_json TEXT NOT NULL,
53
+ shares_json TEXT NOT NULL,
54
+ record_state TEXT,
55
+ version INTEGER NOT NULL,
56
+ updated_at TEXT NOT NULL,
57
+ raw_json TEXT NOT NULL
58
+ );
59
+ CREATE TABLE IF NOT EXISTS comments (
60
+ record_id TEXT PRIMARY KEY,
61
+ owner_npub TEXT NOT NULL,
62
+ target_record_id TEXT NOT NULL,
63
+ target_record_family_hash TEXT,
64
+ parent_comment_id TEXT,
65
+ anchor_line_number INTEGER,
66
+ comment_status TEXT,
67
+ body TEXT,
68
+ attachments_json TEXT NOT NULL,
69
+ sender_npub TEXT,
70
+ record_state TEXT,
71
+ version INTEGER NOT NULL,
72
+ updated_at TEXT NOT NULL,
73
+ raw_json TEXT NOT NULL
74
+ );
75
+ CREATE TABLE IF NOT EXISTS reactions (
76
+ record_id TEXT PRIMARY KEY,
77
+ owner_npub TEXT NOT NULL,
78
+ target_record_id TEXT NOT NULL,
79
+ target_record_family_hash TEXT NOT NULL,
80
+ emoji TEXT NOT NULL,
81
+ emoji_shortcode TEXT NOT NULL,
82
+ reactor_npub TEXT NOT NULL,
83
+ sender_npub TEXT,
84
+ record_state TEXT,
85
+ version INTEGER NOT NULL,
86
+ created_at TEXT,
87
+ updated_at TEXT NOT NULL,
88
+ raw_json TEXT NOT NULL
89
+ );
90
+ CREATE INDEX IF NOT EXISTS idx_reactions_identity
91
+ ON reactions(target_record_family_hash, target_record_id, emoji, reactor_npub);
92
+ CREATE TABLE IF NOT EXISTS documents (
93
+ record_id TEXT PRIMARY KEY,
94
+ owner_npub TEXT NOT NULL,
95
+ title TEXT,
96
+ content TEXT,
97
+ parent_directory_id TEXT,
98
+ scope_id TEXT,
99
+ scope_product_id TEXT,
100
+ scope_project_id TEXT,
101
+ scope_deliverable_id TEXT,
102
+ group_ids_json TEXT NOT NULL,
103
+ shares_json TEXT NOT NULL,
104
+ record_state TEXT,
105
+ version INTEGER NOT NULL,
106
+ updated_at TEXT NOT NULL,
107
+ raw_json TEXT NOT NULL
108
+ );
109
+ CREATE TABLE IF NOT EXISTS directories (
110
+ record_id TEXT PRIMARY KEY,
111
+ owner_npub TEXT NOT NULL,
112
+ title TEXT,
113
+ parent_directory_id TEXT,
114
+ scope_id TEXT,
115
+ scope_product_id TEXT,
116
+ scope_project_id TEXT,
117
+ scope_deliverable_id TEXT,
118
+ group_ids_json TEXT NOT NULL,
119
+ shares_json TEXT NOT NULL,
120
+ record_state TEXT,
121
+ version INTEGER NOT NULL,
122
+ updated_at TEXT NOT NULL,
123
+ raw_json TEXT NOT NULL
124
+ );
125
+ CREATE TABLE IF NOT EXISTS reports (
126
+ record_id TEXT PRIMARY KEY,
127
+ owner_npub TEXT NOT NULL,
128
+ title TEXT,
129
+ declaration_type TEXT,
130
+ surface TEXT,
131
+ generated_at TEXT,
132
+ payload_json TEXT NOT NULL DEFAULT '{}',
133
+ scope_id TEXT,
134
+ scope_level TEXT,
135
+ scope_product_id TEXT,
136
+ scope_project_id TEXT,
137
+ scope_deliverable_id TEXT,
138
+ group_ids_json TEXT NOT NULL DEFAULT '[]',
139
+ record_state TEXT,
140
+ version INTEGER NOT NULL,
141
+ updated_at TEXT NOT NULL,
142
+ raw_json TEXT NOT NULL
143
+ );
144
+ CREATE TABLE IF NOT EXISTS audio_notes (
145
+ record_id TEXT PRIMARY KEY,
146
+ owner_npub TEXT NOT NULL,
147
+ target_record_id TEXT,
148
+ target_record_family_hash TEXT,
149
+ title TEXT,
150
+ storage_object_id TEXT,
151
+ mime_type TEXT,
152
+ duration_seconds REAL,
153
+ size_bytes INTEGER,
154
+ media_encryption_json TEXT,
155
+ waveform_preview_json TEXT,
156
+ transcript_status TEXT,
157
+ transcript_preview TEXT,
158
+ transcript TEXT,
159
+ summary TEXT,
160
+ sender_npub TEXT,
161
+ group_ids_json TEXT NOT NULL,
162
+ record_state TEXT,
163
+ version INTEGER NOT NULL,
164
+ updated_at TEXT NOT NULL,
165
+ raw_json TEXT NOT NULL
166
+ );
167
+ CREATE TABLE IF NOT EXISTS scopes (
168
+ record_id TEXT PRIMARY KEY,
169
+ owner_npub TEXT NOT NULL,
170
+ level TEXT,
171
+ title TEXT,
172
+ description TEXT,
173
+ parent_id TEXT,
174
+ product_id TEXT,
175
+ project_id TEXT,
176
+ l1_id TEXT,
177
+ l2_id TEXT,
178
+ l3_id TEXT,
179
+ l4_id TEXT,
180
+ l5_id TEXT,
181
+ group_ids_json TEXT NOT NULL DEFAULT '[]',
182
+ shares_json TEXT NOT NULL DEFAULT '[]',
183
+ record_state TEXT,
184
+ version INTEGER NOT NULL,
185
+ updated_at TEXT NOT NULL,
186
+ raw_json TEXT NOT NULL
187
+ );
188
+ CREATE TABLE IF NOT EXISTS flows (
189
+ record_id TEXT PRIMARY KEY,
190
+ owner_npub TEXT NOT NULL,
191
+ title TEXT,
192
+ description TEXT,
193
+ steps_json TEXT NOT NULL DEFAULT '[]',
194
+ next_flow_id TEXT,
195
+ scope_id TEXT,
196
+ scope_l1_id TEXT,
197
+ scope_l2_id TEXT,
198
+ scope_l3_id TEXT,
199
+ scope_l4_id TEXT,
200
+ scope_l5_id TEXT,
201
+ group_ids_json TEXT NOT NULL DEFAULT '[]',
202
+ shares_json TEXT NOT NULL DEFAULT '[]',
203
+ record_state TEXT,
204
+ version INTEGER NOT NULL,
205
+ updated_at TEXT NOT NULL,
206
+ raw_json TEXT NOT NULL
207
+ );
208
+ CREATE TABLE IF NOT EXISTS approvals (
209
+ record_id TEXT PRIMARY KEY,
210
+ owner_npub TEXT NOT NULL,
211
+ title TEXT,
212
+ description TEXT,
213
+ flow_id TEXT,
214
+ flow_run_id TEXT,
215
+ flow_step INTEGER,
216
+ task_ids_json TEXT NOT NULL DEFAULT '[]',
217
+ status TEXT,
218
+ approval_mode TEXT,
219
+ brief TEXT,
220
+ confidence_score REAL,
221
+ approved_by TEXT,
222
+ approved_at TEXT,
223
+ decision_note TEXT,
224
+ agent_review_by TEXT,
225
+ agent_review_note TEXT,
226
+ artifact_refs_json TEXT NOT NULL DEFAULT '[]',
227
+ approver_whitelist_json TEXT NOT NULL DEFAULT '[]',
228
+ revision_task_id TEXT,
229
+ scope_id TEXT,
230
+ scope_l1_id TEXT,
231
+ scope_l2_id TEXT,
232
+ scope_l3_id TEXT,
233
+ scope_l4_id TEXT,
234
+ scope_l5_id TEXT,
235
+ group_ids_json TEXT NOT NULL DEFAULT '[]',
236
+ shares_json TEXT NOT NULL DEFAULT '[]',
237
+ record_state TEXT,
238
+ version INTEGER NOT NULL,
239
+ updated_at TEXT NOT NULL,
240
+ raw_json TEXT NOT NULL
241
+ );
242
+ CREATE TABLE IF NOT EXISTS schedules (
243
+ record_id TEXT PRIMARY KEY,
244
+ owner_npub TEXT NOT NULL,
245
+ title TEXT,
246
+ description TEXT,
247
+ time_start TEXT,
248
+ time_end TEXT,
249
+ days_json TEXT NOT NULL,
250
+ timezone TEXT,
251
+ assigned_group_id TEXT,
252
+ active INTEGER,
253
+ last_run TEXT,
254
+ repeat TEXT,
255
+ group_ids_json TEXT NOT NULL,
256
+ shares_json TEXT NOT NULL,
257
+ record_state TEXT,
258
+ version INTEGER NOT NULL,
259
+ updated_at TEXT NOT NULL,
260
+ raw_json TEXT NOT NULL
261
+ );
262
+ `);
263
+ db.exec(`
264
+ CREATE TABLE IF NOT EXISTS workspace_keys (
265
+ workspace_owner_npub TEXT PRIMARY KEY,
266
+ user_npub TEXT NOT NULL,
267
+ ws_key_npub TEXT NOT NULL,
268
+ ws_key_epoch INTEGER NOT NULL DEFAULT 1,
269
+ encrypted_blob TEXT NOT NULL,
270
+ cached_at TEXT NOT NULL
271
+ );
272
+ CREATE TABLE IF NOT EXISTS workspace_key_mappings (
273
+ ws_key_npub TEXT PRIMARY KEY,
274
+ user_npub TEXT NOT NULL,
275
+ cached_at TEXT NOT NULL
276
+ );
277
+ `);
278
+ db.exec(`
279
+ CREATE TABLE IF NOT EXISTS groups_cache (
280
+ group_id TEXT PRIMARY KEY,
281
+ current_group_npub TEXT,
282
+ current_epoch INTEGER NOT NULL DEFAULT 1,
283
+ owner_npub TEXT,
284
+ name TEXT,
285
+ group_kind TEXT,
286
+ private_member_npub TEXT,
287
+ member_npubs_json TEXT NOT NULL,
288
+ raw_json TEXT NOT NULL,
289
+ synced_at TEXT NOT NULL
290
+ );
291
+ CREATE INDEX IF NOT EXISTS idx_groups_cache_current_group_npub
292
+ ON groups_cache(current_group_npub);
293
+ CREATE TABLE IF NOT EXISTS group_keys_cache (
294
+ group_id TEXT NOT NULL,
295
+ key_version INTEGER NOT NULL,
296
+ group_npub TEXT NOT NULL,
297
+ wrapped_group_nsec TEXT NOT NULL,
298
+ wrapped_by_npub TEXT NOT NULL,
299
+ raw_json TEXT NOT NULL,
300
+ synced_at TEXT NOT NULL,
301
+ PRIMARY KEY (group_id, key_version)
302
+ );
303
+ CREATE INDEX IF NOT EXISTS idx_group_keys_cache_group_npub
304
+ ON group_keys_cache(group_npub);
305
+ CREATE TABLE IF NOT EXISTS app_schemas (
306
+ id TEXT PRIMARY KEY,
307
+ workspace_owner_npub TEXT NOT NULL,
308
+ app_npub TEXT NOT NULL,
309
+ app_name TEXT,
310
+ schema_hash TEXT NOT NULL,
311
+ schema_version INTEGER NOT NULL DEFAULT 1,
312
+ record_families_json TEXT NOT NULL DEFAULT '[]',
313
+ schemas_json TEXT NOT NULL DEFAULT '[]',
314
+ raw_json TEXT NOT NULL,
315
+ updated_at TEXT NOT NULL,
316
+ synced_at TEXT NOT NULL
317
+ );
318
+ CREATE INDEX IF NOT EXISTS idx_app_schemas_app
319
+ ON app_schemas(app_npub, schema_hash);
320
+ `);
321
+ ensureColumn(db, 'groups_cache', 'current_group_npub', 'TEXT');
322
+ ensureColumn(db, 'groups_cache', 'current_epoch', 'INTEGER NOT NULL DEFAULT 1');
323
+ ensureColumn(db, 'group_keys_cache', 'group_npub', 'TEXT');
324
+ ensureColumn(db, 'tasks', 'references_json', "TEXT NOT NULL DEFAULT '[]'");
325
+ ensureColumn(db, 'tasks', 'assigned_to_npub', 'TEXT');
326
+ ensureColumn(db, 'tasks', 'scope_id', 'TEXT');
327
+ ensureColumn(db, 'tasks', 'scope_product_id', 'TEXT');
328
+ ensureColumn(db, 'tasks', 'scope_project_id', 'TEXT');
329
+ ensureColumn(db, 'tasks', 'scope_deliverable_id', 'TEXT');
330
+ ensureColumn(db, 'tasks', 'predecessor_task_ids_json', "TEXT NOT NULL DEFAULT '[]'");
331
+ ensureColumn(db, 'tasks', 'flow_id', 'TEXT');
332
+ ensureColumn(db, 'tasks', 'flow_run_id', 'TEXT');
333
+ ensureColumn(db, 'tasks', 'flow_step', 'INTEGER');
334
+ ensureColumn(db, 'documents', 'scope_id', 'TEXT');
335
+ ensureColumn(db, 'documents', 'scope_product_id', 'TEXT');
336
+ ensureColumn(db, 'documents', 'scope_project_id', 'TEXT');
337
+ ensureColumn(db, 'documents', 'scope_deliverable_id', 'TEXT');
338
+ ensureColumn(db, 'directories', 'scope_id', 'TEXT');
339
+ ensureColumn(db, 'directories', 'scope_product_id', 'TEXT');
340
+ ensureColumn(db, 'directories', 'scope_project_id', 'TEXT');
341
+ ensureColumn(db, 'directories', 'scope_deliverable_id', 'TEXT');
342
+ ensureColumn(db, 'reports', 'title', 'TEXT');
343
+ ensureColumn(db, 'reports', 'declaration_type', 'TEXT');
344
+ ensureColumn(db, 'reports', 'surface', 'TEXT');
345
+ ensureColumn(db, 'reports', 'generated_at', 'TEXT');
346
+ ensureColumn(db, 'reports', 'payload_json', "TEXT NOT NULL DEFAULT '{}'");
347
+ ensureColumn(db, 'reports', 'scope_id', 'TEXT');
348
+ ensureColumn(db, 'reports', 'scope_level', 'TEXT');
349
+ ensureColumn(db, 'reports', 'scope_product_id', 'TEXT');
350
+ ensureColumn(db, 'reports', 'scope_project_id', 'TEXT');
351
+ ensureColumn(db, 'reports', 'scope_deliverable_id', 'TEXT');
352
+ ensureColumn(db, 'reports', 'group_ids_json', "TEXT NOT NULL DEFAULT '[]'");
353
+ ensureColumn(db, 'scopes', 'group_ids_json', "TEXT NOT NULL DEFAULT '[]'");
354
+ ensureColumn(db, 'scopes', 'shares_json', "TEXT NOT NULL DEFAULT '[]'");
355
+ ensureColumn(db, 'schedules', 'assigned_group_id', 'TEXT');
356
+ ensureColumn(db, 'audio_notes', 'size_bytes', 'INTEGER');
357
+ ensureColumn(db, 'audio_notes', 'media_encryption_json', 'TEXT');
358
+ ensureColumn(db, 'audio_notes', 'waveform_preview_json', 'TEXT');
359
+ ensureColumn(db, 'audio_notes', 'sender_npub', 'TEXT');
360
+ ensureColumn(db, 'reactions', 'emoji_shortcode', 'TEXT');
361
+ ensureColumn(db, 'reactions', 'sender_npub', 'TEXT');
362
+ ensureColumn(db, 'reactions', 'created_at', 'TEXT');
363
+ // Scope hierarchy l1-l5 columns (leave old product_id/project_id/scope_*_id inert)
364
+ ensureColumn(db, 'scopes', 'l1_id', 'TEXT');
365
+ ensureColumn(db, 'scopes', 'l2_id', 'TEXT');
366
+ ensureColumn(db, 'scopes', 'l3_id', 'TEXT');
367
+ ensureColumn(db, 'scopes', 'l4_id', 'TEXT');
368
+ ensureColumn(db, 'scopes', 'l5_id', 'TEXT');
369
+ for (const table of ['tasks', 'documents', 'directories', 'reports']) {
370
+ for (let i = 1; i <= 5; i++) {
371
+ ensureColumn(db, table, `scope_l${i}_id`, 'TEXT');
372
+ }
373
+ }
374
+ return db;
375
+ }
376
+
377
+ function tableExists(db, tableName) {
378
+ const row = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).get(tableName);
379
+ return Boolean(row);
380
+ }
381
+
382
+ function tableInfo(db, tableName) {
383
+ if (!tableExists(db, tableName)) return [];
384
+ return db.prepare(`PRAGMA table_info(${tableName})`).all();
385
+ }
386
+
387
+ function primaryKeyColumns(columns) {
388
+ return columns
389
+ .filter((column) => Number(column.pk) > 0)
390
+ .sort((a, b) => Number(a.pk) - Number(b.pk))
391
+ .map((column) => column.name);
392
+ }
393
+
394
+ function isLegacyGroupsCache(columns) {
395
+ if (!columns.length) return false;
396
+ const pk = primaryKeyColumns(columns);
397
+ return pk.length !== 1 || pk[0] !== 'group_id' || !columns.some((column) => column.name === 'current_group_npub');
398
+ }
399
+
400
+ function isLegacyGroupKeysCache(columns) {
401
+ if (!columns.length) return false;
402
+ const pk = primaryKeyColumns(columns);
403
+ if (pk.length !== 2 || pk[0] !== 'group_id' || pk[1] !== 'key_version') return true;
404
+ return !columns.some((column) => column.name === 'group_npub');
405
+ }
406
+
407
+ function migrateGroupCacheTables(db) {
408
+ const groupsColumns = tableInfo(db, 'groups_cache');
409
+ if (isLegacyGroupsCache(groupsColumns)) {
410
+ if (tableExists(db, 'groups_cache_legacy')) db.exec(`DROP TABLE groups_cache_legacy`);
411
+ db.exec(`ALTER TABLE groups_cache RENAME TO groups_cache_legacy`);
412
+ }
413
+
414
+ const groupKeysColumns = tableInfo(db, 'group_keys_cache');
415
+ if (isLegacyGroupKeysCache(groupKeysColumns)) {
416
+ if (tableExists(db, 'group_keys_cache_legacy')) db.exec(`DROP TABLE group_keys_cache_legacy`);
417
+ db.exec(`ALTER TABLE group_keys_cache RENAME TO group_keys_cache_legacy`);
418
+ }
419
+
420
+ db.exec(`
421
+ CREATE TABLE IF NOT EXISTS groups_cache (
422
+ group_id TEXT PRIMARY KEY,
423
+ current_group_npub TEXT,
424
+ current_epoch INTEGER NOT NULL DEFAULT 1,
425
+ owner_npub TEXT,
426
+ name TEXT,
427
+ group_kind TEXT,
428
+ private_member_npub TEXT,
429
+ member_npubs_json TEXT NOT NULL,
430
+ raw_json TEXT NOT NULL,
431
+ synced_at TEXT NOT NULL
432
+ );
433
+ CREATE INDEX IF NOT EXISTS idx_groups_cache_current_group_npub
434
+ ON groups_cache(current_group_npub);
435
+ CREATE TABLE IF NOT EXISTS group_keys_cache (
436
+ group_id TEXT NOT NULL,
437
+ key_version INTEGER NOT NULL,
438
+ group_npub TEXT NOT NULL,
439
+ wrapped_group_nsec TEXT NOT NULL,
440
+ wrapped_by_npub TEXT NOT NULL,
441
+ raw_json TEXT NOT NULL,
442
+ synced_at TEXT NOT NULL,
443
+ PRIMARY KEY (group_id, key_version)
444
+ );
445
+ CREATE INDEX IF NOT EXISTS idx_group_keys_cache_group_npub
446
+ ON group_keys_cache(group_npub);
447
+ `);
448
+
449
+ if (tableExists(db, 'groups_cache_legacy')) {
450
+ const rows = db.prepare(`SELECT * FROM groups_cache_legacy`).all();
451
+ const stmt = db.prepare(`
452
+ INSERT OR REPLACE INTO groups_cache (
453
+ group_id,
454
+ current_group_npub,
455
+ current_epoch,
456
+ owner_npub,
457
+ name,
458
+ group_kind,
459
+ private_member_npub,
460
+ member_npubs_json,
461
+ raw_json,
462
+ synced_at
463
+ ) VALUES (
464
+ @group_id,
465
+ @current_group_npub,
466
+ @current_epoch,
467
+ @owner_npub,
468
+ @name,
469
+ @group_kind,
470
+ @private_member_npub,
471
+ @member_npubs_json,
472
+ @raw_json,
473
+ @synced_at
474
+ )
475
+ `);
476
+ for (const row of rows) {
477
+ stmt.run({
478
+ group_id: row.group_id || row.group_npub,
479
+ current_group_npub: row.current_group_npub || row.group_npub || null,
480
+ current_epoch: Number.isInteger(row.current_epoch) ? row.current_epoch : 1,
481
+ owner_npub: row.owner_npub ?? null,
482
+ name: row.name ?? '',
483
+ group_kind: row.group_kind ?? null,
484
+ private_member_npub: row.private_member_npub ?? null,
485
+ member_npubs_json: row.member_npubs_json ?? '[]',
486
+ raw_json: row.raw_json ?? '{}',
487
+ synced_at: row.synced_at ?? new Date().toISOString(),
488
+ });
489
+ }
490
+ db.exec(`DROP TABLE groups_cache_legacy`);
491
+ }
492
+
493
+ if (tableExists(db, 'group_keys_cache_legacy')) {
494
+ const rows = db.prepare(`SELECT * FROM group_keys_cache_legacy`).all();
495
+ const stmt = db.prepare(`
496
+ INSERT OR REPLACE INTO group_keys_cache (
497
+ group_id,
498
+ key_version,
499
+ group_npub,
500
+ wrapped_group_nsec,
501
+ wrapped_by_npub,
502
+ raw_json,
503
+ synced_at
504
+ ) VALUES (
505
+ @group_id,
506
+ @key_version,
507
+ @group_npub,
508
+ @wrapped_group_nsec,
509
+ @wrapped_by_npub,
510
+ @raw_json,
511
+ @synced_at
512
+ )
513
+ `);
514
+ for (const row of rows) {
515
+ stmt.run({
516
+ group_id: row.group_id || row.group_npub,
517
+ key_version: Number.isInteger(row.key_version) ? row.key_version : 1,
518
+ group_npub: row.group_npub,
519
+ wrapped_group_nsec: row.wrapped_group_nsec,
520
+ wrapped_by_npub: row.wrapped_by_npub,
521
+ raw_json: row.raw_json ?? '{}',
522
+ synced_at: row.synced_at ?? new Date().toISOString(),
523
+ });
524
+ }
525
+ db.exec(`DROP TABLE group_keys_cache_legacy`);
526
+ }
527
+ }
528
+
529
+ function ensureColumn(db, tableName, columnName, typeSql) {
530
+ const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
531
+ if (columns.some((column) => column.name === columnName)) return;
532
+ db.exec(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${typeSql}`);
533
+ }
534
+
535
+ export function putMeta(db, key, value) {
536
+ db.prepare(`INSERT INTO app_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value=excluded.value`).run(key, value);
537
+ }
538
+
539
+ export function getMeta(db, key) {
540
+ const row = db.prepare(`SELECT value FROM app_meta WHERE key = ?`).get(key);
541
+ return row?.value ?? null;
542
+ }
543
+
544
+ export function replaceGroups(db, groups) {
545
+ const tx = db.transaction((rows) => {
546
+ db.prepare(`DELETE FROM groups_cache`).run();
547
+ const stmt = db.prepare(`
548
+ INSERT INTO groups_cache (group_id, current_group_npub, current_epoch, owner_npub, name, group_kind, private_member_npub, member_npubs_json, raw_json, synced_at)
549
+ VALUES (@group_id, @current_group_npub, @current_epoch, @owner_npub, @name, @group_kind, @private_member_npub, @member_npubs_json, @raw_json, @synced_at)
550
+ `);
551
+ for (const row of rows) stmt.run(row);
552
+ });
553
+ tx(groups);
554
+ }
555
+
556
+ export function replaceGroupKeys(db, keys) {
557
+ const tx = db.transaction((rows) => {
558
+ db.prepare(`DELETE FROM group_keys_cache`).run();
559
+ const stmt = db.prepare(`
560
+ INSERT INTO group_keys_cache (group_id, key_version, group_npub, wrapped_group_nsec, wrapped_by_npub, raw_json, synced_at)
561
+ VALUES (@group_id, @key_version, @group_npub, @wrapped_group_nsec, @wrapped_by_npub, @raw_json, @synced_at)
562
+ `);
563
+ for (const row of rows) stmt.run(row);
564
+ });
565
+ tx(keys);
566
+ }
567
+
568
+ export function upsertAppSchemas(db, schemas) {
569
+ if (!schemas.length) return;
570
+ const stmt = db.prepare(`
571
+ INSERT INTO app_schemas (
572
+ id,
573
+ workspace_owner_npub,
574
+ app_npub,
575
+ app_name,
576
+ schema_hash,
577
+ schema_version,
578
+ record_families_json,
579
+ schemas_json,
580
+ raw_json,
581
+ updated_at,
582
+ synced_at
583
+ ) VALUES (
584
+ @id,
585
+ @workspace_owner_npub,
586
+ @app_npub,
587
+ @app_name,
588
+ @schema_hash,
589
+ @schema_version,
590
+ @record_families_json,
591
+ @schemas_json,
592
+ @raw_json,
593
+ @updated_at,
594
+ @synced_at
595
+ )
596
+ ON CONFLICT(id) DO UPDATE SET
597
+ workspace_owner_npub = excluded.workspace_owner_npub,
598
+ app_npub = excluded.app_npub,
599
+ app_name = excluded.app_name,
600
+ schema_hash = excluded.schema_hash,
601
+ schema_version = excluded.schema_version,
602
+ record_families_json = excluded.record_families_json,
603
+ schemas_json = excluded.schemas_json,
604
+ raw_json = excluded.raw_json,
605
+ updated_at = excluded.updated_at,
606
+ synced_at = excluded.synced_at
607
+ `);
608
+ const tx = db.transaction((rows) => {
609
+ for (const row of rows) stmt.run(row);
610
+ });
611
+ tx(schemas);
612
+ }
613
+
614
+ export function upsertRows(db, tableName, rows) {
615
+ if (!rows.length) return;
616
+ const columns = Object.keys(rows[0]);
617
+ const placeholders = columns.map((column) => `@${column}`).join(', ');
618
+ const updates = columns.filter((column) => column !== 'record_id' && column !== 'group_npub')
619
+ .map((column) => `${column}=excluded.${column}`)
620
+ .join(', ');
621
+ const sql = `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT(${columns[0]}) DO UPDATE SET ${updates}`;
622
+ const stmt = db.prepare(sql);
623
+ const tx = db.transaction((items) => {
624
+ for (const item of items) stmt.run(item);
625
+ });
626
+ tx(rows);
627
+ }
628
+
629
+ export function getRows(db, sql, params = []) {
630
+ return db.prepare(sql).all(...params);
631
+ }
632
+
633
+ export function getRow(db, sql, params = []) {
634
+ return db.prepare(sql).get(...params) ?? null;
635
+ }
636
+
637
+ export function upsertWorkspaceKeyMapping(db, wsKeyNpub, userNpub) {
638
+ db.prepare(`
639
+ INSERT INTO workspace_key_mappings (ws_key_npub, user_npub, cached_at)
640
+ VALUES (?, ?, ?)
641
+ ON CONFLICT(ws_key_npub) DO UPDATE SET user_npub = excluded.user_npub, cached_at = excluded.cached_at
642
+ `).run(wsKeyNpub, userNpub, new Date().toISOString());
643
+ }
644
+
645
+ export function getWorkspaceKeyMapping(db, wsKeyNpub) {
646
+ const row = db.prepare(`SELECT user_npub FROM workspace_key_mappings WHERE ws_key_npub = ?`).get(wsKeyNpub);
647
+ return row?.user_npub ?? null;
648
+ }
649
+
650
+ export function getAllWorkspaceKeyMappings(db) {
651
+ const rows = db.prepare(`SELECT ws_key_npub, user_npub FROM workspace_key_mappings`).all();
652
+ return new Map(rows.map((row) => [row.ws_key_npub, row.user_npub]));
653
+ }