@origintrail-official/dkg-node-ui 10.0.12 → 10.0.14

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.
Files changed (39) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +50 -50
  3. package/dist/chain-cursor-stores.js +6 -6
  4. package/dist/chat-memory.d.ts +43 -2
  5. package/dist/chat-memory.d.ts.map +1 -1
  6. package/dist/chat-memory.js +240 -216
  7. package/dist/chat-memory.js.map +1 -1
  8. package/dist/db.d.ts +18 -10
  9. package/dist/db.d.ts.map +1 -1
  10. package/dist/db.js +1118 -988
  11. package/dist/db.js.map +1 -1
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +2 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist-ui/assets/{3d-force-graph-Bl6v-uII.js → 3d-force-graph-C9KowW4u.js} +1 -1
  17. package/dist-ui/assets/{AgentHub-D2vaQxlw.js → AgentHub-BVrcom9_.js} +1 -1
  18. package/dist-ui/assets/{AgentProfilePage-BgSTdsjD.js → AgentProfilePage-C9XaTt7j.js} +1 -1
  19. package/dist-ui/assets/{ApproveWalletsModal-D0Ub_eiD.js → ApproveWalletsModal-BdR2mshx.js} +3 -3
  20. package/dist-ui/assets/{ConvictionDetailView-CieVypna.js → ConvictionDetailView-cao48GL1.js} +1 -1
  21. package/dist-ui/assets/{Network-DKw_XEGi.js → Network-B3jCYzr5.js} +1 -1
  22. package/dist-ui/assets/{OnChainProvenanceCard-Dn_IEP7Q.js → OnChainProvenanceCard-DboMX4Aq.js} +1 -1
  23. package/dist-ui/assets/{Operations-BO2C3UWB.js → Operations-Dg1gfLXv.js} +1 -1
  24. package/dist-ui/assets/{PublishingConviction-xKRNHbzk.js → PublishingConviction-Byp6t69e.js} +1 -1
  25. package/dist-ui/assets/{Settings-DC_d9STV.js → Settings-CxI0SKeV.js} +1 -1
  26. package/dist-ui/assets/{ccip-zWfeQL8Q.js → ccip-wJ3OMRuz.js} +1 -1
  27. package/dist-ui/assets/{index-CwRQnNYh.js → index-5DS6xjw0.js} +3 -3
  28. package/dist-ui/assets/index-DOjsv0r7.css +1 -0
  29. package/dist-ui/assets/index-KoAnx9JE.js +1661 -0
  30. package/dist-ui/assets/{jsonld-B67IUHNU-C3ydusi_.js → jsonld-32FQRO67-CDirPi8u.js} +2 -2
  31. package/dist-ui/assets/{jsonld-G-CM-8JR.js → jsonld-CwHW9UDN.js} +1 -1
  32. package/dist-ui/assets/{renderer-3d-OSESAPET-ConXqyK6.js → renderer-3d-2EVDZII7-BNXV6iSu.js} +2 -2
  33. package/dist-ui/assets/{shikiHighlighter-C6lRYd1O.js → shikiHighlighter-C-LWPPSu.js} +1 -1
  34. package/dist-ui/index.html +14 -14
  35. package/package.json +4 -3
  36. package/dist-ui/assets/index-DEtfq9qv.css +0 -1
  37. package/dist-ui/assets/index-hrI-Ir4l.js +0 -1661
  38. /package/dist-ui/assets/{ntriples-SF2LFNMC-DH7OofPp.js → ntriples-ZWBY2WET-DH7OofPp.js} +0 -0
  39. /package/dist-ui/assets/{turtle-BXN4L2BY-BzTu0283.js → turtle-JJPK7LJ5-BzTu0283.js} +0 -0
package/dist/db.js CHANGED
@@ -1,8 +1,8 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { join } from 'node:path';
3
- import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, } from '@origintrail-official/dkg-core';
3
+ import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, DEFAULT_SYNC_CHECKPOINT_TTL_MS, isValidSyncCheckpointEntry, transitionSyncCheckpointManifestOffset, transitionSyncCheckpointOffset, transitionSyncCheckpointResponderSession, withoutSyncCheckpointResponderSession, } from '@origintrail-official/dkg-core';
4
4
  export { SqliteChainEventCursorStore, SqliteContextGraphRegistryScanCursorStore, } from './chain-cursor-stores.js';
5
- const SCHEMA_VERSION = 31;
5
+ export const SCHEMA_VERSION = 34;
6
6
  // Default operator retention. Lowered from 90 → 14 days on V15 (2026-05) after
7
7
  // a production incident in which the `logs` table + its FTS5 shadow tables
8
8
  // grew to ~9 GB on a 12-day-old node and corrupted the SQLite page (header
@@ -138,44 +138,44 @@ export class DashboardDB {
138
138
  migrate() {
139
139
  const version = this.db.pragma('user_version', { simple: true });
140
140
  const upgradedExistingDb = version > 0 && version < SCHEMA_VERSION;
141
- const ensureJoinPolicyAuditCapTrigger = () => this.db.exec(`
142
- CREATE TRIGGER IF NOT EXISTS cap_cg_join_policy_audit_rows
143
- AFTER INSERT ON context_graph_join_policy_audit
144
- BEGIN
145
- DELETE FROM context_graph_join_policy_audit
146
- WHERE id <= NEW.id - 100000
147
- AND event_type NOT IN (
148
- 'join_admission_committed',
149
- 'join_policy_changed'
150
- );
151
- END;
141
+ const ensureJoinPolicyAuditCapTrigger = () => this.db.exec(`
142
+ CREATE TRIGGER IF NOT EXISTS cap_cg_join_policy_audit_rows
143
+ AFTER INSERT ON context_graph_join_policy_audit
144
+ BEGIN
145
+ DELETE FROM context_graph_join_policy_audit
146
+ WHERE id <= NEW.id - 100000
147
+ AND event_type NOT IN (
148
+ 'join_admission_committed',
149
+ 'join_policy_changed'
150
+ );
151
+ END;
152
152
  `);
153
153
  const ensureJoinApprovalRepairMarker = () => {
154
- const table = this.db.prepare(`
155
- SELECT 1 AS found FROM sqlite_master
156
- WHERE type = 'table' AND name = 'context_graph_join_approval_ledger'
154
+ const table = this.db.prepare(`
155
+ SELECT 1 AS found FROM sqlite_master
156
+ WHERE type = 'table' AND name = 'context_graph_join_approval_ledger'
157
157
  `).get();
158
158
  if (!table)
159
159
  return;
160
160
  const columns = this.db.pragma('table_info(context_graph_join_approval_ledger)');
161
161
  if (!columns.some((column) => column.name === 'repair_pending')) {
162
- this.db.exec(`
163
- ALTER TABLE context_graph_join_approval_ledger
164
- ADD COLUMN repair_pending INTEGER NOT NULL DEFAULT 0
165
- CHECK (repair_pending IN (0, 1));
162
+ this.db.exec(`
163
+ ALTER TABLE context_graph_join_approval_ledger
164
+ ADD COLUMN repair_pending INTEGER NOT NULL DEFAULT 0
165
+ CHECK (repair_pending IN (0, 1));
166
166
  `);
167
167
  }
168
- this.db.exec(`
169
- CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_repair
170
- ON context_graph_join_approval_ledger(
171
- context_graph_id, request_digest, repair_pending, reserved_at DESC
172
- );
168
+ this.db.exec(`
169
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_repair
170
+ ON context_graph_join_approval_ledger(
171
+ context_graph_id, request_digest, repair_pending, reserved_at DESC
172
+ );
173
173
  `);
174
174
  };
175
- const ensureSyncCheckpointResponderSessionColumns = () => {
176
- const table = this.db.prepare(`
177
- SELECT 1 AS found FROM sqlite_master
178
- WHERE type = 'table' AND name = 'sync_checkpoints'
175
+ const ensureSyncCheckpointResumeColumns = () => {
176
+ const table = this.db.prepare(`
177
+ SELECT 1 AS found FROM sqlite_master
178
+ WHERE type = 'table' AND name = 'sync_checkpoints'
179
179
  `).get();
180
180
  if (!table)
181
181
  return;
@@ -187,6 +187,18 @@ export class DashboardDB {
187
187
  if (!columns.has('responder_session_expires_at')) {
188
188
  this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_expires_at INTEGER;`);
189
189
  }
190
+ if (!columns.has('responder_session_offset')) {
191
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_offset INTEGER;`);
192
+ }
193
+ if (!columns.has('manifest_digest')) {
194
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN manifest_digest TEXT;`);
195
+ }
196
+ if (!columns.has('manifest_prefix_digest')) {
197
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN manifest_prefix_digest TEXT;`);
198
+ }
199
+ if (!columns.has('terminal')) {
200
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1));`);
201
+ }
190
202
  };
191
203
  if (version > SCHEMA_VERSION)
192
204
  return;
@@ -194,122 +206,122 @@ export class DashboardDB {
194
206
  // Repair restored/development databases that carry the current version
195
207
  // but lost an idempotent schema adjunct.
196
208
  ensureJoinApprovalRepairMarker();
197
- ensureSyncCheckpointResponderSessionColumns();
209
+ ensureSyncCheckpointResumeColumns();
198
210
  ensureJoinPolicyAuditCapTrigger();
199
211
  return;
200
212
  }
201
213
  if (version < 1) {
202
- this.db.exec(`
203
- CREATE TABLE IF NOT EXISTS metric_snapshots (
204
- id INTEGER PRIMARY KEY AUTOINCREMENT,
205
- ts INTEGER NOT NULL,
206
- cpu_percent REAL,
207
- mem_used_bytes INTEGER,
208
- mem_total_bytes INTEGER,
209
- disk_used_bytes INTEGER,
210
- disk_total_bytes INTEGER,
211
- heap_used_bytes INTEGER,
212
- uptime_seconds INTEGER,
213
- peer_count INTEGER,
214
- direct_peers INTEGER,
215
- relayed_peers INTEGER,
216
- mesh_peers INTEGER,
217
- contextGraph_count INTEGER,
218
- total_triples INTEGER,
219
- total_kcs INTEGER,
220
- total_kas INTEGER,
221
- store_bytes INTEGER,
222
- confirmed_kcs INTEGER,
223
- tentative_kcs INTEGER,
224
- rpc_latency_ms INTEGER,
225
- rpc_healthy INTEGER,
226
- relay_capacity INTEGER,
227
- relay_reservation_count INTEGER,
228
- relay_active_circuits INTEGER,
229
- relay_bytes_in INTEGER,
230
- relay_bytes_out INTEGER
231
- );
232
- CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON metric_snapshots(ts);
233
-
234
- CREATE TABLE IF NOT EXISTS operations (
235
- id INTEGER PRIMARY KEY AUTOINCREMENT,
236
- operation_id TEXT NOT NULL,
237
- operation_name TEXT NOT NULL,
238
- started_at INTEGER NOT NULL,
239
- duration_ms INTEGER,
240
- status TEXT DEFAULT 'in_progress',
241
- peer_id TEXT,
242
- contextGraph_id TEXT,
243
- triple_count INTEGER,
244
- error_message TEXT,
245
- details TEXT
246
- );
247
- CREATE INDEX IF NOT EXISTS idx_ops_operation_id ON operations(operation_id);
248
- CREATE INDEX IF NOT EXISTS idx_ops_started_at ON operations(started_at);
249
- CREATE INDEX IF NOT EXISTS idx_ops_name ON operations(operation_name);
250
-
251
- CREATE TABLE IF NOT EXISTS logs (
252
- id INTEGER PRIMARY KEY AUTOINCREMENT,
253
- ts INTEGER NOT NULL,
254
- level TEXT NOT NULL,
255
- operation_name TEXT,
256
- operation_id TEXT,
257
- module TEXT,
258
- message TEXT NOT NULL
259
- );
260
- CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
261
- CREATE INDEX IF NOT EXISTS idx_logs_operation_id ON logs(operation_id);
262
- CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
263
-
264
- -- NOTE: Earlier schema versions (V1..V14) also created an FTS5
265
- -- virtual table "logs_fts" plus AFTER INSERT/DELETE triggers to
266
- -- keep it in sync with "logs". That fed a free-text search path
267
- -- behind /api/logs?q=... The FTS5 index turned out to dominate
268
- -- on-disk size (multi-GB shadow tables on long-lived nodes) and
269
- -- the corresponding HTTP route had no production consumer
270
- -- (the dashboard log viewer is file-backed via /api/node-log).
271
- -- V15 drops it for fresh installs; the migration below cleans
272
- -- it up for in-place upgrades.
273
-
274
- CREATE TABLE IF NOT EXISTS query_history (
275
- id INTEGER PRIMARY KEY AUTOINCREMENT,
276
- ts INTEGER NOT NULL,
277
- sparql TEXT NOT NULL,
278
- duration_ms INTEGER,
279
- result_count INTEGER,
280
- error TEXT
281
- );
282
- CREATE INDEX IF NOT EXISTS idx_qhist_ts ON query_history(ts);
283
-
284
- CREATE TABLE IF NOT EXISTS saved_queries (
285
- id INTEGER PRIMARY KEY AUTOINCREMENT,
286
- name TEXT NOT NULL,
287
- description TEXT,
288
- sparql TEXT NOT NULL,
289
- created_at INTEGER NOT NULL,
290
- updated_at INTEGER NOT NULL
291
- );
214
+ this.db.exec(`
215
+ CREATE TABLE IF NOT EXISTS metric_snapshots (
216
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
217
+ ts INTEGER NOT NULL,
218
+ cpu_percent REAL,
219
+ mem_used_bytes INTEGER,
220
+ mem_total_bytes INTEGER,
221
+ disk_used_bytes INTEGER,
222
+ disk_total_bytes INTEGER,
223
+ heap_used_bytes INTEGER,
224
+ uptime_seconds INTEGER,
225
+ peer_count INTEGER,
226
+ direct_peers INTEGER,
227
+ relayed_peers INTEGER,
228
+ mesh_peers INTEGER,
229
+ contextGraph_count INTEGER,
230
+ total_triples INTEGER,
231
+ total_kcs INTEGER,
232
+ total_kas INTEGER,
233
+ store_bytes INTEGER,
234
+ confirmed_kcs INTEGER,
235
+ tentative_kcs INTEGER,
236
+ rpc_latency_ms INTEGER,
237
+ rpc_healthy INTEGER,
238
+ relay_capacity INTEGER,
239
+ relay_reservation_count INTEGER,
240
+ relay_active_circuits INTEGER,
241
+ relay_bytes_in INTEGER,
242
+ relay_bytes_out INTEGER
243
+ );
244
+ CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON metric_snapshots(ts);
245
+
246
+ CREATE TABLE IF NOT EXISTS operations (
247
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
248
+ operation_id TEXT NOT NULL,
249
+ operation_name TEXT NOT NULL,
250
+ started_at INTEGER NOT NULL,
251
+ duration_ms INTEGER,
252
+ status TEXT DEFAULT 'in_progress',
253
+ peer_id TEXT,
254
+ contextGraph_id TEXT,
255
+ triple_count INTEGER,
256
+ error_message TEXT,
257
+ details TEXT
258
+ );
259
+ CREATE INDEX IF NOT EXISTS idx_ops_operation_id ON operations(operation_id);
260
+ CREATE INDEX IF NOT EXISTS idx_ops_started_at ON operations(started_at);
261
+ CREATE INDEX IF NOT EXISTS idx_ops_name ON operations(operation_name);
262
+
263
+ CREATE TABLE IF NOT EXISTS logs (
264
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
265
+ ts INTEGER NOT NULL,
266
+ level TEXT NOT NULL,
267
+ operation_name TEXT,
268
+ operation_id TEXT,
269
+ module TEXT,
270
+ message TEXT NOT NULL
271
+ );
272
+ CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
273
+ CREATE INDEX IF NOT EXISTS idx_logs_operation_id ON logs(operation_id);
274
+ CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
275
+
276
+ -- NOTE: Earlier schema versions (V1..V14) also created an FTS5
277
+ -- virtual table "logs_fts" plus AFTER INSERT/DELETE triggers to
278
+ -- keep it in sync with "logs". That fed a free-text search path
279
+ -- behind /api/logs?q=... The FTS5 index turned out to dominate
280
+ -- on-disk size (multi-GB shadow tables on long-lived nodes) and
281
+ -- the corresponding HTTP route had no production consumer
282
+ -- (the dashboard log viewer is file-backed via /api/node-log).
283
+ -- V15 drops it for fresh installs; the migration below cleans
284
+ -- it up for in-place upgrades.
285
+
286
+ CREATE TABLE IF NOT EXISTS query_history (
287
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
288
+ ts INTEGER NOT NULL,
289
+ sparql TEXT NOT NULL,
290
+ duration_ms INTEGER,
291
+ result_count INTEGER,
292
+ error TEXT
293
+ );
294
+ CREATE INDEX IF NOT EXISTS idx_qhist_ts ON query_history(ts);
295
+
296
+ CREATE TABLE IF NOT EXISTS saved_queries (
297
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
298
+ name TEXT NOT NULL,
299
+ description TEXT,
300
+ sparql TEXT NOT NULL,
301
+ created_at INTEGER NOT NULL,
302
+ updated_at INTEGER NOT NULL
303
+ );
292
304
  `);
293
305
  }
294
306
  if (version < 2) {
295
- this.db.exec(`
296
- CREATE TABLE IF NOT EXISTS operation_phases (
297
- id INTEGER PRIMARY KEY AUTOINCREMENT,
298
- operation_id TEXT NOT NULL,
299
- phase TEXT NOT NULL,
300
- started_at INTEGER NOT NULL,
301
- duration_ms INTEGER,
302
- status TEXT DEFAULT 'in_progress',
303
- details TEXT
304
- );
305
- CREATE INDEX IF NOT EXISTS idx_phases_op ON operation_phases(operation_id);
306
-
307
- ALTER TABLE operations ADD COLUMN gas_used INTEGER;
308
- ALTER TABLE operations ADD COLUMN gas_price_gwei REAL;
309
- ALTER TABLE operations ADD COLUMN gas_cost_eth REAL;
310
- ALTER TABLE operations ADD COLUMN trac_cost REAL;
311
- ALTER TABLE operations ADD COLUMN tx_hash TEXT;
312
- ALTER TABLE operations ADD COLUMN chain_id INTEGER;
307
+ this.db.exec(`
308
+ CREATE TABLE IF NOT EXISTS operation_phases (
309
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
310
+ operation_id TEXT NOT NULL,
311
+ phase TEXT NOT NULL,
312
+ started_at INTEGER NOT NULL,
313
+ duration_ms INTEGER,
314
+ status TEXT DEFAULT 'in_progress',
315
+ details TEXT
316
+ );
317
+ CREATE INDEX IF NOT EXISTS idx_phases_op ON operation_phases(operation_id);
318
+
319
+ ALTER TABLE operations ADD COLUMN gas_used INTEGER;
320
+ ALTER TABLE operations ADD COLUMN gas_price_gwei REAL;
321
+ ALTER TABLE operations ADD COLUMN gas_cost_eth REAL;
322
+ ALTER TABLE operations ADD COLUMN trac_cost REAL;
323
+ ALTER TABLE operations ADD COLUMN tx_hash TEXT;
324
+ ALTER TABLE operations ADD COLUMN chain_id INTEGER;
313
325
  `);
314
326
  }
315
327
  if (version < 3) {
@@ -319,42 +331,42 @@ export class DashboardDB {
319
331
  // column + the partial unique index in one step; the V11 block
320
332
  // below idempotently adds the column to nodes that upgraded
321
333
  // through versions 3..10 first.
322
- this.db.exec(`
323
- CREATE TABLE IF NOT EXISTS chat_messages (
324
- id INTEGER PRIMARY KEY AUTOINCREMENT,
325
- ts INTEGER NOT NULL,
326
- direction TEXT NOT NULL,
327
- peer TEXT NOT NULL,
328
- peer_name TEXT,
329
- text TEXT NOT NULL,
330
- delivered INTEGER,
331
- message_id TEXT
332
- );
333
- CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat_messages(ts);
334
- CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer);
334
+ this.db.exec(`
335
+ CREATE TABLE IF NOT EXISTS chat_messages (
336
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
337
+ ts INTEGER NOT NULL,
338
+ direction TEXT NOT NULL,
339
+ peer TEXT NOT NULL,
340
+ peer_name TEXT,
341
+ text TEXT NOT NULL,
342
+ delivered INTEGER,
343
+ message_id TEXT
344
+ );
345
+ CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat_messages(ts);
346
+ CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer);
335
347
  `);
336
348
  }
337
349
  if (version < 4) {
338
- this.db.exec(`
339
- CREATE TABLE IF NOT EXISTS chat_persistence_jobs (
340
- turn_id TEXT PRIMARY KEY,
341
- session_id TEXT NOT NULL,
342
- user_message TEXT NOT NULL,
343
- assistant_reply TEXT NOT NULL,
344
- tool_calls_json TEXT,
345
- status TEXT NOT NULL,
346
- attempts INTEGER NOT NULL DEFAULT 0,
347
- max_attempts INTEGER NOT NULL DEFAULT 3,
348
- next_attempt_at INTEGER NOT NULL,
349
- queued_at INTEGER NOT NULL,
350
- updated_at INTEGER NOT NULL,
351
- store_ms INTEGER,
352
- error_message TEXT
353
- );
354
- CREATE INDEX IF NOT EXISTS idx_chat_persist_status_next
355
- ON chat_persistence_jobs(status, next_attempt_at);
356
- CREATE INDEX IF NOT EXISTS idx_chat_persist_session
357
- ON chat_persistence_jobs(session_id);
350
+ this.db.exec(`
351
+ CREATE TABLE IF NOT EXISTS chat_persistence_jobs (
352
+ turn_id TEXT PRIMARY KEY,
353
+ session_id TEXT NOT NULL,
354
+ user_message TEXT NOT NULL,
355
+ assistant_reply TEXT NOT NULL,
356
+ tool_calls_json TEXT,
357
+ status TEXT NOT NULL,
358
+ attempts INTEGER NOT NULL DEFAULT 0,
359
+ max_attempts INTEGER NOT NULL DEFAULT 3,
360
+ next_attempt_at INTEGER NOT NULL,
361
+ queued_at INTEGER NOT NULL,
362
+ updated_at INTEGER NOT NULL,
363
+ store_ms INTEGER,
364
+ error_message TEXT
365
+ );
366
+ CREATE INDEX IF NOT EXISTS idx_chat_persist_status_next
367
+ ON chat_persistence_jobs(status, next_attempt_at);
368
+ CREATE INDEX IF NOT EXISTS idx_chat_persist_session
369
+ ON chat_persistence_jobs(session_id);
358
370
  `);
359
371
  }
360
372
  if (version < 5) {
@@ -363,77 +375,77 @@ export class DashboardDB {
363
375
  // fresh-install CREATE so new nodes get the column + its index in one
364
376
  // step; the V16 block below idempotently adds it to nodes that upgraded
365
377
  // through versions 5..15 first (same pattern as `message_id` in V3/V11).
366
- this.db.exec(`
367
- CREATE TABLE IF NOT EXISTS notifications (
368
- id INTEGER PRIMARY KEY AUTOINCREMENT,
369
- ts INTEGER NOT NULL,
370
- type TEXT NOT NULL,
371
- title TEXT NOT NULL,
372
- message TEXT NOT NULL,
373
- source TEXT,
374
- peer TEXT,
375
- read INTEGER NOT NULL DEFAULT 0,
376
- meta TEXT,
377
- context_graph_id TEXT
378
- );
379
- CREATE INDEX IF NOT EXISTS idx_notif_ts ON notifications(ts);
380
- CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(read);
381
- CREATE INDEX IF NOT EXISTS idx_notif_cg ON notifications(context_graph_id);
378
+ this.db.exec(`
379
+ CREATE TABLE IF NOT EXISTS notifications (
380
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
381
+ ts INTEGER NOT NULL,
382
+ type TEXT NOT NULL,
383
+ title TEXT NOT NULL,
384
+ message TEXT NOT NULL,
385
+ source TEXT,
386
+ peer TEXT,
387
+ read INTEGER NOT NULL DEFAULT 0,
388
+ meta TEXT,
389
+ context_graph_id TEXT
390
+ );
391
+ CREATE INDEX IF NOT EXISTS idx_notif_ts ON notifications(ts);
392
+ CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(read);
393
+ CREATE INDEX IF NOT EXISTS idx_notif_cg ON notifications(context_graph_id);
382
394
  `);
383
395
  }
384
396
  if (version < 6) {
385
- this.db.exec(`
386
- CREATE TABLE IF NOT EXISTS settings (
387
- key TEXT PRIMARY KEY,
388
- value TEXT NOT NULL
389
- );
397
+ this.db.exec(`
398
+ CREATE TABLE IF NOT EXISTS settings (
399
+ key TEXT PRIMARY KEY,
400
+ value TEXT NOT NULL
401
+ );
390
402
  `);
391
403
  }
392
404
  if (version < 7) {
393
- this.db.exec(`
394
- CREATE TABLE IF NOT EXISTS context_graph_subscriptions (
395
- context_graph_id TEXT PRIMARY KEY,
396
- name TEXT,
397
- subscribed INTEGER NOT NULL,
398
- synced INTEGER NOT NULL,
399
- meta_synced INTEGER,
400
- on_chain_id TEXT,
401
- sync_scoped INTEGER NOT NULL DEFAULT 1,
402
- updated_at INTEGER NOT NULL
403
- );
404
- CREATE INDEX IF NOT EXISTS idx_cg_subs_sync_scoped
405
- ON context_graph_subscriptions(sync_scoped);
405
+ this.db.exec(`
406
+ CREATE TABLE IF NOT EXISTS context_graph_subscriptions (
407
+ context_graph_id TEXT PRIMARY KEY,
408
+ name TEXT,
409
+ subscribed INTEGER NOT NULL,
410
+ synced INTEGER NOT NULL,
411
+ meta_synced INTEGER,
412
+ on_chain_id TEXT,
413
+ sync_scoped INTEGER NOT NULL DEFAULT 1,
414
+ updated_at INTEGER NOT NULL
415
+ );
416
+ CREATE INDEX IF NOT EXISTS idx_cg_subs_sync_scoped
417
+ ON context_graph_subscriptions(sync_scoped);
406
418
  `);
407
419
  }
408
420
  if (version < 8) {
409
- this.db.exec(`
410
- CREATE TABLE IF NOT EXISTS context_graph_memberships (
411
- context_graph_id TEXT NOT NULL,
412
- principal_type TEXT NOT NULL,
413
- principal_id TEXT NOT NULL,
414
- role TEXT,
415
- status TEXT NOT NULL,
416
- source TEXT,
417
- display_name TEXT,
418
- metadata TEXT,
419
- first_seen_at INTEGER NOT NULL,
420
- updated_at INTEGER NOT NULL,
421
- PRIMARY KEY (context_graph_id, principal_type, principal_id)
422
- );
423
- CREATE INDEX IF NOT EXISTS idx_cg_members_context
424
- ON context_graph_memberships(context_graph_id);
425
- CREATE INDEX IF NOT EXISTS idx_cg_members_principal
426
- ON context_graph_memberships(principal_type, principal_id);
427
- CREATE INDEX IF NOT EXISTS idx_cg_members_status
428
- ON context_graph_memberships(status);
421
+ this.db.exec(`
422
+ CREATE TABLE IF NOT EXISTS context_graph_memberships (
423
+ context_graph_id TEXT NOT NULL,
424
+ principal_type TEXT NOT NULL,
425
+ principal_id TEXT NOT NULL,
426
+ role TEXT,
427
+ status TEXT NOT NULL,
428
+ source TEXT,
429
+ display_name TEXT,
430
+ metadata TEXT,
431
+ first_seen_at INTEGER NOT NULL,
432
+ updated_at INTEGER NOT NULL,
433
+ PRIMARY KEY (context_graph_id, principal_type, principal_id)
434
+ );
435
+ CREATE INDEX IF NOT EXISTS idx_cg_members_context
436
+ ON context_graph_memberships(context_graph_id);
437
+ CREATE INDEX IF NOT EXISTS idx_cg_members_principal
438
+ ON context_graph_memberships(principal_type, principal_id);
439
+ CREATE INDEX IF NOT EXISTS idx_cg_members_status
440
+ ON context_graph_memberships(status);
429
441
  `);
430
442
  }
431
443
  if (version < 9) {
432
444
  const columns = this.db.prepare('PRAGMA table_info(context_graph_subscriptions)').all();
433
445
  if (!columns.some((column) => column.name === 'shared_memory_synced')) {
434
- this.db.exec(`
435
- ALTER TABLE context_graph_subscriptions
436
- ADD COLUMN shared_memory_synced INTEGER;
446
+ this.db.exec(`
447
+ ALTER TABLE context_graph_subscriptions
448
+ ADD COLUMN shared_memory_synced INTEGER;
437
449
  `);
438
450
  }
439
451
  }
@@ -502,10 +514,10 @@ export class DashboardDB {
502
514
  if (!chatCols.has('message_id')) {
503
515
  this.db.exec(`ALTER TABLE chat_messages ADD COLUMN message_id TEXT;`);
504
516
  }
505
- this.db.exec(`
506
- CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_msgid
507
- ON chat_messages(peer, direction, message_id)
508
- WHERE message_id IS NOT NULL;
517
+ this.db.exec(`
518
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_msgid
519
+ ON chat_messages(peer, direction, message_id)
520
+ WHERE message_id IS NOT NULL;
509
521
  `);
510
522
  }
511
523
  if (version < 12) {
@@ -561,33 +573,33 @@ export class DashboardDB {
561
573
  // `message_id` is enforced via `message_idempotency` instead.
562
574
  // V13 will also preserve `chat_messages.message_id` as
563
575
  // nullable + unwritten for rollback safety.
564
- this.db.exec(`
565
- CREATE TABLE IF NOT EXISTS message_idempotency (
566
- peer_id TEXT NOT NULL,
567
- protocol TEXT NOT NULL,
568
- message_id TEXT NOT NULL,
569
- direction TEXT NOT NULL CHECK (direction IN ('in', 'out')),
570
- response_blob BLOB,
571
- response_size INTEGER NOT NULL DEFAULT 0,
572
- ts INTEGER NOT NULL,
573
- PRIMARY KEY (peer_id, protocol, message_id, direction)
574
- );
575
- CREATE INDEX IF NOT EXISTS idx_idem_ts ON message_idempotency(ts);
576
-
577
- CREATE TABLE IF NOT EXISTS protocol_outbox (
578
- peer_id TEXT NOT NULL,
579
- protocol TEXT NOT NULL,
580
- message_id TEXT NOT NULL,
581
- payload BLOB NOT NULL,
582
- attempts INTEGER NOT NULL DEFAULT 0,
583
- first_failure_at INTEGER NOT NULL,
584
- last_attempt_at INTEGER NOT NULL,
585
- next_attempt_at INTEGER NOT NULL,
586
- last_error TEXT,
587
- PRIMARY KEY (peer_id, protocol, message_id)
588
- );
589
- CREATE INDEX IF NOT EXISTS idx_outbox_next_attempt
590
- ON protocol_outbox(next_attempt_at);
576
+ this.db.exec(`
577
+ CREATE TABLE IF NOT EXISTS message_idempotency (
578
+ peer_id TEXT NOT NULL,
579
+ protocol TEXT NOT NULL,
580
+ message_id TEXT NOT NULL,
581
+ direction TEXT NOT NULL CHECK (direction IN ('in', 'out')),
582
+ response_blob BLOB,
583
+ response_size INTEGER NOT NULL DEFAULT 0,
584
+ ts INTEGER NOT NULL,
585
+ PRIMARY KEY (peer_id, protocol, message_id, direction)
586
+ );
587
+ CREATE INDEX IF NOT EXISTS idx_idem_ts ON message_idempotency(ts);
588
+
589
+ CREATE TABLE IF NOT EXISTS protocol_outbox (
590
+ peer_id TEXT NOT NULL,
591
+ protocol TEXT NOT NULL,
592
+ message_id TEXT NOT NULL,
593
+ payload BLOB NOT NULL,
594
+ attempts INTEGER NOT NULL DEFAULT 0,
595
+ first_failure_at INTEGER NOT NULL,
596
+ last_attempt_at INTEGER NOT NULL,
597
+ next_attempt_at INTEGER NOT NULL,
598
+ last_error TEXT,
599
+ PRIMARY KEY (peer_id, protocol, message_id)
600
+ );
601
+ CREATE INDEX IF NOT EXISTS idx_outbox_next_attempt
602
+ ON protocol_outbox(next_attempt_at);
591
603
  `);
592
604
  }
593
605
  if (version < 13) {
@@ -612,8 +624,8 @@ export class DashboardDB {
612
624
  // table rebuild on downgrade.
613
625
  //
614
626
  // No data migration. No new tables. Pure DROP INDEX.
615
- this.db.exec(`
616
- DROP INDEX IF EXISTS idx_chat_msgid;
627
+ this.db.exec(`
628
+ DROP INDEX IF EXISTS idx_chat_msgid;
617
629
  `);
618
630
  }
619
631
  if (version < 14) {
@@ -654,10 +666,10 @@ export class DashboardDB {
654
666
  // operation-correlated log views (`getOperation`,
655
667
  // `getFailedOperations`). Substring/text search moves to
656
668
  // /api/node-log, which already supports `?q=`.
657
- this.db.exec(`
658
- DROP TRIGGER IF EXISTS logs_ai;
659
- DROP TRIGGER IF EXISTS logs_ad;
660
- DROP TABLE IF EXISTS logs_fts;
669
+ this.db.exec(`
670
+ DROP TRIGGER IF EXISTS logs_ai;
671
+ DROP TRIGGER IF EXISTS logs_ad;
672
+ DROP TABLE IF EXISTS logs_fts;
661
673
  `);
662
674
  // VACUUM cannot run inside a transaction; better-sqlite3 wraps
663
675
  // multi-statement exec() in implicit BEGIN/COMMIT, so we issue
@@ -721,23 +733,23 @@ export class DashboardDB {
721
733
  // re-parsing the daemon log. One row per decision point (sweep / fetch /
722
734
  // promote / already / defer / cursor-advance / core-fill). Pruned by `ts`
723
735
  // like the other event tables.
724
- this.db.exec(`
725
- CREATE TABLE IF NOT EXISTS replication_events (
726
- id INTEGER PRIMARY KEY AUTOINCREMENT,
727
- ts INTEGER NOT NULL,
728
- context_graph_id TEXT NOT NULL,
729
- on_chain_cg_id TEXT,
730
- action TEXT NOT NULL,
731
- ual TEXT,
732
- ordinal INTEGER,
733
- ka_id TEXT,
734
- from_watermark INTEGER,
735
- to_watermark INTEGER,
736
- head INTEGER,
737
- reconciled INTEGER,
738
- pending INTEGER,
739
- detail TEXT
740
- );
736
+ this.db.exec(`
737
+ CREATE TABLE IF NOT EXISTS replication_events (
738
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
739
+ ts INTEGER NOT NULL,
740
+ context_graph_id TEXT NOT NULL,
741
+ on_chain_cg_id TEXT,
742
+ action TEXT NOT NULL,
743
+ ual TEXT,
744
+ ordinal INTEGER,
745
+ ka_id TEXT,
746
+ from_watermark INTEGER,
747
+ to_watermark INTEGER,
748
+ head INTEGER,
749
+ reconciled INTEGER,
750
+ pending INTEGER,
751
+ detail TEXT
752
+ );
741
753
  `);
742
754
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_repl_ts ON replication_events(ts);`);
743
755
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_repl_cg ON replication_events(context_graph_id, ts);`);
@@ -776,54 +788,60 @@ export class DashboardDB {
776
788
  // the 2^63 INTEGER max and under the uint96 on-chain field. Like
777
789
  // `protocol_outbox`, this state is durable and is NEVER pruned —
778
790
  // reclaiming a number could mint a duplicate kaId.
779
- this.db.exec(`
780
- CREATE TABLE IF NOT EXISTS ka_numbers (
781
- author_address TEXT PRIMARY KEY,
782
- next_number INTEGER NOT NULL
783
- );
791
+ this.db.exec(`
792
+ CREATE TABLE IF NOT EXISTS ka_numbers (
793
+ author_address TEXT PRIMARY KEY,
794
+ next_number INTEGER NOT NULL
795
+ );
784
796
  `);
785
797
  }
786
798
  if (version < 21) {
787
- this.db.exec(`
788
- CREATE TABLE IF NOT EXISTS sync_checkpoints (
789
- key TEXT PRIMARY KEY,
790
- offset INTEGER NOT NULL CHECK (offset >= 0),
791
- updated_at INTEGER NOT NULL,
792
- expires_at INTEGER NOT NULL
793
- );
794
- CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_expires_at
795
- ON sync_checkpoints(expires_at);
799
+ this.db.exec(`
800
+ CREATE TABLE IF NOT EXISTS sync_checkpoints (
801
+ key TEXT PRIMARY KEY,
802
+ offset INTEGER NOT NULL CHECK (offset >= 0),
803
+ updated_at INTEGER NOT NULL,
804
+ expires_at INTEGER NOT NULL,
805
+ responder_session_id TEXT,
806
+ responder_session_expires_at INTEGER,
807
+ responder_session_offset INTEGER CHECK (responder_session_offset >= 0),
808
+ manifest_digest TEXT,
809
+ manifest_prefix_digest TEXT,
810
+ terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1))
811
+ );
812
+ CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_expires_at
813
+ ON sync_checkpoints(expires_at);
796
814
  `);
797
815
  }
798
816
  if (version < 22) {
799
- this.db.exec(`
800
- CREATE TABLE IF NOT EXISTS runtime_cursors (
801
- namespace TEXT NOT NULL,
802
- scope TEXT NOT NULL,
803
- key TEXT NOT NULL,
804
- value INTEGER NOT NULL CHECK (value > 0),
805
- updated_at INTEGER NOT NULL,
806
- PRIMARY KEY (namespace, scope, key)
807
- );
817
+ this.db.exec(`
818
+ CREATE TABLE IF NOT EXISTS runtime_cursors (
819
+ namespace TEXT NOT NULL,
820
+ scope TEXT NOT NULL,
821
+ key TEXT NOT NULL,
822
+ value INTEGER NOT NULL CHECK (value > 0),
823
+ updated_at INTEGER NOT NULL,
824
+ PRIMARY KEY (namespace, scope, key)
825
+ );
808
826
  `);
809
- this.db.exec(`
810
- CREATE INDEX IF NOT EXISTS idx_runtime_cursors_namespace_scope
811
- ON runtime_cursors(namespace, scope);
827
+ this.db.exec(`
828
+ CREATE INDEX IF NOT EXISTS idx_runtime_cursors_namespace_scope
829
+ ON runtime_cursors(namespace, scope);
812
830
  `);
813
831
  }
814
832
  if (version < 23) {
815
833
  // OT-RFC-59 SC5: durable per-(peer, contextGraph) changelog cursor. NEVER
816
834
  // pruned (see SqliteChangelogCursorStore) — a TTL would defeat the O(delta)
817
835
  // cross-restart catch-up this table exists for.
818
- this.db.exec(`
819
- CREATE TABLE IF NOT EXISTS changelog_cursors (
820
- peer_id TEXT NOT NULL,
821
- context_graph_id TEXT NOT NULL,
822
- era TEXT NOT NULL,
823
- seq INTEGER NOT NULL CHECK (seq >= 0),
824
- updated_at INTEGER NOT NULL,
825
- PRIMARY KEY (peer_id, context_graph_id)
826
- );
836
+ this.db.exec(`
837
+ CREATE TABLE IF NOT EXISTS changelog_cursors (
838
+ peer_id TEXT NOT NULL,
839
+ context_graph_id TEXT NOT NULL,
840
+ era TEXT NOT NULL,
841
+ seq INTEGER NOT NULL CHECK (seq >= 0),
842
+ updated_at INTEGER NOT NULL,
843
+ PRIMARY KEY (peer_id, context_graph_id)
844
+ );
827
845
  `);
828
846
  }
829
847
  if (version < 24) {
@@ -832,39 +850,39 @@ export class DashboardDB {
832
850
  // `store.nq` restore does NOT roll back with the RDF store. node-ui.db
833
851
  // survives such a restore, so a rollback under the same era rotates the era
834
852
  // (forcing peers to full-resync instead of silently skipping). Single row.
835
- this.db.exec(`
836
- CREATE TABLE IF NOT EXISTS changelog_era (
837
- id INTEGER PRIMARY KEY CHECK (id = 1),
838
- era TEXT NOT NULL,
839
- high_seq INTEGER NOT NULL CHECK (high_seq >= 0),
840
- updated_at INTEGER NOT NULL
841
- );
853
+ this.db.exec(`
854
+ CREATE TABLE IF NOT EXISTS changelog_era (
855
+ id INTEGER PRIMARY KEY CHECK (id = 1),
856
+ era TEXT NOT NULL,
857
+ high_seq INTEGER NOT NULL CHECK (high_seq >= 0),
858
+ updated_at INTEGER NOT NULL
859
+ );
842
860
  `);
843
861
  }
844
862
  if (version < 25) {
845
863
  // Durable security audit for private-CG open enrollment. V25/V26 also
846
864
  // used this table as admission state; V27 projects those legacy rows into
847
865
  // the typed operational ledger below.
848
- this.db.exec(`
849
- CREATE TABLE IF NOT EXISTS context_graph_join_policy_audit (
850
- id INTEGER PRIMARY KEY AUTOINCREMENT,
851
- ts INTEGER NOT NULL,
852
- context_graph_id TEXT NOT NULL,
853
- event_type TEXT NOT NULL,
854
- actor TEXT,
855
- agent_address TEXT,
856
- outcome TEXT NOT NULL,
857
- reason TEXT,
858
- request_digest TEXT,
859
- policy_version INTEGER,
860
- details TEXT
861
- );
862
- CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_ts
863
- ON context_graph_join_policy_audit(ts);
864
- CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_cg_ts
865
- ON context_graph_join_policy_audit(context_graph_id, ts);
866
- CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_event_ts
867
- ON context_graph_join_policy_audit(event_type, ts);
866
+ this.db.exec(`
867
+ CREATE TABLE IF NOT EXISTS context_graph_join_policy_audit (
868
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
869
+ ts INTEGER NOT NULL,
870
+ context_graph_id TEXT NOT NULL,
871
+ event_type TEXT NOT NULL,
872
+ actor TEXT,
873
+ agent_address TEXT,
874
+ outcome TEXT NOT NULL,
875
+ reason TEXT,
876
+ request_digest TEXT,
877
+ policy_version INTEGER,
878
+ details TEXT
879
+ );
880
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_ts
881
+ ON context_graph_join_policy_audit(ts);
882
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_cg_ts
883
+ ON context_graph_join_policy_audit(context_graph_id, ts);
884
+ CREATE INDEX IF NOT EXISTS idx_cg_join_policy_audit_event_ts
885
+ ON context_graph_join_policy_audit(event_type, ts);
868
886
  `);
869
887
  }
870
888
  if (version < 26) {
@@ -883,108 +901,108 @@ export class DashboardDB {
883
901
  // Operational admission state is deliberately separate from the audit
884
902
  // stream. Audit retention and event naming must never alter rate-limit,
885
903
  // epoch, or commit-idempotency behaviour.
886
- this.db.exec(`
887
- CREATE TABLE IF NOT EXISTS context_graph_join_approval_ledger (
888
- context_graph_id TEXT NOT NULL,
889
- request_digest TEXT NOT NULL,
890
- policy_epoch INTEGER NOT NULL,
891
- reserved_at INTEGER NOT NULL,
892
- state TEXT NOT NULL CHECK (state IN ('reserved', 'committed')),
893
- committed_at INTEGER,
894
- actor TEXT NOT NULL,
895
- agent_address TEXT NOT NULL,
896
- policy_version INTEGER NOT NULL,
897
- PRIMARY KEY (context_graph_id, request_digest, policy_epoch),
898
- CHECK (length(context_graph_id) > 0),
899
- CHECK (length(request_digest) > 0),
900
- CHECK (typeof(policy_epoch) = 'integer'),
901
- CHECK (typeof(reserved_at) = 'integer'),
902
- CHECK (committed_at IS NULL OR typeof(committed_at) = 'integer'),
903
- CHECK (typeof(policy_version) = 'integer'),
904
- CHECK (
905
- (state = 'reserved' AND committed_at IS NULL)
906
- OR (state = 'committed' AND committed_at IS NOT NULL)
907
- )
908
- );
909
- CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_reserved_at
910
- ON context_graph_join_approval_ledger(reserved_at);
911
- CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_cg_reserved_at
912
- ON context_graph_join_approval_ledger(context_graph_id, reserved_at);
913
- CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_digest_reserved_at
914
- ON context_graph_join_approval_ledger(context_graph_id, request_digest, reserved_at DESC);
915
- CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_committed_at
916
- ON context_graph_join_approval_ledger(committed_at);
904
+ this.db.exec(`
905
+ CREATE TABLE IF NOT EXISTS context_graph_join_approval_ledger (
906
+ context_graph_id TEXT NOT NULL,
907
+ request_digest TEXT NOT NULL,
908
+ policy_epoch INTEGER NOT NULL,
909
+ reserved_at INTEGER NOT NULL,
910
+ state TEXT NOT NULL CHECK (state IN ('reserved', 'committed')),
911
+ committed_at INTEGER,
912
+ actor TEXT NOT NULL,
913
+ agent_address TEXT NOT NULL,
914
+ policy_version INTEGER NOT NULL,
915
+ PRIMARY KEY (context_graph_id, request_digest, policy_epoch),
916
+ CHECK (length(context_graph_id) > 0),
917
+ CHECK (length(request_digest) > 0),
918
+ CHECK (typeof(policy_epoch) = 'integer'),
919
+ CHECK (typeof(reserved_at) = 'integer'),
920
+ CHECK (committed_at IS NULL OR typeof(committed_at) = 'integer'),
921
+ CHECK (typeof(policy_version) = 'integer'),
922
+ CHECK (
923
+ (state = 'reserved' AND committed_at IS NULL)
924
+ OR (state = 'committed' AND committed_at IS NOT NULL)
925
+ )
926
+ );
927
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_reserved_at
928
+ ON context_graph_join_approval_ledger(reserved_at);
929
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_cg_reserved_at
930
+ ON context_graph_join_approval_ledger(context_graph_id, reserved_at);
931
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_digest_reserved_at
932
+ ON context_graph_join_approval_ledger(context_graph_id, request_digest, reserved_at DESC);
933
+ CREATE INDEX IF NOT EXISTS idx_cg_join_approval_ledger_committed_at
934
+ ON context_graph_join_approval_ledger(committed_at);
917
935
  `);
918
936
  // Best-effort one-time projection for V25/V26 databases. Invalid legacy
919
937
  // JSON cannot abort startup; only rows with a typed numeric epoch can be
920
938
  // authoritative operational state. For duplicate audit reservations,
921
939
  // retain the newest row for each (CG, digest, epoch).
922
- this.db.exec(`
923
- WITH raw_reservations AS (
924
- SELECT
925
- id,
926
- context_graph_id,
927
- request_digest,
928
- CASE
929
- WHEN json_valid(details)
930
- AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
931
- THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
932
- ELSE NULL
933
- END AS policy_epoch,
934
- ts AS reserved_at,
935
- COALESCE(actor, '') AS actor,
936
- COALESCE(agent_address, '') AS agent_address,
937
- COALESCE(policy_version, 1) AS policy_version
938
- FROM context_graph_join_policy_audit
939
- WHERE event_type = 'join_auto_reservation'
940
- AND outcome = 'reserved'
941
- AND request_digest IS NOT NULL
942
- ),
943
- ranked_reservations AS (
944
- SELECT *, ROW_NUMBER() OVER (
945
- PARTITION BY context_graph_id, request_digest, policy_epoch
946
- ORDER BY reserved_at DESC, id DESC
947
- ) AS rank
948
- FROM raw_reservations
949
- WHERE policy_epoch IS NOT NULL
950
- ),
951
- raw_commits AS (
952
- SELECT
953
- context_graph_id,
954
- request_digest,
955
- CASE
956
- WHEN json_valid(details)
957
- AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
958
- THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
959
- ELSE NULL
960
- END AS policy_epoch,
961
- MIN(ts) AS committed_at
962
- FROM context_graph_join_policy_audit
963
- WHERE event_type = 'join_admission_committed'
964
- AND outcome = 'approved'
965
- AND request_digest IS NOT NULL
966
- GROUP BY context_graph_id, request_digest, policy_epoch
967
- )
968
- INSERT OR IGNORE INTO context_graph_join_approval_ledger (
969
- context_graph_id, request_digest, policy_epoch, reserved_at,
970
- state, committed_at, actor, agent_address, policy_version
971
- )
972
- SELECT
973
- reservation.context_graph_id,
974
- reservation.request_digest,
975
- reservation.policy_epoch,
976
- reservation.reserved_at,
977
- CASE WHEN commit_row.committed_at IS NULL THEN 'reserved' ELSE 'committed' END,
978
- commit_row.committed_at,
979
- reservation.actor,
980
- reservation.agent_address,
981
- reservation.policy_version
982
- FROM ranked_reservations AS reservation
983
- LEFT JOIN raw_commits AS commit_row
984
- ON commit_row.context_graph_id = reservation.context_graph_id
985
- AND commit_row.request_digest = reservation.request_digest
986
- AND commit_row.policy_epoch = reservation.policy_epoch
987
- WHERE reservation.rank = 1;
940
+ this.db.exec(`
941
+ WITH raw_reservations AS (
942
+ SELECT
943
+ id,
944
+ context_graph_id,
945
+ request_digest,
946
+ CASE
947
+ WHEN json_valid(details)
948
+ AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
949
+ THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
950
+ ELSE NULL
951
+ END AS policy_epoch,
952
+ ts AS reserved_at,
953
+ COALESCE(actor, '') AS actor,
954
+ COALESCE(agent_address, '') AS agent_address,
955
+ COALESCE(policy_version, 1) AS policy_version
956
+ FROM context_graph_join_policy_audit
957
+ WHERE event_type = 'join_auto_reservation'
958
+ AND outcome = 'reserved'
959
+ AND request_digest IS NOT NULL
960
+ ),
961
+ ranked_reservations AS (
962
+ SELECT *, ROW_NUMBER() OVER (
963
+ PARTITION BY context_graph_id, request_digest, policy_epoch
964
+ ORDER BY reserved_at DESC, id DESC
965
+ ) AS rank
966
+ FROM raw_reservations
967
+ WHERE policy_epoch IS NOT NULL
968
+ ),
969
+ raw_commits AS (
970
+ SELECT
971
+ context_graph_id,
972
+ request_digest,
973
+ CASE
974
+ WHEN json_valid(details)
975
+ AND json_type(details, '$.policyEpoch') IN ('integer', 'real')
976
+ THEN CAST(json_extract(details, '$.policyEpoch') AS INTEGER)
977
+ ELSE NULL
978
+ END AS policy_epoch,
979
+ MIN(ts) AS committed_at
980
+ FROM context_graph_join_policy_audit
981
+ WHERE event_type = 'join_admission_committed'
982
+ AND outcome = 'approved'
983
+ AND request_digest IS NOT NULL
984
+ GROUP BY context_graph_id, request_digest, policy_epoch
985
+ )
986
+ INSERT OR IGNORE INTO context_graph_join_approval_ledger (
987
+ context_graph_id, request_digest, policy_epoch, reserved_at,
988
+ state, committed_at, actor, agent_address, policy_version
989
+ )
990
+ SELECT
991
+ reservation.context_graph_id,
992
+ reservation.request_digest,
993
+ reservation.policy_epoch,
994
+ reservation.reserved_at,
995
+ CASE WHEN commit_row.committed_at IS NULL THEN 'reserved' ELSE 'committed' END,
996
+ commit_row.committed_at,
997
+ reservation.actor,
998
+ reservation.agent_address,
999
+ reservation.policy_version
1000
+ FROM ranked_reservations AS reservation
1001
+ LEFT JOIN raw_commits AS commit_row
1002
+ ON commit_row.context_graph_id = reservation.context_graph_id
1003
+ AND commit_row.request_digest = reservation.request_digest
1004
+ AND commit_row.policy_epoch = reservation.policy_epoch
1005
+ WHERE reservation.rank = 1;
988
1006
  `);
989
1007
  // V26's trigger protected live reservation audit rows because they were
990
1008
  // operational state. The ledger makes that coupling obsolete, so replace
@@ -1002,46 +1020,81 @@ export class DashboardDB {
1002
1020
  // idempotent and keeps the audit bound fail-closed on every open.
1003
1021
  ensureJoinPolicyAuditCapTrigger();
1004
1022
  if (version < 29) {
1005
- this.db.exec(`
1006
- CREATE TABLE IF NOT EXISTS vm_reconcile_negative_cache (
1007
- cache_key TEXT PRIMARY KEY,
1008
- context_graph_id TEXT NOT NULL,
1009
- failures INTEGER NOT NULL,
1010
- next_retry_at INTEGER NOT NULL,
1011
- swm_gen TEXT NOT NULL,
1012
- candidate_namespaces TEXT NOT NULL,
1013
- peer_topology_key TEXT NOT NULL,
1014
- updated_at INTEGER NOT NULL
1015
- );
1016
- CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_cg
1017
- ON vm_reconcile_negative_cache(context_graph_id);
1018
- CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_retry
1019
- ON vm_reconcile_negative_cache(next_retry_at);
1023
+ this.db.exec(`
1024
+ CREATE TABLE IF NOT EXISTS vm_reconcile_negative_cache (
1025
+ cache_key TEXT PRIMARY KEY,
1026
+ context_graph_id TEXT NOT NULL,
1027
+ failures INTEGER NOT NULL,
1028
+ next_retry_at INTEGER NOT NULL,
1029
+ swm_gen TEXT NOT NULL,
1030
+ candidate_namespaces TEXT NOT NULL,
1031
+ peer_topology_key TEXT NOT NULL,
1032
+ updated_at INTEGER NOT NULL
1033
+ );
1034
+ CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_cg
1035
+ ON vm_reconcile_negative_cache(context_graph_id);
1036
+ CREATE INDEX IF NOT EXISTS idx_vm_reconcile_negative_retry
1037
+ ON vm_reconcile_negative_cache(next_retry_at);
1020
1038
  `);
1021
1039
  }
1022
1040
  if (version < 30) {
1023
1041
  // OFFSET pagination is scoped to an immutable responder row list. Keep
1024
1042
  // the opaque responder token beside the verified offset so a requester
1025
1043
  // restart can resume that same list instead of discarding durable work.
1026
- ensureSyncCheckpointResponderSessionColumns();
1044
+ ensureSyncCheckpointResumeColumns();
1027
1045
  }
1028
1046
  if (version < 31) {
1029
- this.db.exec(`
1030
- CREATE TABLE IF NOT EXISTS snapshot_page_indexes (
1031
- snapshot_digest TEXT PRIMARY KEY,
1032
- format_version INTEGER NOT NULL CHECK (format_version > 0),
1033
- stride INTEGER NOT NULL CHECK (stride > 0),
1034
- snapshot_file_size INTEGER NOT NULL CHECK (snapshot_file_size >= 0),
1035
- modification_fingerprint TEXT NOT NULL,
1036
- offset_count INTEGER NOT NULL CHECK (offset_count > 0),
1037
- offsets BLOB NOT NULL,
1038
- checksum TEXT NOT NULL,
1039
- CHECK (length(snapshot_digest) > 0),
1040
- CHECK (length(modification_fingerprint) > 0),
1041
- CHECK (length(offsets) = offset_count * 8),
1042
- CHECK (length(checksum) > 0)
1043
- );
1047
+ this.db.exec(`
1048
+ CREATE TABLE IF NOT EXISTS snapshot_page_indexes (
1049
+ snapshot_digest TEXT PRIMARY KEY,
1050
+ format_version INTEGER NOT NULL CHECK (format_version > 0),
1051
+ stride INTEGER NOT NULL CHECK (stride > 0),
1052
+ snapshot_file_size INTEGER NOT NULL CHECK (snapshot_file_size >= 0),
1053
+ modification_fingerprint TEXT NOT NULL,
1054
+ offset_count INTEGER NOT NULL CHECK (offset_count > 0),
1055
+ offsets BLOB NOT NULL,
1056
+ checksum TEXT NOT NULL,
1057
+ CHECK (length(snapshot_digest) > 0),
1058
+ CHECK (length(modification_fingerprint) > 0),
1059
+ CHECK (length(offsets) = offset_count * 8),
1060
+ CHECK (length(checksum) > 0)
1061
+ );
1044
1062
  `);
1063
+ }
1064
+ if (version < 32) {
1065
+ this.db.exec(`
1066
+ CREATE TABLE IF NOT EXISTS selected_vm_reconcile_cursors (
1067
+ deployment_id TEXT NOT NULL CHECK (length(trim(deployment_id)) > 0),
1068
+ context_graph_id TEXT NOT NULL,
1069
+ on_chain_context_graph_id TEXT NOT NULL,
1070
+ name_hash TEXT NOT NULL,
1071
+ watermark INTEGER NOT NULL CHECK (watermark >= 0),
1072
+ updated_at INTEGER NOT NULL,
1073
+ PRIMARY KEY (deployment_id, context_graph_id, on_chain_context_graph_id)
1074
+ );
1075
+ CREATE INDEX IF NOT EXISTS idx_selected_vm_reconcile_cursor_cg
1076
+ ON selected_vm_reconcile_cursors(deployment_id, context_graph_id);
1077
+ `);
1078
+ }
1079
+ if (version < 33) {
1080
+ // A durable DATA offset is reusable only with cryptographic proof of the
1081
+ // canonical META generation and verified descriptor prefix that gave it
1082
+ // meaning. Persist both digests beside the offset/session so production
1083
+ // SQLite checkpoints have the same safety contract as the in-memory
1084
+ // requester store and survive daemon restarts.
1085
+ ensureSyncCheckpointResumeColumns();
1086
+ }
1087
+ if (version < 34) {
1088
+ // The V33 manifest-bound continuation must not share a key with V32,
1089
+ // which interpreted OFFSET as an unverified raw responder coordinate.
1090
+ // Drop every pre-versioned durable DATA row before new code starts using
1091
+ // the `|checkpoint:v2` namespace, so a later rollback also fails closed.
1092
+ ensureSyncCheckpointResumeColumns();
1093
+ this.db.prepare(`
1094
+ DELETE FROM sync_checkpoints
1095
+ WHERE key LIKE '%|durable|data%'
1096
+ AND key NOT LIKE '%|durable|data|checkpoint:v2%'
1097
+ `).run();
1045
1098
  }
1046
1099
  this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
1047
1100
  if (upgradedExistingDb && !this.explicitRetentionDays) {
@@ -1080,10 +1133,10 @@ export class DashboardDB {
1080
1133
  // not depend on whether the corresponding audit rows still exist. Preserve
1081
1134
  // the historical retention envelope for durable commit idempotency while
1082
1135
  // allowing fully expired reservation/commit state to age out.
1083
- this.db.prepare(`
1084
- DELETE FROM context_graph_join_approval_ledger
1085
- WHERE reserved_at < ?
1086
- AND (committed_at IS NULL OR committed_at < ?)
1136
+ this.db.prepare(`
1137
+ DELETE FROM context_graph_join_approval_ledger
1138
+ WHERE reserved_at < ?
1139
+ AND (committed_at IS NULL OR committed_at < ?)
1087
1140
  `).run(cutoff, cutoff);
1088
1141
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE expires_at < ?`).run(Date.now());
1089
1142
  // Reconcile negatives are accelerators, never historical records. Once the
@@ -1136,16 +1189,16 @@ export class DashboardDB {
1136
1189
  : 'done',
1137
1190
  };
1138
1191
  }
1139
- const deleted = this.db.prepare(`
1140
- DELETE FROM logs
1141
- WHERE id IN (
1142
- SELECT id
1143
- FROM logs
1144
- WHERE id <= @cutoff
1145
- AND level NOT IN ('warn', 'error')
1146
- ORDER BY id ASC
1147
- LIMIT @batchRows
1148
- )
1192
+ const deleted = this.db.prepare(`
1193
+ DELETE FROM logs
1194
+ WHERE id IN (
1195
+ SELECT id
1196
+ FROM logs
1197
+ WHERE id <= @cutoff
1198
+ AND level NOT IN ('warn', 'error')
1199
+ ORDER BY id ASC
1200
+ LIMIT @batchRows
1201
+ )
1149
1202
  `).run({
1150
1203
  cutoff: overflowCutoff,
1151
1204
  batchRows: this.logVolumePruneBatchRows,
@@ -1171,12 +1224,12 @@ export class DashboardDB {
1171
1224
  };
1172
1225
  }
1173
1226
  routineLogOverflowCutoff() {
1174
- const row = this.db.prepare(`
1175
- SELECT id
1176
- FROM logs
1177
- WHERE level NOT IN ('warn', 'error')
1178
- ORDER BY id DESC
1179
- LIMIT 1 OFFSET ?
1227
+ const row = this.db.prepare(`
1228
+ SELECT id
1229
+ FROM logs
1230
+ WHERE level NOT IN ('warn', 'error')
1231
+ ORDER BY id DESC
1232
+ LIMIT 1 OFFSET ?
1180
1233
  `).get(this.routineLogRowCap);
1181
1234
  return row?.id ?? null;
1182
1235
  }
@@ -1277,50 +1330,50 @@ export class DashboardDB {
1277
1330
  }
1278
1331
  // --- Metric snapshots ---
1279
1332
  insertSnapshot(snap) {
1280
- this.stmt('insertSnapshot', `
1281
- INSERT INTO metric_snapshots (
1282
- ts, cpu_percent, mem_used_bytes, mem_total_bytes,
1283
- disk_used_bytes, disk_total_bytes, heap_used_bytes, uptime_seconds,
1284
- peer_count, direct_peers, relayed_peers, mesh_peers, contextGraph_count,
1285
- total_triples, total_kcs, total_kas, store_bytes,
1286
- confirmed_kcs, tentative_kcs, rpc_latency_ms, rpc_healthy,
1287
- relay_capacity, relay_reservation_count, relay_active_circuits,
1288
- relay_bytes_in, relay_bytes_out
1289
- ) VALUES (
1290
- @ts, @cpu_percent, @mem_used_bytes, @mem_total_bytes,
1291
- @disk_used_bytes, @disk_total_bytes, @heap_used_bytes, @uptime_seconds,
1292
- @peer_count, @direct_peers, @relayed_peers, @mesh_peers, @contextGraph_count,
1293
- @total_triples, @total_kcs, @total_kas, @store_bytes,
1294
- @confirmed_kcs, @tentative_kcs, @rpc_latency_ms, @rpc_healthy,
1295
- @relay_capacity, @relay_reservation_count, @relay_active_circuits,
1296
- @relay_bytes_in, @relay_bytes_out
1297
- )
1333
+ this.stmt('insertSnapshot', `
1334
+ INSERT INTO metric_snapshots (
1335
+ ts, cpu_percent, mem_used_bytes, mem_total_bytes,
1336
+ disk_used_bytes, disk_total_bytes, heap_used_bytes, uptime_seconds,
1337
+ peer_count, direct_peers, relayed_peers, mesh_peers, contextGraph_count,
1338
+ total_triples, total_kcs, total_kas, store_bytes,
1339
+ confirmed_kcs, tentative_kcs, rpc_latency_ms, rpc_healthy,
1340
+ relay_capacity, relay_reservation_count, relay_active_circuits,
1341
+ relay_bytes_in, relay_bytes_out
1342
+ ) VALUES (
1343
+ @ts, @cpu_percent, @mem_used_bytes, @mem_total_bytes,
1344
+ @disk_used_bytes, @disk_total_bytes, @heap_used_bytes, @uptime_seconds,
1345
+ @peer_count, @direct_peers, @relayed_peers, @mesh_peers, @contextGraph_count,
1346
+ @total_triples, @total_kcs, @total_kas, @store_bytes,
1347
+ @confirmed_kcs, @tentative_kcs, @rpc_latency_ms, @rpc_healthy,
1348
+ @relay_capacity, @relay_reservation_count, @relay_active_circuits,
1349
+ @relay_bytes_in, @relay_bytes_out
1350
+ )
1298
1351
  `).run(snap);
1299
1352
  }
1300
1353
  getLatestSnapshot() {
1301
1354
  return this.db.prepare('SELECT * FROM metric_snapshots ORDER BY ts DESC LIMIT 1').get();
1302
1355
  }
1303
1356
  upsertContextGraphSubscription(record) {
1304
- this.stmt('upsertContextGraphSubscription', `
1305
- INSERT INTO context_graph_subscriptions (
1306
- context_graph_id, name, subscribed, synced, shared_memory_synced, meta_synced,
1307
- on_chain_id, on_chain_hash, last_reconciled_ordinal, core_hosted, sync_scoped, updated_at
1308
- ) VALUES (
1309
- @context_graph_id, @name, @subscribed, @synced, @shared_memory_synced, @meta_synced,
1310
- @on_chain_id, @on_chain_hash, @last_reconciled_ordinal, @core_hosted, @sync_scoped, @updated_at
1311
- )
1312
- ON CONFLICT(context_graph_id) DO UPDATE SET
1313
- name = excluded.name,
1314
- subscribed = excluded.subscribed,
1315
- synced = excluded.synced,
1316
- shared_memory_synced = excluded.shared_memory_synced,
1317
- meta_synced = excluded.meta_synced,
1318
- on_chain_id = excluded.on_chain_id,
1319
- on_chain_hash = excluded.on_chain_hash,
1320
- last_reconciled_ordinal = excluded.last_reconciled_ordinal,
1321
- core_hosted = excluded.core_hosted,
1322
- sync_scoped = excluded.sync_scoped,
1323
- updated_at = excluded.updated_at
1357
+ this.stmt('upsertContextGraphSubscription', `
1358
+ INSERT INTO context_graph_subscriptions (
1359
+ context_graph_id, name, subscribed, synced, shared_memory_synced, meta_synced,
1360
+ on_chain_id, on_chain_hash, last_reconciled_ordinal, core_hosted, sync_scoped, updated_at
1361
+ ) VALUES (
1362
+ @context_graph_id, @name, @subscribed, @synced, @shared_memory_synced, @meta_synced,
1363
+ @on_chain_id, @on_chain_hash, @last_reconciled_ordinal, @core_hosted, @sync_scoped, @updated_at
1364
+ )
1365
+ ON CONFLICT(context_graph_id) DO UPDATE SET
1366
+ name = excluded.name,
1367
+ subscribed = excluded.subscribed,
1368
+ synced = excluded.synced,
1369
+ shared_memory_synced = excluded.shared_memory_synced,
1370
+ meta_synced = excluded.meta_synced,
1371
+ on_chain_id = excluded.on_chain_id,
1372
+ on_chain_hash = excluded.on_chain_hash,
1373
+ last_reconciled_ordinal = excluded.last_reconciled_ordinal,
1374
+ core_hosted = excluded.core_hosted,
1375
+ sync_scoped = excluded.sync_scoped,
1376
+ updated_at = excluded.updated_at
1324
1377
  `).run({
1325
1378
  context_graph_id: record.context_graph_id,
1326
1379
  name: record.name ?? null,
@@ -1418,37 +1471,37 @@ export class DashboardDB {
1418
1471
  transition();
1419
1472
  }
1420
1473
  appendContextGraphJoinPolicyAudit(event) {
1421
- this.stmt('appendContextGraphJoinPolicyAudit', `
1422
- INSERT INTO context_graph_join_policy_audit (
1423
- ts, context_graph_id, event_type, actor, agent_address, outcome,
1424
- reason, request_digest, policy_version, details
1425
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1474
+ this.stmt('appendContextGraphJoinPolicyAudit', `
1475
+ INSERT INTO context_graph_join_policy_audit (
1476
+ ts, context_graph_id, event_type, actor, agent_address, outcome,
1477
+ reason, request_digest, policy_version, details
1478
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1426
1479
  `).run(event.timestamp, event.contextGraphId, event.eventType, event.actor ?? null, event.agentAddress ?? null, event.outcome, event.reason ?? null, event.requestDigest ?? null, event.policyVersion ?? null, event.details ? JSON.stringify(event.details) : null);
1427
1480
  }
1428
1481
  reserveContextGraphAutomaticApproval(input) {
1429
1482
  const reserve = this.db.transaction(() => {
1430
1483
  const since = input.timestamp - 60 * 60 * 1000;
1431
- const cgRow = this.stmt('countContextGraphAutomaticApprovalReservations', `
1432
- SELECT COUNT(*) AS count
1433
- FROM context_graph_join_approval_ledger
1434
- WHERE context_graph_id = ?
1435
- AND reserved_at >= ?
1484
+ const cgRow = this.stmt('countContextGraphAutomaticApprovalReservations', `
1485
+ SELECT COUNT(*) AS count
1486
+ FROM context_graph_join_approval_ledger
1487
+ WHERE context_graph_id = ?
1488
+ AND reserved_at >= ?
1436
1489
  `).get(input.contextGraphId, since);
1437
- const nodeRow = this.stmt('countNodeAutomaticApprovalReservations', `
1438
- SELECT COUNT(*) AS count
1439
- FROM context_graph_join_approval_ledger
1440
- WHERE reserved_at >= ?
1490
+ const nodeRow = this.stmt('countNodeAutomaticApprovalReservations', `
1491
+ SELECT COUNT(*) AS count
1492
+ FROM context_graph_join_approval_ledger
1493
+ WHERE reserved_at >= ?
1441
1494
  `).get(since);
1442
1495
  const contextGraphCount = Number(cgRow?.count ?? 0);
1443
1496
  const nodeCount = Number(nodeRow?.count ?? 0);
1444
- const existing = this.stmt('findContextGraphAutomaticApprovalReservation', `
1445
- SELECT 1 AS found
1446
- FROM context_graph_join_approval_ledger
1447
- WHERE context_graph_id = ?
1448
- AND request_digest = ?
1449
- AND policy_epoch = ?
1450
- AND reserved_at >= ?
1451
- LIMIT 1
1497
+ const existing = this.stmt('findContextGraphAutomaticApprovalReservation', `
1498
+ SELECT 1 AS found
1499
+ FROM context_graph_join_approval_ledger
1500
+ WHERE context_graph_id = ?
1501
+ AND request_digest = ?
1502
+ AND policy_epoch = ?
1503
+ AND reserved_at >= ?
1504
+ LIMIT 1
1452
1505
  `).get(input.contextGraphId, input.requestDigest, input.policyEpoch, since);
1453
1506
  if (existing) {
1454
1507
  return {
@@ -1478,16 +1531,16 @@ export class DashboardDB {
1478
1531
  // reservation without durable operational state. An expired replay of
1479
1532
  // the same digest+epoch refreshes its reservation timestamp and consumes
1480
1533
  // the rolling-window quota again, matching the former append-only model.
1481
- this.stmt('upsertContextGraphAutomaticApprovalReservation', `
1482
- INSERT INTO context_graph_join_approval_ledger (
1483
- context_graph_id, request_digest, policy_epoch, reserved_at,
1484
- state, committed_at, actor, agent_address, policy_version
1485
- ) VALUES (?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)
1486
- ON CONFLICT(context_graph_id, request_digest, policy_epoch) DO UPDATE SET
1487
- reserved_at = excluded.reserved_at,
1488
- actor = excluded.actor,
1489
- agent_address = excluded.agent_address,
1490
- policy_version = excluded.policy_version
1534
+ this.stmt('upsertContextGraphAutomaticApprovalReservation', `
1535
+ INSERT INTO context_graph_join_approval_ledger (
1536
+ context_graph_id, request_digest, policy_epoch, reserved_at,
1537
+ state, committed_at, actor, agent_address, policy_version
1538
+ ) VALUES (?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)
1539
+ ON CONFLICT(context_graph_id, request_digest, policy_epoch) DO UPDATE SET
1540
+ reserved_at = excluded.reserved_at,
1541
+ actor = excluded.actor,
1542
+ agent_address = excluded.agent_address,
1543
+ policy_version = excluded.policy_version
1491
1544
  `).run(input.contextGraphId, input.requestDigest, input.policyEpoch, input.timestamp, input.actor, input.agentAddress, input.policyVersion);
1492
1545
  this.appendContextGraphJoinPolicyAudit({
1493
1546
  timestamp: input.timestamp,
@@ -1513,26 +1566,26 @@ export class DashboardDB {
1513
1566
  return reserve();
1514
1567
  }
1515
1568
  markContextGraphAutomaticApprovalRepairPending(input) {
1516
- const marked = this.stmt('markContextGraphAutomaticApprovalRepairPending', `
1517
- UPDATE context_graph_join_approval_ledger
1518
- SET repair_pending = 1
1519
- WHERE context_graph_id = ?
1520
- AND request_digest = ?
1521
- AND policy_epoch = ?
1522
- AND state = 'reserved'
1569
+ const marked = this.stmt('markContextGraphAutomaticApprovalRepairPending', `
1570
+ UPDATE context_graph_join_approval_ledger
1571
+ SET repair_pending = 1
1572
+ WHERE context_graph_id = ?
1573
+ AND request_digest = ?
1574
+ AND policy_epoch = ?
1575
+ AND state = 'reserved'
1523
1576
  `).run(input.contextGraphId, input.requestDigest, input.policyEpoch);
1524
1577
  return marked.changes === 1;
1525
1578
  }
1526
1579
  getContextGraphAutomaticApprovalRepair(contextGraphId, requestDigest) {
1527
- const repair = this.stmt('getContextGraphAutomaticApprovalRepair', `
1528
- SELECT policy_epoch, actor, agent_address
1529
- FROM context_graph_join_approval_ledger
1530
- WHERE context_graph_id = ?
1531
- AND request_digest = ?
1532
- AND state = 'reserved'
1533
- AND repair_pending = 1
1534
- ORDER BY reserved_at DESC
1535
- LIMIT 1
1580
+ const repair = this.stmt('getContextGraphAutomaticApprovalRepair', `
1581
+ SELECT policy_epoch, actor, agent_address
1582
+ FROM context_graph_join_approval_ledger
1583
+ WHERE context_graph_id = ?
1584
+ AND request_digest = ?
1585
+ AND state = 'reserved'
1586
+ AND repair_pending = 1
1587
+ ORDER BY reserved_at DESC
1588
+ LIMIT 1
1536
1589
  `).get(contextGraphId, requestDigest);
1537
1590
  return repair
1538
1591
  ? {
@@ -1544,26 +1597,26 @@ export class DashboardDB {
1544
1597
  }
1545
1598
  commitContextGraphAutomaticApproval(input) {
1546
1599
  const commit = this.db.transaction(() => {
1547
- const reservation = this.stmt('findExactContextGraphAutomaticApprovalReservation', `
1548
- SELECT context_graph_id, request_digest, policy_epoch, reserved_at,
1549
- state, committed_at, actor, agent_address, policy_version
1550
- FROM context_graph_join_approval_ledger
1551
- WHERE context_graph_id = ?
1552
- AND request_digest = ?
1553
- AND policy_epoch = ?
1554
- LIMIT 1
1600
+ const reservation = this.stmt('findExactContextGraphAutomaticApprovalReservation', `
1601
+ SELECT context_graph_id, request_digest, policy_epoch, reserved_at,
1602
+ state, committed_at, actor, agent_address, policy_version
1603
+ FROM context_graph_join_approval_ledger
1604
+ WHERE context_graph_id = ?
1605
+ AND request_digest = ?
1606
+ AND policy_epoch = ?
1607
+ LIMIT 1
1555
1608
  `).get(input.contextGraphId, input.requestDigest, input.policyEpoch);
1556
1609
  if (!reservation)
1557
1610
  return false;
1558
1611
  if (reservation.state === 'committed')
1559
1612
  return true;
1560
- const updated = this.stmt('commitContextGraphAutomaticApprovalReservation', `
1561
- UPDATE context_graph_join_approval_ledger
1562
- SET state = 'committed', committed_at = ?, repair_pending = 0
1563
- WHERE context_graph_id = ?
1564
- AND request_digest = ?
1565
- AND policy_epoch = ?
1566
- AND state = 'reserved'
1613
+ const updated = this.stmt('commitContextGraphAutomaticApprovalReservation', `
1614
+ UPDATE context_graph_join_approval_ledger
1615
+ SET state = 'committed', committed_at = ?, repair_pending = 0
1616
+ WHERE context_graph_id = ?
1617
+ AND request_digest = ?
1618
+ AND policy_epoch = ?
1619
+ AND state = 'reserved'
1567
1620
  `).run(input.timestamp, reservation.context_graph_id, reservation.request_digest, reservation.policy_epoch);
1568
1621
  if (updated.changes !== 1)
1569
1622
  return false;
@@ -1587,48 +1640,48 @@ export class DashboardDB {
1587
1640
  }
1588
1641
  getContextGraphAutomaticApprovalUsage(contextGraphId, timestamp) {
1589
1642
  const since = timestamp - 60 * 60 * 1000;
1590
- const contextGraphApprovalsLastHour = Number(this.stmt('getContextGraphAutomaticApprovalUsage', `
1591
- SELECT COUNT(*) AS count
1592
- FROM context_graph_join_approval_ledger
1593
- WHERE context_graph_id = ?
1594
- AND reserved_at >= ?
1643
+ const contextGraphApprovalsLastHour = Number(this.stmt('getContextGraphAutomaticApprovalUsage', `
1644
+ SELECT COUNT(*) AS count
1645
+ FROM context_graph_join_approval_ledger
1646
+ WHERE context_graph_id = ?
1647
+ AND reserved_at >= ?
1595
1648
  `).get(contextGraphId, since)?.count ?? 0);
1596
- const nodeApprovalsLastHour = Number(this.stmt('getNodeAutomaticApprovalUsage', `
1597
- SELECT COUNT(*) AS count
1598
- FROM context_graph_join_approval_ledger
1599
- WHERE reserved_at >= ?
1649
+ const nodeApprovalsLastHour = Number(this.stmt('getNodeAutomaticApprovalUsage', `
1650
+ SELECT COUNT(*) AS count
1651
+ FROM context_graph_join_approval_ledger
1652
+ WHERE reserved_at >= ?
1600
1653
  `).get(since)?.count ?? 0);
1601
1654
  return { contextGraphApprovalsLastHour, nodeApprovalsLastHour };
1602
1655
  }
1603
1656
  listContextGraphJoinPolicyAudit(contextGraphId) {
1604
- return this.stmt('listContextGraphJoinPolicyAudit', `
1605
- SELECT id, ts, context_graph_id, event_type, actor, agent_address,
1606
- outcome, reason, request_digest, policy_version, details
1607
- FROM context_graph_join_policy_audit
1608
- WHERE context_graph_id = ?
1609
- ORDER BY id ASC
1657
+ return this.stmt('listContextGraphJoinPolicyAudit', `
1658
+ SELECT id, ts, context_graph_id, event_type, actor, agent_address,
1659
+ outcome, reason, request_digest, policy_version, details
1660
+ FROM context_graph_join_policy_audit
1661
+ WHERE context_graph_id = ?
1662
+ ORDER BY id ASC
1610
1663
  `).all(contextGraphId);
1611
1664
  }
1612
1665
  contextGraphJoinPolicyKey(contextGraphId) {
1613
1666
  return `contextGraphJoinPolicy:${contextGraphId}`;
1614
1667
  }
1615
1668
  upsertVmReconcileNegative(record) {
1616
- this.stmt('upsertVmReconcileNegative', `
1617
- INSERT INTO vm_reconcile_negative_cache (
1618
- cache_key, context_graph_id, failures, next_retry_at, swm_gen,
1619
- candidate_namespaces, peer_topology_key, updated_at
1620
- ) VALUES (
1621
- @cache_key, @context_graph_id, @failures, @next_retry_at, @swm_gen,
1622
- @candidate_namespaces, @peer_topology_key, @updated_at
1623
- )
1624
- ON CONFLICT(cache_key) DO UPDATE SET
1625
- context_graph_id = excluded.context_graph_id,
1626
- failures = excluded.failures,
1627
- next_retry_at = excluded.next_retry_at,
1628
- swm_gen = excluded.swm_gen,
1629
- candidate_namespaces = excluded.candidate_namespaces,
1630
- peer_topology_key = excluded.peer_topology_key,
1631
- updated_at = excluded.updated_at
1669
+ this.stmt('upsertVmReconcileNegative', `
1670
+ INSERT INTO vm_reconcile_negative_cache (
1671
+ cache_key, context_graph_id, failures, next_retry_at, swm_gen,
1672
+ candidate_namespaces, peer_topology_key, updated_at
1673
+ ) VALUES (
1674
+ @cache_key, @context_graph_id, @failures, @next_retry_at, @swm_gen,
1675
+ @candidate_namespaces, @peer_topology_key, @updated_at
1676
+ )
1677
+ ON CONFLICT(cache_key) DO UPDATE SET
1678
+ context_graph_id = excluded.context_graph_id,
1679
+ failures = excluded.failures,
1680
+ next_retry_at = excluded.next_retry_at,
1681
+ swm_gen = excluded.swm_gen,
1682
+ candidate_namespaces = excluded.candidate_namespaces,
1683
+ peer_topology_key = excluded.peer_topology_key,
1684
+ updated_at = excluded.updated_at
1632
1685
  `).run(record);
1633
1686
  }
1634
1687
  getVmReconcileNegative(cacheKey) {
@@ -1640,6 +1693,29 @@ export class DashboardDB {
1640
1693
  deleteVmReconcileNegativesForContextGraph(contextGraphId) {
1641
1694
  this.stmt('deleteVmReconcileNegativesForContextGraph', 'DELETE FROM vm_reconcile_negative_cache WHERE context_graph_id = ?').run(contextGraphId);
1642
1695
  }
1696
+ upsertSelectedVmReconcileCursor(record) {
1697
+ this.stmt('upsertSelectedVmReconcileCursor', `
1698
+ INSERT INTO selected_vm_reconcile_cursors (
1699
+ deployment_id, context_graph_id, on_chain_context_graph_id,
1700
+ name_hash, watermark, updated_at
1701
+ ) VALUES (
1702
+ @deployment_id, @context_graph_id, @on_chain_context_graph_id,
1703
+ @name_hash, @watermark, @updated_at
1704
+ )
1705
+ ON CONFLICT(deployment_id, context_graph_id, on_chain_context_graph_id) DO UPDATE SET
1706
+ name_hash = excluded.name_hash,
1707
+ watermark = excluded.watermark,
1708
+ updated_at = excluded.updated_at
1709
+ `).run(record);
1710
+ }
1711
+ getSelectedVmReconcileCursor(deploymentId, contextGraphId, onChainContextGraphId) {
1712
+ return this.stmt('getSelectedVmReconcileCursor', `
1713
+ SELECT * FROM selected_vm_reconcile_cursors
1714
+ WHERE deployment_id = ?
1715
+ AND context_graph_id = ?
1716
+ AND on_chain_context_graph_id = ?
1717
+ `).get(deploymentId, contextGraphId, onChainContextGraphId);
1718
+ }
1643
1719
  // --- Phase F: chain-driven VM reconciliation telemetry ---
1644
1720
  /**
1645
1721
  * Persist one {@link ReplicationEvent} (best-effort sink target). `ordinal`,
@@ -1647,14 +1723,14 @@ export class DashboardDB {
1647
1723
  * coerced defensively so a malformed event can never abort the insert.
1648
1724
  */
1649
1725
  insertReplicationEvent(ev) {
1650
- this.stmt('insertReplicationEvent', `
1651
- INSERT INTO replication_events (
1652
- ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1653
- from_watermark, to_watermark, head, reconciled, pending, detail
1654
- ) VALUES (
1655
- @ts, @context_graph_id, @on_chain_cg_id, @action, @ual, @ordinal, @ka_id,
1656
- @from_watermark, @to_watermark, @head, @reconciled, @pending, @detail
1657
- )
1726
+ this.stmt('insertReplicationEvent', `
1727
+ INSERT INTO replication_events (
1728
+ ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1729
+ from_watermark, to_watermark, head, reconciled, pending, detail
1730
+ ) VALUES (
1731
+ @ts, @context_graph_id, @on_chain_cg_id, @action, @ual, @ordinal, @ka_id,
1732
+ @from_watermark, @to_watermark, @head, @reconciled, @pending, @detail
1733
+ )
1658
1734
  `).run({
1659
1735
  ts: ev.ts,
1660
1736
  context_graph_id: ev.context_graph_id,
@@ -1701,15 +1777,15 @@ export class DashboardDB {
1701
1777
  // and inflate the reported latency. fetch/promote for one version always
1702
1778
  // share the same ordinal, so this pairs them exactly; a fast-path promote
1703
1779
  // with no fetch for its ordinal correctly contributes a 0ms latency.
1704
- const latencyRows = this.db.prepare(`
1705
- SELECT p.ts AS promote_ts,
1706
- (SELECT MAX(f.ts) FROM replication_events f
1707
- WHERE f.action = 'fetch'
1708
- AND f.context_graph_id = p.context_graph_id
1709
- AND f.ordinal = p.ordinal
1710
- AND f.ts <= p.ts AND f.ts >= ?) AS fetch_ts
1711
- FROM replication_events p
1712
- WHERE p.action = 'promote' AND p.ts >= ? AND p.ordinal IS NOT NULL
1780
+ const latencyRows = this.db.prepare(`
1781
+ SELECT p.ts AS promote_ts,
1782
+ (SELECT MAX(f.ts) FROM replication_events f
1783
+ WHERE f.action = 'fetch'
1784
+ AND f.context_graph_id = p.context_graph_id
1785
+ AND f.ordinal = p.ordinal
1786
+ AND f.ts <= p.ts AND f.ts >= ?) AS fetch_ts
1787
+ FROM replication_events p
1788
+ WHERE p.action = 'promote' AND p.ts >= ? AND p.ordinal IS NOT NULL
1713
1789
  `).all(cutoff, cutoff);
1714
1790
  const latencies = latencyRows
1715
1791
  .map((r) => (r.fetch_ts != null ? r.promote_ts - r.fetch_ts : 0))
@@ -1739,20 +1815,20 @@ export class DashboardDB {
1739
1815
  }
1740
1816
  _computeReplicationPerCg(periodMs) {
1741
1817
  const cutoff = Date.now() - periodMs;
1742
- return this.db.prepare(`
1743
- SELECT context_graph_id,
1744
- on_chain_cg_id,
1745
- SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1746
- SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1747
- SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1748
- SUM(CASE WHEN action = 'cursor-advance' THEN 1 ELSE 0 END) AS cursor_advances,
1749
- MAX(to_watermark) AS last_watermark,
1750
- MAX(head) AS last_head,
1751
- MAX(ts) AS last_event_ts
1752
- FROM replication_events
1753
- WHERE ts >= ?
1754
- GROUP BY context_graph_id, on_chain_cg_id
1755
- ORDER BY last_event_ts DESC
1818
+ return this.db.prepare(`
1819
+ SELECT context_graph_id,
1820
+ on_chain_cg_id,
1821
+ SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1822
+ SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1823
+ SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1824
+ SUM(CASE WHEN action = 'cursor-advance' THEN 1 ELSE 0 END) AS cursor_advances,
1825
+ MAX(to_watermark) AS last_watermark,
1826
+ MAX(head) AS last_head,
1827
+ MAX(ts) AS last_event_ts
1828
+ FROM replication_events
1829
+ WHERE ts >= ?
1830
+ GROUP BY context_graph_id, on_chain_cg_id
1831
+ ORDER BY last_event_ts DESC
1756
1832
  `).all(cutoff);
1757
1833
  }
1758
1834
  /**
@@ -1771,16 +1847,16 @@ export class DashboardDB {
1771
1847
  const cutoff = Date.now() - opts.periodMs;
1772
1848
  const bucket = Math.max(1, Math.floor(opts.bucketMs));
1773
1849
  const where = opts.contextGraphId ? 'AND context_graph_id = @cg' : '';
1774
- return this.db.prepare(`
1775
- SELECT (ts / @bucket) * @bucket AS bucket,
1776
- SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1777
- SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1778
- SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1779
- COUNT(*) AS total
1780
- FROM replication_events
1781
- WHERE ts >= @cutoff ${where}
1782
- GROUP BY bucket
1783
- ORDER BY bucket ASC
1850
+ return this.db.prepare(`
1851
+ SELECT (ts / @bucket) * @bucket AS bucket,
1852
+ SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1853
+ SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1854
+ SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1855
+ COUNT(*) AS total
1856
+ FROM replication_events
1857
+ WHERE ts >= @cutoff ${where}
1858
+ GROUP BY bucket
1859
+ ORDER BY bucket ASC
1784
1860
  `).all({ cutoff, bucket, cg: opts.contextGraphId ?? null });
1785
1861
  }
1786
1862
  /**
@@ -1792,13 +1868,13 @@ export class DashboardDB {
1792
1868
  const safeLimit = Number.isFinite(limit)
1793
1869
  ? Math.max(1, Math.min(1000, Math.trunc(limit)))
1794
1870
  : 100;
1795
- return this.db.prepare(`
1796
- SELECT ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1797
- from_watermark, to_watermark, head, reconciled, pending, detail
1798
- FROM replication_events
1799
- WHERE context_graph_id = ?
1800
- ORDER BY ts DESC
1801
- LIMIT ?
1871
+ return this.db.prepare(`
1872
+ SELECT ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1873
+ from_watermark, to_watermark, head, reconciled, pending, detail
1874
+ FROM replication_events
1875
+ WHERE context_graph_id = ?
1876
+ ORDER BY ts DESC
1877
+ LIMIT ?
1802
1878
  `).all(contextGraphId, safeLimit);
1803
1879
  }
1804
1880
  /**
@@ -1807,38 +1883,38 @@ export class DashboardDB {
1807
1883
  * on-disk truth the Replication tab's cursor inspector renders.
1808
1884
  */
1809
1885
  getReplicationCursors() {
1810
- return this.db.prepare(`
1811
- SELECT s.context_graph_id AS context_graph_id,
1812
- s.on_chain_id AS on_chain_id,
1813
- s.last_reconciled_ordinal AS last_reconciled_ordinal,
1814
- s.core_hosted AS core_hosted,
1815
- s.subscribed AS subscribed,
1816
- (SELECT MAX(head) FROM replication_events e
1817
- WHERE e.context_graph_id = s.context_graph_id) AS last_head,
1818
- (SELECT MAX(ts) FROM replication_events e
1819
- WHERE e.context_graph_id = s.context_graph_id) AS last_event_ts
1820
- FROM context_graph_subscriptions s
1821
- ORDER BY s.context_graph_id ASC
1886
+ return this.db.prepare(`
1887
+ SELECT s.context_graph_id AS context_graph_id,
1888
+ s.on_chain_id AS on_chain_id,
1889
+ s.last_reconciled_ordinal AS last_reconciled_ordinal,
1890
+ s.core_hosted AS core_hosted,
1891
+ s.subscribed AS subscribed,
1892
+ (SELECT MAX(head) FROM replication_events e
1893
+ WHERE e.context_graph_id = s.context_graph_id) AS last_head,
1894
+ (SELECT MAX(ts) FROM replication_events e
1895
+ WHERE e.context_graph_id = s.context_graph_id) AS last_event_ts
1896
+ FROM context_graph_subscriptions s
1897
+ ORDER BY s.context_graph_id ASC
1822
1898
  `).all();
1823
1899
  }
1824
1900
  upsertContextGraphMember(record) {
1825
1901
  const firstSeenAt = record.first_seen_at ?? record.updated_at;
1826
- this.stmt('upsertContextGraphMember', `
1827
- INSERT INTO context_graph_memberships (
1828
- context_graph_id, principal_type, principal_id, role, status, source,
1829
- display_name, metadata, first_seen_at, updated_at
1830
- ) VALUES (
1831
- @context_graph_id, @principal_type, @principal_id, @role, @status, @source,
1832
- @display_name, @metadata, @first_seen_at, @updated_at
1833
- )
1834
- ON CONFLICT(context_graph_id, principal_type, principal_id) DO UPDATE SET
1835
- role = excluded.role,
1836
- status = excluded.status,
1837
- source = excluded.source,
1838
- display_name = excluded.display_name,
1839
- metadata = excluded.metadata,
1840
- first_seen_at = context_graph_memberships.first_seen_at,
1841
- updated_at = excluded.updated_at
1902
+ this.stmt('upsertContextGraphMember', `
1903
+ INSERT INTO context_graph_memberships (
1904
+ context_graph_id, principal_type, principal_id, role, status, source,
1905
+ display_name, metadata, first_seen_at, updated_at
1906
+ ) VALUES (
1907
+ @context_graph_id, @principal_type, @principal_id, @role, @status, @source,
1908
+ @display_name, @metadata, @first_seen_at, @updated_at
1909
+ )
1910
+ ON CONFLICT(context_graph_id, principal_type, principal_id) DO UPDATE SET
1911
+ role = excluded.role,
1912
+ status = excluded.status,
1913
+ source = excluded.source,
1914
+ display_name = excluded.display_name,
1915
+ metadata = excluded.metadata,
1916
+ first_seen_at = context_graph_memberships.first_seen_at,
1917
+ updated_at = excluded.updated_at
1842
1918
  `).run({
1843
1919
  context_graph_id: record.context_graph_id,
1844
1920
  principal_type: record.principal_type,
@@ -1854,15 +1930,15 @@ export class DashboardDB {
1854
1930
  }
1855
1931
  listContextGraphMembers(contextGraphId) {
1856
1932
  if (contextGraphId) {
1857
- return this.db.prepare(`
1858
- SELECT * FROM context_graph_memberships
1859
- WHERE context_graph_id = ?
1860
- ORDER BY principal_type ASC, principal_id ASC
1933
+ return this.db.prepare(`
1934
+ SELECT * FROM context_graph_memberships
1935
+ WHERE context_graph_id = ?
1936
+ ORDER BY principal_type ASC, principal_id ASC
1861
1937
  `).all(contextGraphId);
1862
1938
  }
1863
- return this.db.prepare(`
1864
- SELECT * FROM context_graph_memberships
1865
- ORDER BY context_graph_id ASC, principal_type ASC, principal_id ASC
1939
+ return this.db.prepare(`
1940
+ SELECT * FROM context_graph_memberships
1941
+ ORDER BY context_graph_id ASC, principal_type ASC, principal_id ASC
1866
1942
  `).all();
1867
1943
  }
1868
1944
  deleteContextGraphMember(contextGraphId, principalType, principalId) {
@@ -1874,18 +1950,18 @@ export class DashboardDB {
1874
1950
  return this.db.prepare('SELECT * FROM metric_snapshots WHERE ts >= ? AND ts <= ? ORDER BY ts').all(from, to);
1875
1951
  }
1876
1952
  const step = Math.ceil(total.c / maxPoints);
1877
- return this.db.prepare(`
1878
- SELECT * FROM (
1879
- SELECT *, ROW_NUMBER() OVER (ORDER BY ts) as rn
1880
- FROM metric_snapshots WHERE ts >= ? AND ts <= ?
1881
- ) WHERE rn % ? = 0 ORDER BY ts
1953
+ return this.db.prepare(`
1954
+ SELECT * FROM (
1955
+ SELECT *, ROW_NUMBER() OVER (ORDER BY ts) as rn
1956
+ FROM metric_snapshots WHERE ts >= ? AND ts <= ?
1957
+ ) WHERE rn % ? = 0 ORDER BY ts
1882
1958
  `).all(from, to, step);
1883
1959
  }
1884
1960
  // --- Operations ---
1885
1961
  insertOperation(op) {
1886
- this.stmt('insertOp', `
1887
- INSERT INTO operations (operation_id, operation_name, started_at, status, peer_id, contextGraph_id, details)
1888
- VALUES (@operation_id, @operation_name, @started_at, 'in_progress', @peer_id, @contextGraph_id, @details)
1962
+ this.stmt('insertOp', `
1963
+ INSERT INTO operations (operation_id, operation_name, started_at, status, peer_id, contextGraph_id, details)
1964
+ VALUES (@operation_id, @operation_name, @started_at, 'in_progress', @peer_id, @contextGraph_id, @details)
1889
1965
  `).run({
1890
1966
  operation_id: op.operation_id,
1891
1967
  operation_name: op.operation_name,
@@ -1896,10 +1972,10 @@ export class DashboardDB {
1896
1972
  });
1897
1973
  }
1898
1974
  completeOperation(op) {
1899
- this.stmt('completeOp', `
1900
- UPDATE operations SET status = 'success', duration_ms = @duration_ms,
1901
- triple_count = @triple_count, details = COALESCE(@details, details)
1902
- WHERE operation_id = @operation_id AND status = 'in_progress'
1975
+ this.stmt('completeOp', `
1976
+ UPDATE operations SET status = 'success', duration_ms = @duration_ms,
1977
+ triple_count = @triple_count, details = COALESCE(@details, details)
1978
+ WHERE operation_id = @operation_id AND status = 'in_progress'
1903
1979
  `).run({
1904
1980
  operation_id: op.operation_id,
1905
1981
  duration_ms: op.duration_ms,
@@ -1914,10 +1990,10 @@ export class DashboardDB {
1914
1990
  this.finishOperation({ ...op, status: 'cancelled' });
1915
1991
  }
1916
1992
  finishOperation(op) {
1917
- this.stmt('finishOperation', `
1918
- UPDATE operations SET status = @status, duration_ms = @duration_ms,
1919
- error_message = @error_message
1920
- WHERE operation_id = @operation_id AND status = 'in_progress'
1993
+ this.stmt('finishOperation', `
1994
+ UPDATE operations SET status = @status, duration_ms = @duration_ms,
1995
+ error_message = @error_message
1996
+ WHERE operation_id = @operation_id AND status = 'in_progress'
1921
1997
  `).run(op);
1922
1998
  }
1923
1999
  getOperations(opts = {}) {
@@ -1974,22 +2050,22 @@ export class DashboardDB {
1974
2050
  }
1975
2051
  getErrorHotspots(periodMs = 7 * 86_400_000) {
1976
2052
  const cutoff = Date.now() - periodMs;
1977
- return this.db.prepare(`
1978
- SELECT
1979
- p.phase,
1980
- o.operation_name,
1981
- COUNT(*) as error_count,
1982
- (SELECT p2.details FROM operation_phases p2
1983
- JOIN operations o2 ON o2.operation_id = p2.operation_id
1984
- WHERE p2.phase = p.phase AND o2.operation_name = o.operation_name
1985
- AND p2.status = 'error' AND p2.started_at >= ?
1986
- ORDER BY p2.started_at DESC LIMIT 1) as last_error,
1987
- MAX(p.started_at) as last_occurred
1988
- FROM operation_phases p
1989
- JOIN operations o ON o.operation_id = p.operation_id
1990
- WHERE p.status = 'error' AND p.started_at >= ?
1991
- GROUP BY p.phase, o.operation_name
1992
- ORDER BY error_count DESC
2053
+ return this.db.prepare(`
2054
+ SELECT
2055
+ p.phase,
2056
+ o.operation_name,
2057
+ COUNT(*) as error_count,
2058
+ (SELECT p2.details FROM operation_phases p2
2059
+ JOIN operations o2 ON o2.operation_id = p2.operation_id
2060
+ WHERE p2.phase = p.phase AND o2.operation_name = o.operation_name
2061
+ AND p2.status = 'error' AND p2.started_at >= ?
2062
+ ORDER BY p2.started_at DESC LIMIT 1) as last_error,
2063
+ MAX(p.started_at) as last_occurred
2064
+ FROM operation_phases p
2065
+ JOIN operations o ON o.operation_id = p.operation_id
2066
+ WHERE p.status = 'error' AND p.started_at >= ?
2067
+ GROUP BY p.phase, o.operation_name
2068
+ ORDER BY error_count DESC
1993
2069
  `).all(cutoff, cutoff);
1994
2070
  }
1995
2071
  getFailedOperations(opts = {}) {
@@ -2011,17 +2087,17 @@ export class DashboardDB {
2011
2087
  params.push(like, like, like);
2012
2088
  }
2013
2089
  params.push(limit);
2014
- const rows = this.db.prepare(`
2015
- SELECT
2016
- o.*,
2017
- p.phase AS phase,
2018
- p.details AS phase_error,
2019
- p.started_at AS phase_started_at
2020
- FROM operation_phases p
2021
- JOIN operations o ON o.operation_id = p.operation_id
2022
- WHERE ${where}
2023
- ORDER BY p.started_at DESC
2024
- LIMIT ?
2090
+ const rows = this.db.prepare(`
2091
+ SELECT
2092
+ o.*,
2093
+ p.phase AS phase,
2094
+ p.details AS phase_error,
2095
+ p.started_at AS phase_started_at
2096
+ FROM operation_phases p
2097
+ JOIN operations o ON o.operation_id = p.operation_id
2098
+ WHERE ${where}
2099
+ ORDER BY p.started_at DESC
2100
+ LIMIT ?
2025
2101
  `).all(...params);
2026
2102
  const operations = rows.map(row => {
2027
2103
  const logs = this.db.prepare('SELECT * FROM logs WHERE operation_id = ? ORDER BY ts DESC LIMIT 20').all(row.operation_id);
@@ -2038,15 +2114,15 @@ export class DashboardDB {
2038
2114
  }
2039
2115
  // --- Operation phases ---
2040
2116
  insertPhase(op) {
2041
- this.stmt('insertPhase', `
2042
- INSERT INTO operation_phases (operation_id, phase, started_at, status)
2043
- VALUES (@operation_id, @phase, @started_at, 'in_progress')
2117
+ this.stmt('insertPhase', `
2118
+ INSERT INTO operation_phases (operation_id, phase, started_at, status)
2119
+ VALUES (@operation_id, @phase, @started_at, 'in_progress')
2044
2120
  `).run(op);
2045
2121
  }
2046
2122
  completePhase(op) {
2047
- this.stmt('completePhase', `
2048
- UPDATE operation_phases SET status = 'success', duration_ms = @duration_ms
2049
- WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2123
+ this.stmt('completePhase', `
2124
+ UPDATE operation_phases SET status = 'success', duration_ms = @duration_ms
2125
+ WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2050
2126
  `).run(op);
2051
2127
  }
2052
2128
  failPhase(op) {
@@ -2056,30 +2132,30 @@ export class DashboardDB {
2056
2132
  this.finishPhase({ ...op, status: 'cancelled' });
2057
2133
  }
2058
2134
  finishPhase(op) {
2059
- this.stmt('finishPhase', `
2060
- UPDATE operation_phases SET status = @status, duration_ms = @duration_ms,
2061
- details = @error_message
2062
- WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2135
+ this.stmt('finishPhase', `
2136
+ UPDATE operation_phases SET status = @status, duration_ms = @duration_ms,
2137
+ details = @error_message
2138
+ WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2063
2139
  `).run(op);
2064
2140
  }
2065
2141
  failAllPhases(op) {
2066
- this.stmt('failAllPhases', `
2067
- UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
2068
- details = @error_message
2069
- WHERE operation_id = @operation_id AND status = 'in_progress'
2142
+ this.stmt('failAllPhases', `
2143
+ UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
2144
+ details = @error_message
2145
+ WHERE operation_id = @operation_id AND status = 'in_progress'
2070
2146
  `).run(op);
2071
2147
  }
2072
2148
  // --- Operation cost & tx ---
2073
2149
  setOperationCost(op) {
2074
- this.stmt('setCost', `
2075
- UPDATE operations SET
2076
- gas_used = COALESCE(@gas_used, gas_used),
2077
- gas_price_gwei = COALESCE(@gas_price_gwei, gas_price_gwei),
2078
- gas_cost_eth = COALESCE(@gas_cost_eth, gas_cost_eth),
2079
- trac_cost = COALESCE(@trac_cost, trac_cost),
2080
- tx_hash = COALESCE(@tx_hash, tx_hash),
2081
- chain_id = COALESCE(@chain_id, chain_id)
2082
- WHERE operation_id = @operation_id
2150
+ this.stmt('setCost', `
2151
+ UPDATE operations SET
2152
+ gas_used = COALESCE(@gas_used, gas_used),
2153
+ gas_price_gwei = COALESCE(@gas_price_gwei, gas_price_gwei),
2154
+ gas_cost_eth = COALESCE(@gas_cost_eth, gas_cost_eth),
2155
+ trac_cost = COALESCE(@trac_cost, trac_cost),
2156
+ tx_hash = COALESCE(@tx_hash, tx_hash),
2157
+ chain_id = COALESCE(@chain_id, chain_id)
2158
+ WHERE operation_id = @operation_id
2083
2159
  `).run({
2084
2160
  operation_id: op.operation_id,
2085
2161
  gas_used: op.gas_used ?? null,
@@ -2097,18 +2173,18 @@ export class DashboardDB {
2097
2173
  const params = [cutoff];
2098
2174
  if (opts.name)
2099
2175
  params.push(opts.name);
2100
- const summaryRow = this.db.prepare(`
2101
- SELECT
2102
- COUNT(*) as totalCount,
2103
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2104
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount,
2105
- SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2106
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2107
- AVG(gas_cost_eth) as avgGasCostEth,
2108
- SUM(gas_cost_eth) as totalGasCostEth,
2109
- AVG(trac_cost) as avgTracCost,
2110
- SUM(trac_cost) as totalTracCost
2111
- FROM operations WHERE started_at >= ? ${nameFilter}
2176
+ const summaryRow = this.db.prepare(`
2177
+ SELECT
2178
+ COUNT(*) as totalCount,
2179
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2180
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount,
2181
+ SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2182
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2183
+ AVG(gas_cost_eth) as avgGasCostEth,
2184
+ SUM(gas_cost_eth) as totalGasCostEth,
2185
+ AVG(trac_cost) as avgTracCost,
2186
+ SUM(trac_cost) as totalTracCost
2187
+ FROM operations WHERE started_at >= ? ${nameFilter}
2112
2188
  `).get(...params);
2113
2189
  const summary = {
2114
2190
  totalCount: summaryRow.totalCount ?? 0,
@@ -2127,18 +2203,18 @@ export class DashboardDB {
2127
2203
  const tsParams = [bucketSize, cutoff];
2128
2204
  if (opts.name)
2129
2205
  tsParams.push(opts.name);
2130
- const timeSeries = this.db.prepare(`
2131
- SELECT
2132
- (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2133
- COUNT(*) as count,
2134
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2135
- SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2136
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2137
- AVG(gas_cost_eth) as avgGasCostEth,
2138
- SUM(gas_cost_eth) as totalGasCostEth
2139
- FROM operations
2140
- WHERE started_at >= ? ${nameFilter}
2141
- GROUP BY bucket ORDER BY bucket
2206
+ const timeSeries = this.db.prepare(`
2207
+ SELECT
2208
+ (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2209
+ COUNT(*) as count,
2210
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2211
+ SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2212
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2213
+ AVG(gas_cost_eth) as avgGasCostEth,
2214
+ SUM(gas_cost_eth) as totalGasCostEth
2215
+ FROM operations
2216
+ WHERE started_at >= ? ${nameFilter}
2217
+ GROUP BY bucket ORDER BY bucket
2142
2218
  `).all(bucketSize, bucketSize, ...params);
2143
2219
  return {
2144
2220
  summary,
@@ -2155,17 +2231,17 @@ export class DashboardDB {
2155
2231
  // --- Per-type time series ---
2156
2232
  getPerTypeTimeSeries(opts) {
2157
2233
  const cutoff = Date.now() - opts.periodMs;
2158
- const rows = this.db.prepare(`
2159
- SELECT
2160
- (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2161
- operation_name as type,
2162
- COUNT(*) as count,
2163
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs,
2164
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2165
- SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2166
- SUM(gas_cost_eth) as gasCostEth
2167
- FROM operations WHERE started_at >= ?
2168
- GROUP BY bucket, operation_name ORDER BY bucket
2234
+ const rows = this.db.prepare(`
2235
+ SELECT
2236
+ (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2237
+ operation_name as type,
2238
+ COUNT(*) as count,
2239
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs,
2240
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2241
+ SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,
2242
+ SUM(gas_cost_eth) as gasCostEth
2243
+ FROM operations WHERE started_at >= ?
2244
+ GROUP BY bucket, operation_name ORDER BY bucket
2169
2245
  `).all(opts.bucketMs, opts.bucketMs, cutoff);
2170
2246
  const bucketSet = new Set();
2171
2247
  const typeSet = new Set();
@@ -2195,15 +2271,15 @@ export class DashboardDB {
2195
2271
  // --- Success rates by operation type ---
2196
2272
  getSuccessRatesByType(periodMs) {
2197
2273
  const cutoff = Date.now() - periodMs;
2198
- return this.db.prepare(`
2199
- SELECT
2200
- operation_name as type,
2201
- COUNT(*) as total,
2202
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
2203
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
2204
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs
2205
- FROM operations WHERE started_at >= ?
2206
- GROUP BY operation_name ORDER BY total DESC
2274
+ return this.db.prepare(`
2275
+ SELECT
2276
+ operation_name as type,
2277
+ COUNT(*) as total,
2278
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
2279
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
2280
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs
2281
+ FROM operations WHERE started_at >= ?
2282
+ GROUP BY operation_name ORDER BY total DESC
2207
2283
  `).all(cutoff).map(r => ({
2208
2284
  ...r,
2209
2285
  rate: r.success + r.error > 0 ? r.success / (r.success + r.error) : 0,
@@ -2226,16 +2302,16 @@ export class DashboardDB {
2226
2302
  // on-chain (Verifiable Memory commits) are counted, so the publish
2227
2303
  // count and the TRAC total are consistent. Free SWM/local/testnet
2228
2304
  // publishes record trac_cost = 0 and are intentionally excluded.
2229
- const row = this.db.prepare(`
2230
- SELECT
2231
- SUM(CASE WHEN trac_cost > 0 THEN 1 ELSE 0 END) as publishCount,
2232
- SUM(CASE WHEN status = 'success' AND trac_cost > 0 THEN 1 ELSE 0 END) as successCount,
2233
- COALESCE(SUM(gas_cost_eth), 0) as totalGasEth,
2234
- COALESCE(SUM(trac_cost), 0) as totalTrac,
2235
- COALESCE(AVG(CASE WHEN trac_cost > 0 THEN gas_cost_eth END), 0) as avgGasEth,
2236
- COALESCE(AVG(CASE WHEN trac_cost > 0 THEN trac_cost END), 0) as avgTrac
2237
- FROM operations
2238
- WHERE operation_name = 'publish' AND trac_cost > 0 AND started_at >= ?
2305
+ const row = this.db.prepare(`
2306
+ SELECT
2307
+ SUM(CASE WHEN trac_cost > 0 THEN 1 ELSE 0 END) as publishCount,
2308
+ SUM(CASE WHEN status = 'success' AND trac_cost > 0 THEN 1 ELSE 0 END) as successCount,
2309
+ COALESCE(SUM(gas_cost_eth), 0) as totalGasEth,
2310
+ COALESCE(SUM(trac_cost), 0) as totalTrac,
2311
+ COALESCE(AVG(CASE WHEN trac_cost > 0 THEN gas_cost_eth END), 0) as avgGasEth,
2312
+ COALESCE(AVG(CASE WHEN trac_cost > 0 THEN trac_cost END), 0) as avgTrac
2313
+ FROM operations
2314
+ WHERE operation_name = 'publish' AND trac_cost > 0 AND started_at >= ?
2239
2315
  `).get(cutoff);
2240
2316
  results.periods.push({
2241
2317
  label: p.label,
@@ -2270,9 +2346,9 @@ export class DashboardDB {
2270
2346
  * still throw — pinned by db.test.ts.
2271
2347
  */
2272
2348
  insertChatMessage(msg) {
2273
- const info = this.stmt('insertChat', `
2274
- INSERT INTO chat_messages (ts, direction, peer, peer_name, text, delivered, message_id)
2275
- VALUES (@ts, @direction, @peer, @peer_name, @text, @delivered, @message_id)
2349
+ const info = this.stmt('insertChat', `
2350
+ INSERT INTO chat_messages (ts, direction, peer, peer_name, text, delivered, message_id)
2351
+ VALUES (@ts, @direction, @peer, @peer_name, @text, @delivered, @message_id)
2276
2352
  `).run({
2277
2353
  ts: msg.ts,
2278
2354
  direction: msg.direction,
@@ -2363,16 +2439,16 @@ export class DashboardDB {
2363
2439
  return this.db.prepare('SELECT * FROM chat_persistence_jobs WHERE turn_id = ?').get(turnId);
2364
2440
  }
2365
2441
  insertChatPersistenceJob(job) {
2366
- this.stmt('insertChatPersistenceJob', `
2367
- INSERT INTO chat_persistence_jobs (
2368
- turn_id, session_id, user_message, assistant_reply, tool_calls_json,
2369
- status, attempts, max_attempts, next_attempt_at, queued_at, updated_at,
2370
- store_ms, error_message
2371
- ) VALUES (
2372
- @turn_id, @session_id, @user_message, @assistant_reply, @tool_calls_json,
2373
- @status, @attempts, @max_attempts, @next_attempt_at, @queued_at, @updated_at,
2374
- @store_ms, @error_message
2375
- )
2442
+ this.stmt('insertChatPersistenceJob', `
2443
+ INSERT INTO chat_persistence_jobs (
2444
+ turn_id, session_id, user_message, assistant_reply, tool_calls_json,
2445
+ status, attempts, max_attempts, next_attempt_at, queued_at, updated_at,
2446
+ store_ms, error_message
2447
+ ) VALUES (
2448
+ @turn_id, @session_id, @user_message, @assistant_reply, @tool_calls_json,
2449
+ @status, @attempts, @max_attempts, @next_attempt_at, @queued_at, @updated_at,
2450
+ @store_ms, @error_message
2451
+ )
2376
2452
  `).run({
2377
2453
  ...job,
2378
2454
  tool_calls_json: job.tool_calls_json ?? null,
@@ -2381,46 +2457,46 @@ export class DashboardDB {
2381
2457
  });
2382
2458
  }
2383
2459
  markChatPersistenceInProgress(turnId, attempts, updatedAt) {
2384
- this.stmt('markChatPersistenceInProgress', `
2385
- UPDATE chat_persistence_jobs
2386
- SET status = 'in_progress', attempts = ?, updated_at = ?, error_message = NULL
2387
- WHERE turn_id = ?
2460
+ this.stmt('markChatPersistenceInProgress', `
2461
+ UPDATE chat_persistence_jobs
2462
+ SET status = 'in_progress', attempts = ?, updated_at = ?, error_message = NULL
2463
+ WHERE turn_id = ?
2388
2464
  `).run(attempts, updatedAt, turnId);
2389
2465
  }
2390
2466
  markChatPersistenceStored(turnId, storeMs, updatedAt) {
2391
- this.stmt('markChatPersistenceStored', `
2392
- UPDATE chat_persistence_jobs
2393
- SET status = 'stored', store_ms = ?, updated_at = ?, error_message = NULL
2394
- WHERE turn_id = ?
2467
+ this.stmt('markChatPersistenceStored', `
2468
+ UPDATE chat_persistence_jobs
2469
+ SET status = 'stored', store_ms = ?, updated_at = ?, error_message = NULL
2470
+ WHERE turn_id = ?
2395
2471
  `).run(storeMs, updatedAt, turnId);
2396
2472
  }
2397
2473
  markChatPersistencePendingRetry(turnId, attempts, nextAttemptAt, updatedAt, errorMessage) {
2398
- this.stmt('markChatPersistencePendingRetry', `
2399
- UPDATE chat_persistence_jobs
2400
- SET status = 'pending', attempts = ?, next_attempt_at = ?, updated_at = ?, error_message = ?
2401
- WHERE turn_id = ?
2474
+ this.stmt('markChatPersistencePendingRetry', `
2475
+ UPDATE chat_persistence_jobs
2476
+ SET status = 'pending', attempts = ?, next_attempt_at = ?, updated_at = ?, error_message = ?
2477
+ WHERE turn_id = ?
2402
2478
  `).run(attempts, nextAttemptAt, updatedAt, errorMessage, turnId);
2403
2479
  }
2404
2480
  markChatPersistenceFailed(turnId, attempts, updatedAt, errorMessage) {
2405
- this.stmt('markChatPersistenceFailed', `
2406
- UPDATE chat_persistence_jobs
2407
- SET status = 'failed', attempts = ?, updated_at = ?, error_message = ?
2408
- WHERE turn_id = ?
2481
+ this.stmt('markChatPersistenceFailed', `
2482
+ UPDATE chat_persistence_jobs
2483
+ SET status = 'failed', attempts = ?, updated_at = ?, error_message = ?
2484
+ WHERE turn_id = ?
2409
2485
  `).run(attempts, updatedAt, errorMessage, turnId);
2410
2486
  }
2411
2487
  recoverInProgressChatPersistenceJobs(now) {
2412
- this.stmt('recoverInProgressChatPersistenceJobs', `
2413
- UPDATE chat_persistence_jobs
2414
- SET status = 'pending', next_attempt_at = ?, updated_at = ?
2415
- WHERE status = 'in_progress'
2488
+ this.stmt('recoverInProgressChatPersistenceJobs', `
2489
+ UPDATE chat_persistence_jobs
2490
+ SET status = 'pending', next_attempt_at = ?, updated_at = ?
2491
+ WHERE status = 'in_progress'
2416
2492
  `).run(now, now);
2417
2493
  }
2418
2494
  getRunnableChatPersistenceJobs(now, limit = 10) {
2419
- return this.db.prepare(`
2420
- SELECT * FROM chat_persistence_jobs
2421
- WHERE status = 'pending' AND next_attempt_at <= ?
2422
- ORDER BY next_attempt_at ASC, queued_at ASC
2423
- LIMIT ?
2495
+ return this.db.prepare(`
2496
+ SELECT * FROM chat_persistence_jobs
2497
+ WHERE status = 'pending' AND next_attempt_at <= ?
2498
+ ORDER BY next_attempt_at ASC, queued_at ASC
2499
+ LIMIT ?
2424
2500
  `).all(now, limit);
2425
2501
  }
2426
2502
  getNextPendingChatPersistenceAt() {
@@ -2428,19 +2504,19 @@ export class DashboardDB {
2428
2504
  return row?.next_at ?? null;
2429
2505
  }
2430
2506
  getChatPersistenceHealth(now) {
2431
- const counts = this.db.prepare(`
2432
- SELECT
2433
- SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
2434
- SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress_count,
2435
- SUM(CASE WHEN status = 'stored' THEN 1 ELSE 0 END) AS stored_count,
2436
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
2437
- SUM(CASE WHEN status = 'pending' AND next_attempt_at < ? THEN 1 ELSE 0 END) AS overdue_pending_count
2438
- FROM chat_persistence_jobs
2507
+ const counts = this.db.prepare(`
2508
+ SELECT
2509
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
2510
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress_count,
2511
+ SUM(CASE WHEN status = 'stored' THEN 1 ELSE 0 END) AS stored_count,
2512
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
2513
+ SUM(CASE WHEN status = 'pending' AND next_attempt_at < ? THEN 1 ELSE 0 END) AS overdue_pending_count
2514
+ FROM chat_persistence_jobs
2439
2515
  `).get(now);
2440
- const oldest = this.db.prepare(`
2441
- SELECT MIN(queued_at) AS oldest_pending_queued_at
2442
- FROM chat_persistence_jobs
2443
- WHERE status = 'pending'
2516
+ const oldest = this.db.prepare(`
2517
+ SELECT MIN(queued_at) AS oldest_pending_queued_at
2518
+ FROM chat_persistence_jobs
2519
+ WHERE status = 'pending'
2444
2520
  `).get();
2445
2521
  return {
2446
2522
  pending_count: counts?.pending_count ?? 0,
@@ -2453,9 +2529,9 @@ export class DashboardDB {
2453
2529
  }
2454
2530
  // --- Logs ---
2455
2531
  insertLog(entry) {
2456
- this.stmt('insertLog', `
2457
- INSERT INTO logs (ts, level, operation_name, operation_id, module, message)
2458
- VALUES (@ts, @level, @operation_name, @operation_id, @module, @message)
2532
+ this.stmt('insertLog', `
2533
+ INSERT INTO logs (ts, level, operation_name, operation_id, module, message)
2534
+ VALUES (@ts, @level, @operation_name, @operation_id, @module, @message)
2459
2535
  `).run({
2460
2536
  ts: entry.ts,
2461
2537
  level: entry.level,
@@ -2510,9 +2586,9 @@ export class DashboardDB {
2510
2586
  }
2511
2587
  // --- Query history ---
2512
2588
  insertQueryHistory(entry) {
2513
- this.stmt('insertQueryHistory', `
2514
- INSERT INTO query_history (ts, sparql, duration_ms, result_count, error)
2515
- VALUES (@ts, @sparql, @duration_ms, @result_count, @error)
2589
+ this.stmt('insertQueryHistory', `
2590
+ INSERT INTO query_history (ts, sparql, duration_ms, result_count, error)
2591
+ VALUES (@ts, @sparql, @duration_ms, @result_count, @error)
2516
2592
  `).run({
2517
2593
  ts: Date.now(),
2518
2594
  sparql: entry.sparql,
@@ -2556,9 +2632,9 @@ export class DashboardDB {
2556
2632
  }
2557
2633
  // --- Notifications ---
2558
2634
  insertNotification(n) {
2559
- const result = this.stmt('insertNotif', `
2560
- INSERT INTO notifications (ts, type, title, message, source, peer, read, meta, context_graph_id)
2561
- VALUES (@ts, @type, @title, @message, @source, @peer, 0, @meta, @contextGraphId)
2635
+ const result = this.stmt('insertNotif', `
2636
+ INSERT INTO notifications (ts, type, title, message, source, peer, read, meta, context_graph_id)
2637
+ VALUES (@ts, @type, @title, @message, @source, @peer, 0, @meta, @contextGraphId)
2562
2638
  `).run({
2563
2639
  ts: n.ts,
2564
2640
  type: n.type,
@@ -2594,8 +2670,8 @@ export class DashboardDB {
2594
2670
  if (cgIds.length === 0)
2595
2671
  return [];
2596
2672
  const placeholders = cgIds.map(() => '?').join(',');
2597
- return this.db.prepare(`SELECT * FROM notifications
2598
- WHERE context_graph_id IN (${placeholders})
2673
+ return this.db.prepare(`SELECT * FROM notifications
2674
+ WHERE context_graph_id IN (${placeholders})
2599
2675
  ORDER BY ts DESC LIMIT ?`).all(...cgIds, limit);
2600
2676
  }
2601
2677
  /**
@@ -2626,8 +2702,8 @@ export class DashboardDB {
2626
2702
  if (types.length === 0)
2627
2703
  return [];
2628
2704
  const placeholders = types.map(() => '?').join(',');
2629
- return this.db.prepare(`SELECT * FROM notifications
2630
- WHERE type IN (${placeholders})
2705
+ return this.db.prepare(`SELECT * FROM notifications
2706
+ WHERE type IN (${placeholders})
2631
2707
  ORDER BY ts DESC LIMIT ?`).all(...types, limit);
2632
2708
  }
2633
2709
  /**
@@ -2647,9 +2723,9 @@ export class DashboardDB {
2647
2723
  const wallet = walletAddress.trim().toLowerCase();
2648
2724
  if (!wallet)
2649
2725
  return [];
2650
- return this.db.prepare(`SELECT * FROM notifications
2651
- WHERE type = 'pca_cost_covered'
2652
- AND lower(json_extract(meta, '$.publisherAddress')) = ?
2726
+ return this.db.prepare(`SELECT * FROM notifications
2727
+ WHERE type = 'pca_cost_covered'
2728
+ AND lower(json_extract(meta, '$.publisherAddress')) = ?
2653
2729
  ORDER BY ts DESC LIMIT ?`).all(wallet, limit);
2654
2730
  }
2655
2731
  markNotificationsRead(ids) {
@@ -2679,10 +2755,10 @@ export class DashboardDB {
2679
2755
  return [];
2680
2756
  const windowStart = parsed.windowBucket * ACTIVITY_DIGEST_WINDOW_MS;
2681
2757
  const windowEnd = windowStart + ACTIVITY_DIGEST_WINDOW_MS;
2682
- const rows = this.db.prepare(`SELECT id FROM notifications
2683
- WHERE context_graph_id = ?
2684
- AND type = ?
2685
- AND ts >= ? AND ts < ?
2758
+ const rows = this.db.prepare(`SELECT id FROM notifications
2759
+ WHERE context_graph_id = ?
2760
+ AND type = ?
2761
+ AND ts >= ? AND ts < ?
2686
2762
  AND json_extract(meta, '$.kind') = ?`).all(parsed.contextGraphId, ASSERTION_ACTIVITY_TYPE, windowStart, windowEnd, parsed.kind);
2687
2763
  return rows.map((r) => r.id);
2688
2764
  }
@@ -2691,7 +2767,6 @@ export class DashboardDB {
2691
2767
  }
2692
2768
  }
2693
2769
  // --- Sync requester checkpoints (issue #1138 A3) ---
2694
- const DEFAULT_SYNC_CHECKPOINT_TTL_MS = 24 * 60 * 60 * 1000;
2695
2770
  export class SqliteSyncCheckpointStore {
2696
2771
  db;
2697
2772
  clock;
@@ -2701,73 +2776,128 @@ export class SqliteSyncCheckpointStore {
2701
2776
  this.clock = options.clock ?? (() => Date.now());
2702
2777
  this.ttlMs = options.ttlMs ?? DEFAULT_SYNC_CHECKPOINT_TTL_MS;
2703
2778
  }
2704
- get(key) {
2705
- const now = this.clock();
2706
- const row = this.db.prepare(`SELECT offset, updated_at, expires_at,
2707
- responder_session_id, responder_session_expires_at
2779
+ readRow(key) {
2780
+ const row = this.db.prepare(`SELECT offset, updated_at, expires_at,
2781
+ responder_session_id, responder_session_expires_at, responder_session_offset,
2782
+ manifest_digest, manifest_prefix_digest, terminal
2708
2783
  FROM sync_checkpoints WHERE key = ?`).get(key);
2709
2784
  if (!row)
2710
2785
  return undefined;
2711
- if (row.expires_at < now) {
2712
- this.delete(key);
2713
- return undefined;
2714
- }
2715
- const hasFreshResponderSession = Boolean(row.responder_session_id)
2716
- && Number.isSafeInteger(row.responder_session_expires_at)
2717
- && (row.responder_session_expires_at ?? 0) > now;
2718
- if (row.responder_session_id && !hasFreshResponderSession) {
2719
- this.clearResponderSession(key);
2720
- }
2721
2786
  return {
2722
2787
  offset: row.offset,
2723
2788
  updatedAtMs: row.updated_at,
2724
2789
  expiresAtMs: row.expires_at,
2725
- ...(hasFreshResponderSession
2726
- ? {
2727
- responderSessionId: row.responder_session_id,
2728
- responderSessionExpiresAtMs: row.responder_session_expires_at,
2729
- }
2790
+ ...(row.terminal === 1 ? { terminal: true } : {}),
2791
+ ...(row.manifest_digest ? { manifestDigest: row.manifest_digest } : {}),
2792
+ ...(row.manifest_prefix_digest
2793
+ ? { manifestPrefixDigest: row.manifest_prefix_digest }
2794
+ : {}),
2795
+ // Preserve each nullable session column independently so the shared
2796
+ // validator can distinguish an absent session from a torn/malformed
2797
+ // persisted session. Collapsing a partial row to no session fields would
2798
+ // turn corrupt durable state into an apparently valid ordinary offset.
2799
+ ...(row.responder_session_id !== null
2800
+ ? { responderSessionId: row.responder_session_id }
2801
+ : {}),
2802
+ ...(row.responder_session_expires_at !== null
2803
+ ? { responderSessionExpiresAtMs: row.responder_session_expires_at }
2804
+ : {}),
2805
+ ...(row.responder_session_offset !== null
2806
+ ? { responderSessionOffset: row.responder_session_offset }
2730
2807
  : {}),
2731
2808
  };
2732
2809
  }
2733
- set(key, value, nowMs = this.clock()) {
2734
- if (!Number.isSafeInteger(value) || value < 0) {
2735
- throw new Error(`Invalid sync checkpoint offset for ${key}: ${value}`);
2810
+ get(key, now = this.clock()) {
2811
+ const entry = this.readRow(key);
2812
+ if (!entry)
2813
+ return undefined;
2814
+ if (!isValidSyncCheckpointEntry(entry) || entry.expiresAtMs < now) {
2815
+ this.delete(key);
2816
+ return undefined;
2736
2817
  }
2737
- this.db.prepare(`
2738
- INSERT INTO sync_checkpoints (key, offset, updated_at, expires_at)
2739
- VALUES (?, ?, ?, ?)
2740
- ON CONFLICT(key) DO UPDATE SET
2741
- offset = excluded.offset,
2742
- updated_at = excluded.updated_at,
2743
- expires_at = excluded.expires_at
2744
- `).run(key, value, nowMs, nowMs + this.ttlMs);
2818
+ if (entry.responderSessionId
2819
+ && (entry.responderSessionExpiresAtMs ?? 0) <= now) {
2820
+ const withoutExpiredSession = withoutSyncCheckpointResponderSession(entry);
2821
+ this.writeEntry(key, withoutExpiredSession);
2822
+ return withoutExpiredSession;
2823
+ }
2824
+ return entry;
2825
+ }
2826
+ writeEntry(key, entry) {
2827
+ this.db.prepare(`
2828
+ INSERT INTO sync_checkpoints (
2829
+ key, offset, updated_at, expires_at,
2830
+ responder_session_id, responder_session_expires_at, responder_session_offset,
2831
+ manifest_digest, manifest_prefix_digest, terminal
2832
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2833
+ ON CONFLICT(key) DO UPDATE SET
2834
+ offset = excluded.offset,
2835
+ updated_at = excluded.updated_at,
2836
+ expires_at = excluded.expires_at,
2837
+ responder_session_id = excluded.responder_session_id,
2838
+ responder_session_expires_at = excluded.responder_session_expires_at,
2839
+ responder_session_offset = excluded.responder_session_offset,
2840
+ manifest_digest = excluded.manifest_digest,
2841
+ manifest_prefix_digest = excluded.manifest_prefix_digest,
2842
+ terminal = excluded.terminal
2843
+ `).run(key, entry.offset, entry.updatedAtMs, entry.expiresAtMs, entry.responderSessionId ?? null, entry.responderSessionExpiresAtMs ?? null, entry.responderSessionOffset ?? null, entry.manifestDigest ?? null, entry.manifestPrefixDigest ?? null, entry.terminal ? 1 : 0);
2844
+ }
2845
+ set(key, value, nowMs = this.clock(), responderSessionOffset) {
2846
+ const transition = this.db.transaction(() => {
2847
+ this.writeEntry(key, transitionSyncCheckpointOffset({
2848
+ key,
2849
+ existing: this.get(key, nowMs),
2850
+ value,
2851
+ nowMs,
2852
+ ttlMs: this.ttlMs,
2853
+ responderSessionOffset,
2854
+ }));
2855
+ });
2856
+ transition();
2745
2857
  }
2746
- setResponderSession(key, sessionId, expiresAtMs, nowMs = this.clock()) {
2747
- if (!sessionId || !Number.isSafeInteger(expiresAtMs)) {
2748
- throw new Error(`Invalid sync responder session for ${key}`);
2749
- }
2858
+ setManifestBoundOffset(key, value, manifestDigest, nowMs = this.clock(), manifestPrefixDigest, terminal = false, responderSessionOffset) {
2859
+ const transition = this.db.transaction(() => {
2860
+ this.writeEntry(key, transitionSyncCheckpointManifestOffset({
2861
+ key,
2862
+ existing: this.get(key, nowMs),
2863
+ value,
2864
+ manifestDigest,
2865
+ nowMs,
2866
+ ttlMs: this.ttlMs,
2867
+ manifestPrefixDigest,
2868
+ terminal,
2869
+ responderSessionOffset,
2870
+ }));
2871
+ });
2872
+ transition();
2873
+ }
2874
+ setResponderSession(key, sessionId, expiresAtMs, nowMs = this.clock(), manifestDigest, manifestPrefixDigest, responderSessionOffset) {
2750
2875
  if (expiresAtMs <= nowMs) {
2751
2876
  this.clearResponderSession(key);
2752
2877
  return;
2753
2878
  }
2754
- this.db.prepare(`
2755
- INSERT INTO sync_checkpoints (
2756
- key, offset, updated_at, expires_at,
2757
- responder_session_id, responder_session_expires_at
2758
- ) VALUES (?, 0, ?, ?, ?, ?)
2759
- ON CONFLICT(key) DO UPDATE SET
2760
- responder_session_id = excluded.responder_session_id,
2761
- responder_session_expires_at = excluded.responder_session_expires_at
2762
- `).run(key, nowMs, nowMs + this.ttlMs, sessionId, expiresAtMs);
2879
+ const transition = this.db.transaction(() => {
2880
+ this.writeEntry(key, transitionSyncCheckpointResponderSession({
2881
+ key,
2882
+ existing: this.get(key, nowMs),
2883
+ sessionId,
2884
+ expiresAtMs,
2885
+ nowMs,
2886
+ ttlMs: this.ttlMs,
2887
+ manifestDigest,
2888
+ manifestPrefixDigest,
2889
+ responderSessionOffset,
2890
+ }));
2891
+ });
2892
+ transition();
2763
2893
  }
2764
2894
  clearResponderSession(key) {
2765
- this.db.prepare(`
2766
- UPDATE sync_checkpoints
2767
- SET responder_session_id = NULL,
2768
- responder_session_expires_at = NULL
2769
- WHERE key = ?
2770
- `).run(key);
2895
+ const transition = this.db.transaction(() => {
2896
+ const existing = this.readRow(key);
2897
+ if (existing)
2898
+ this.writeEntry(key, withoutSyncCheckpointResponderSession(existing));
2899
+ });
2900
+ transition();
2771
2901
  }
2772
2902
  delete(key) {
2773
2903
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE key = ?`).run(key);
@@ -2800,13 +2930,13 @@ export class SqliteChangelogCursorStore {
2800
2930
  if (!Number.isSafeInteger(seq) || seq < 0) {
2801
2931
  throw new Error(`Invalid changelog cursor seq for ${peerId}/${contextGraphId}: ${seq}`);
2802
2932
  }
2803
- this.db.prepare(`
2804
- INSERT INTO changelog_cursors (peer_id, context_graph_id, era, seq, updated_at)
2805
- VALUES (?, ?, ?, ?, ?)
2806
- ON CONFLICT(peer_id, context_graph_id) DO UPDATE SET
2807
- era = excluded.era,
2808
- seq = excluded.seq,
2809
- updated_at = excluded.updated_at
2933
+ this.db.prepare(`
2934
+ INSERT INTO changelog_cursors (peer_id, context_graph_id, era, seq, updated_at)
2935
+ VALUES (?, ?, ?, ?, ?)
2936
+ ON CONFLICT(peer_id, context_graph_id) DO UPDATE SET
2937
+ era = excluded.era,
2938
+ seq = excluded.seq,
2939
+ updated_at = excluded.updated_at
2810
2940
  `).run(peerId, contextGraphId, era, seq, nowMs);
2811
2941
  }
2812
2942
  }
@@ -2834,13 +2964,13 @@ export class SqliteChangelogEraGuard {
2834
2964
  if (!Number.isSafeInteger(highSeq) || highSeq < 0) {
2835
2965
  throw new Error(`Invalid changelog era high_seq: ${highSeq}`);
2836
2966
  }
2837
- this.db.prepare(`
2838
- INSERT INTO changelog_era (id, era, high_seq, updated_at)
2839
- VALUES (1, ?, ?, ?)
2840
- ON CONFLICT(id) DO UPDATE SET
2841
- era = excluded.era,
2842
- high_seq = excluded.high_seq,
2843
- updated_at = excluded.updated_at
2967
+ this.db.prepare(`
2968
+ INSERT INTO changelog_era (id, era, high_seq, updated_at)
2969
+ VALUES (1, ?, ?, ?)
2970
+ ON CONFLICT(id) DO UPDATE SET
2971
+ era = excluded.era,
2972
+ high_seq = excluded.high_seq,
2973
+ updated_at = excluded.updated_at
2844
2974
  `).run(era, highSeq, this.clock());
2845
2975
  }
2846
2976
  }
@@ -2874,7 +3004,7 @@ export class SqliteMessageIdempotencyStore {
2874
3004
  }
2875
3005
  check(peer, protocol, messageId, direction) {
2876
3006
  const row = this.db
2877
- .prepare(`SELECT response_blob FROM message_idempotency
3007
+ .prepare(`SELECT response_blob FROM message_idempotency
2878
3008
  WHERE peer_id = ? AND protocol = ? AND message_id = ? AND direction = ?`)
2879
3009
  .get(peer, protocol, messageId, direction);
2880
3010
  if (!row)
@@ -2904,9 +3034,9 @@ export class SqliteMessageIdempotencyStore {
2904
3034
  // SqliteError so the substrate's bug doesn't disguise itself as
2905
3035
  // a normal duplicate.
2906
3036
  this.db
2907
- .prepare(`INSERT INTO message_idempotency
2908
- (peer_id, protocol, message_id, direction, response_blob, response_size, ts)
2909
- VALUES (?, ?, ?, ?, ?, ?, ?)
3037
+ .prepare(`INSERT INTO message_idempotency
3038
+ (peer_id, protocol, message_id, direction, response_blob, response_size, ts)
3039
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2910
3040
  ON CONFLICT (peer_id, protocol, message_id, direction) DO NOTHING`)
2911
3041
  .run(peer, protocol, messageId, direction, blob, responseSize, this.clock());
2912
3042
  }
@@ -2931,15 +3061,15 @@ export class SqliteProtocolOutboxStore {
2931
3061
  }
2932
3062
  enqueue(peer, protocol, messageId, payload, error, now) {
2933
3063
  const existing = this.db
2934
- .prepare(`SELECT * FROM protocol_outbox
3064
+ .prepare(`SELECT * FROM protocol_outbox
2935
3065
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2936
3066
  .get(peer, protocol, messageId);
2937
3067
  if (existing) {
2938
3068
  const newAttempts = existing.attempts + 1;
2939
3069
  const nextAttemptAt = now + this.backoffFor(newAttempts);
2940
3070
  this.db
2941
- .prepare(`UPDATE protocol_outbox
2942
- SET attempts = ?, last_attempt_at = ?, next_attempt_at = ?, last_error = ?
3071
+ .prepare(`UPDATE protocol_outbox
3072
+ SET attempts = ?, last_attempt_at = ?, next_attempt_at = ?, last_error = ?
2943
3073
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2944
3074
  .run(newAttempts, now, nextAttemptAt, error, peer, protocol, messageId);
2945
3075
  return {
@@ -2958,9 +3088,9 @@ export class SqliteProtocolOutboxStore {
2958
3088
  const nextAttemptAt = now + this.backoffFor(attempts);
2959
3089
  const blob = Buffer.from(payload);
2960
3090
  this.db
2961
- .prepare(`INSERT INTO protocol_outbox
2962
- (peer_id, protocol, message_id, payload, attempts,
2963
- first_failure_at, last_attempt_at, next_attempt_at, last_error)
3091
+ .prepare(`INSERT INTO protocol_outbox
3092
+ (peer_id, protocol, message_id, payload, attempts,
3093
+ first_failure_at, last_attempt_at, next_attempt_at, last_error)
2964
3094
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2965
3095
  .run(peer, protocol, messageId, blob, attempts, now, now, nextAttemptAt, error);
2966
3096
  return {
@@ -2977,14 +3107,14 @@ export class SqliteProtocolOutboxStore {
2977
3107
  }
2978
3108
  markDelivered(peer, protocol, messageId) {
2979
3109
  const result = this.db
2980
- .prepare(`DELETE FROM protocol_outbox
3110
+ .prepare(`DELETE FROM protocol_outbox
2981
3111
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2982
3112
  .run(peer, protocol, messageId);
2983
3113
  return result.changes > 0;
2984
3114
  }
2985
3115
  hasEntry(peer, protocol, messageId) {
2986
3116
  const row = this.db
2987
- .prepare(`SELECT 1 FROM protocol_outbox
3117
+ .prepare(`SELECT 1 FROM protocol_outbox
2988
3118
  WHERE peer_id = ? AND protocol = ? AND message_id = ? LIMIT 1`)
2989
3119
  .get(peer, protocol, messageId);
2990
3120
  return row !== undefined;
@@ -2994,27 +3124,27 @@ export class SqliteProtocolOutboxStore {
2994
3124
  }
2995
3125
  pendingFor(peer) {
2996
3126
  const rows = this.db
2997
- .prepare(`SELECT * FROM protocol_outbox
2998
- WHERE peer_id = ?
3127
+ .prepare(`SELECT * FROM protocol_outbox
3128
+ WHERE peer_id = ?
2999
3129
  ORDER BY first_failure_at ASC, protocol ASC, message_id ASC`)
3000
3130
  .all(peer);
3001
3131
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
3002
3132
  }
3003
3133
  due(now) {
3004
3134
  const rows = this.db
3005
- .prepare(`SELECT * FROM protocol_outbox
3006
- WHERE next_attempt_at <= ?
3007
- ORDER BY next_attempt_at ASC, first_failure_at ASC,
3135
+ .prepare(`SELECT * FROM protocol_outbox
3136
+ WHERE next_attempt_at <= ?
3137
+ ORDER BY next_attempt_at ASC, first_failure_at ASC,
3008
3138
  peer_id ASC, protocol ASC, message_id ASC`)
3009
3139
  .all(now);
3010
3140
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
3011
3141
  }
3012
3142
  duePage(now, limit) {
3013
3143
  const rows = this.db
3014
- .prepare(`SELECT * FROM protocol_outbox
3015
- WHERE next_attempt_at <= ?
3016
- ORDER BY next_attempt_at ASC, first_failure_at ASC,
3017
- peer_id ASC, protocol ASC, message_id ASC
3144
+ .prepare(`SELECT * FROM protocol_outbox
3145
+ WHERE next_attempt_at <= ?
3146
+ ORDER BY next_attempt_at ASC, first_failure_at ASC,
3147
+ peer_id ASC, protocol ASC, message_id ASC
3018
3148
  LIMIT ?`)
3019
3149
  .all(now, limit);
3020
3150
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
@@ -3104,9 +3234,9 @@ export class SqliteKaNumberStore {
3104
3234
  // `safeIntegers(true)` opts INTO bigint returns so the counter is
3105
3235
  // exact past `Number.MAX_SAFE_INTEGER` (codex PR #976 F6).
3106
3236
  const row = this.db
3107
- .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3108
- VALUES (?, 1)
3109
- ON CONFLICT(author_address) DO UPDATE SET next_number = next_number + 1
3237
+ .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3238
+ VALUES (?, 1)
3239
+ ON CONFLICT(author_address) DO UPDATE SET next_number = next_number + 1
3110
3240
  RETURNING next_number - 1 AS number`)
3111
3241
  .safeIntegers(true)
3112
3242
  .get(author);
@@ -3118,9 +3248,9 @@ export class SqliteKaNumberStore {
3118
3248
  // `excluded.next_number` is the proposed floor from the VALUES row.
3119
3249
  // `better-sqlite3` accepts bigint statement parameters natively.
3120
3250
  this.db
3121
- .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3122
- VALUES (?, ?)
3123
- ON CONFLICT(author_address) DO UPDATE SET
3251
+ .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3252
+ VALUES (?, ?)
3253
+ ON CONFLICT(author_address) DO UPDATE SET
3124
3254
  next_number = MAX(next_number, excluded.next_number)`)
3125
3255
  .run(author, nextNumberFloor);
3126
3256
  }