@origintrail-official/dkg-node-ui 10.0.6 → 10.0.8

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 (33) 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.js +209 -209
  5. package/dist/db.d.ts +127 -1
  6. package/dist/db.d.ts.map +1 -1
  7. package/dist/db.js +1356 -675
  8. package/dist/db.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist-ui/assets/{3d-force-graph-Beyt80PS.js → 3d-force-graph-BOxtBjdk.js} +1 -1
  13. package/dist-ui/assets/{AgentHub-BGlM0TkX.js → AgentHub-BwBMYiaM.js} +1 -1
  14. package/dist-ui/assets/{AgentProfilePage-BK2nv-IH.js → AgentProfilePage-Bufz0Md_.js} +1 -1
  15. package/dist-ui/assets/{ApproveWalletsModal-CZrl51Kn.js → ApproveWalletsModal-B8bQTpiR.js} +3 -3
  16. package/dist-ui/assets/{ConvictionDetailView-CP2aS_DI.js → ConvictionDetailView-CiwcbT2W.js} +1 -1
  17. package/dist-ui/assets/{Network-DiXYe0aB.js → Network-C8AvmIfL.js} +1 -1
  18. package/dist-ui/assets/{OnChainProvenanceCard-BykYhleb.js → OnChainProvenanceCard-Dga51ZJL.js} +1 -1
  19. package/dist-ui/assets/{Operations-Cgg0jP9m.js → Operations-BhRo3Qzg.js} +1 -1
  20. package/dist-ui/assets/{PublishingConviction-DGtPr9Cs.js → PublishingConviction-DzvXFq9z.js} +1 -1
  21. package/dist-ui/assets/{Settings-dSId2mSF.js → Settings-CwO6V6er.js} +1 -1
  22. package/dist-ui/assets/{ccip-DUvYIt92.js → ccip-BxPhmAn8.js} +1 -1
  23. package/dist-ui/assets/{index-DOIKXU1s.js → index-BTD4QwAm.js} +3 -3
  24. package/dist-ui/assets/index-Btc8omeP.js +1661 -0
  25. package/dist-ui/assets/{jsonld-32FQRO67-XkBT-pPz.js → jsonld-B67IUHNU-Xa0S85WB.js} +2 -2
  26. package/dist-ui/assets/{jsonld-BQ18nHYe.js → jsonld-DzfKKFxR.js} +1 -1
  27. package/dist-ui/assets/{renderer-3d-2EVDZII7-DlPF04Rl.js → renderer-3d-OSESAPET-xkehb8k9.js} +2 -2
  28. package/dist-ui/assets/{shikiHighlighter-BhEVT05C.js → shikiHighlighter-DRJC8bc5.js} +1 -1
  29. package/dist-ui/index.html +13 -13
  30. package/package.json +3 -3
  31. package/dist-ui/assets/index-DJH2KcPG.js +0 -1661
  32. /package/dist-ui/assets/{ntriples-ZWBY2WET-DH7OofPp.js → ntriples-SF2LFNMC-DH7OofPp.js} +0 -0
  33. /package/dist-ui/assets/{turtle-JJPK7LJ5-BzTu0283.js → turtle-BXN4L2BY-BzTu0283.js} +0 -0
package/dist/db.js CHANGED
@@ -1,20 +1,24 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { join } from 'node:path';
3
- import { RESPONSE_CACHE_BYTES, } from '@origintrail-official/dkg-core';
3
+ import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, } from '@origintrail-official/dkg-core';
4
4
  export { SqliteChainEventCursorStore, SqliteContextGraphRegistryScanCursorStore, } from './chain-cursor-stores.js';
5
- const SCHEMA_VERSION = 24;
5
+ const SCHEMA_VERSION = 30;
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
9
9
  // hash mismatch on boot). 90 days had been chosen for "metrics history",
10
10
  // but logs were the dominant grower (~1M rows/12d) and pruning was a no-op
11
- // on any DB younger than 90 days. 14 days is still long enough for any
12
- // realistic operator-driven post-mortem while bounding worst-case growth
13
- // of the (now FTS5-less) logs table to ~150 MB. Operators who want longer
11
+ // on any DB younger than 90 days. 14 days is still long enough for a useful
12
+ // operator-driven post-mortem, but the mainnet sync storm proved that time
13
+ // retention alone cannot bound worst-case growth. Operators who want longer
14
14
  // retention can override via `setRetentionDays()`; the setting is persisted
15
- // in the `settings` table and re-read on next boot.
15
+ // in the `settings` table and re-read on next boot. Time alone is not a hard
16
+ // size bound during a log storm, so routine info/debug rows also have a count
17
+ // ceiling. Warning/error rows keep the full operator-selected time window.
16
18
  const DEFAULT_RETENTION_DAYS = 14;
17
19
  const LEGACY_IMPLICIT_RETENTION_DAYS = 90;
20
+ const DEFAULT_ROUTINE_LOG_ROW_CAP = 1_000_000;
21
+ const DEFAULT_LOG_VOLUME_PRUNE_BATCH_ROWS = 25_000;
18
22
  const LOGS_VACUUM_DELETE_THRESHOLD = 10_000;
19
23
  // SQLite reports reusable-but-not-yet-reclaimed pages via freelist_count.
20
24
  // With the default 4 KiB page size this is roughly 4 MiB, large enough
@@ -100,10 +104,14 @@ export class DashboardDB {
100
104
  dataDir;
101
105
  retentionDays;
102
106
  explicitRetentionDays;
107
+ routineLogRowCap;
108
+ logVolumePruneBatchRows;
103
109
  constructor(opts) {
104
110
  this.dataDir = opts.dataDir;
105
111
  this.explicitRetentionDays = opts.retentionDays !== undefined;
106
112
  this.retentionDays = opts.retentionDays ?? DEFAULT_RETENTION_DAYS;
113
+ this.routineLogRowCap = Math.max(0, Math.floor(opts.routineLogRowCap ?? DEFAULT_ROUTINE_LOG_ROW_CAP));
114
+ this.logVolumePruneBatchRows = Math.max(1, Math.floor(opts.logVolumePruneBatchRows ?? DEFAULT_LOG_VOLUME_PRUNE_BATCH_ROWS));
107
115
  this._memoTtlMs = resolveCacheTtlMs(opts.cacheTtlMs);
108
116
  const dbPath = join(opts.dataDir, 'node-ui.db');
109
117
  this.db = new Database(dbPath);
@@ -130,120 +138,178 @@ export class DashboardDB {
130
138
  migrate() {
131
139
  const version = this.db.pragma('user_version', { simple: true });
132
140
  const upgradedExistingDb = version > 0 && version < SCHEMA_VERSION;
133
- if (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;
152
+ `);
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'
157
+ `).get();
158
+ if (!table)
159
+ return;
160
+ const columns = this.db.pragma('table_info(context_graph_join_approval_ledger)');
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));
166
+ `);
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
+ );
173
+ `);
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'
179
+ `).get();
180
+ if (!table)
181
+ return;
182
+ const columns = new Set(this.db.prepare('PRAGMA table_info(sync_checkpoints)').all()
183
+ .map((column) => column.name));
184
+ if (!columns.has('responder_session_id')) {
185
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_id TEXT;`);
186
+ }
187
+ if (!columns.has('responder_session_expires_at')) {
188
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_expires_at INTEGER;`);
189
+ }
190
+ };
191
+ if (version > SCHEMA_VERSION)
192
+ return;
193
+ if (version === SCHEMA_VERSION) {
194
+ // Repair restored/development databases that carry the current version
195
+ // but lost an idempotent schema adjunct.
196
+ ensureJoinApprovalRepairMarker();
197
+ ensureSyncCheckpointResponderSessionColumns();
198
+ ensureJoinPolicyAuditCapTrigger();
134
199
  return;
200
+ }
135
201
  if (version < 1) {
136
- this.db.exec(`
137
- CREATE TABLE IF NOT EXISTS metric_snapshots (
138
- id INTEGER PRIMARY KEY AUTOINCREMENT,
139
- ts INTEGER NOT NULL,
140
- cpu_percent REAL,
141
- mem_used_bytes INTEGER,
142
- mem_total_bytes INTEGER,
143
- disk_used_bytes INTEGER,
144
- disk_total_bytes INTEGER,
145
- heap_used_bytes INTEGER,
146
- uptime_seconds INTEGER,
147
- peer_count INTEGER,
148
- direct_peers INTEGER,
149
- relayed_peers INTEGER,
150
- mesh_peers INTEGER,
151
- contextGraph_count INTEGER,
152
- total_triples INTEGER,
153
- total_kcs INTEGER,
154
- total_kas INTEGER,
155
- store_bytes INTEGER,
156
- confirmed_kcs INTEGER,
157
- tentative_kcs INTEGER,
158
- rpc_latency_ms INTEGER,
159
- rpc_healthy INTEGER,
160
- relay_capacity INTEGER,
161
- relay_reservation_count INTEGER,
162
- relay_active_circuits INTEGER,
163
- relay_bytes_in INTEGER,
164
- relay_bytes_out INTEGER
165
- );
166
- CREATE INDEX IF NOT EXISTS idx_snapshots_ts ON metric_snapshots(ts);
167
-
168
- CREATE TABLE IF NOT EXISTS operations (
169
- id INTEGER PRIMARY KEY AUTOINCREMENT,
170
- operation_id TEXT NOT NULL,
171
- operation_name TEXT NOT NULL,
172
- started_at INTEGER NOT NULL,
173
- duration_ms INTEGER,
174
- status TEXT DEFAULT 'in_progress',
175
- peer_id TEXT,
176
- contextGraph_id TEXT,
177
- triple_count INTEGER,
178
- error_message TEXT,
179
- details TEXT
180
- );
181
- CREATE INDEX IF NOT EXISTS idx_ops_operation_id ON operations(operation_id);
182
- CREATE INDEX IF NOT EXISTS idx_ops_started_at ON operations(started_at);
183
- CREATE INDEX IF NOT EXISTS idx_ops_name ON operations(operation_name);
184
-
185
- CREATE TABLE IF NOT EXISTS logs (
186
- id INTEGER PRIMARY KEY AUTOINCREMENT,
187
- ts INTEGER NOT NULL,
188
- level TEXT NOT NULL,
189
- operation_name TEXT,
190
- operation_id TEXT,
191
- module TEXT,
192
- message TEXT NOT NULL
193
- );
194
- CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts);
195
- CREATE INDEX IF NOT EXISTS idx_logs_operation_id ON logs(operation_id);
196
- CREATE INDEX IF NOT EXISTS idx_logs_level ON logs(level);
197
-
198
- -- NOTE: Earlier schema versions (V1..V14) also created an FTS5
199
- -- virtual table "logs_fts" plus AFTER INSERT/DELETE triggers to
200
- -- keep it in sync with "logs". That fed a free-text search path
201
- -- behind /api/logs?q=... The FTS5 index turned out to dominate
202
- -- on-disk size (multi-GB shadow tables on long-lived nodes) and
203
- -- the corresponding HTTP route had no production consumer
204
- -- (the dashboard log viewer is file-backed via /api/node-log).
205
- -- V15 drops it for fresh installs; the migration below cleans
206
- -- it up for in-place upgrades.
207
-
208
- CREATE TABLE IF NOT EXISTS query_history (
209
- id INTEGER PRIMARY KEY AUTOINCREMENT,
210
- ts INTEGER NOT NULL,
211
- sparql TEXT NOT NULL,
212
- duration_ms INTEGER,
213
- result_count INTEGER,
214
- error TEXT
215
- );
216
- CREATE INDEX IF NOT EXISTS idx_qhist_ts ON query_history(ts);
217
-
218
- CREATE TABLE IF NOT EXISTS saved_queries (
219
- id INTEGER PRIMARY KEY AUTOINCREMENT,
220
- name TEXT NOT NULL,
221
- description TEXT,
222
- sparql TEXT NOT NULL,
223
- created_at INTEGER NOT NULL,
224
- updated_at INTEGER NOT NULL
225
- );
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
+ );
226
292
  `);
227
293
  }
228
294
  if (version < 2) {
229
- this.db.exec(`
230
- CREATE TABLE IF NOT EXISTS operation_phases (
231
- id INTEGER PRIMARY KEY AUTOINCREMENT,
232
- operation_id TEXT NOT NULL,
233
- phase TEXT NOT NULL,
234
- started_at INTEGER NOT NULL,
235
- duration_ms INTEGER,
236
- status TEXT DEFAULT 'in_progress',
237
- details TEXT
238
- );
239
- CREATE INDEX IF NOT EXISTS idx_phases_op ON operation_phases(operation_id);
240
-
241
- ALTER TABLE operations ADD COLUMN gas_used INTEGER;
242
- ALTER TABLE operations ADD COLUMN gas_price_gwei REAL;
243
- ALTER TABLE operations ADD COLUMN gas_cost_eth REAL;
244
- ALTER TABLE operations ADD COLUMN trac_cost REAL;
245
- ALTER TABLE operations ADD COLUMN tx_hash TEXT;
246
- ALTER TABLE operations ADD COLUMN chain_id INTEGER;
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;
247
313
  `);
248
314
  }
249
315
  if (version < 3) {
@@ -253,42 +319,42 @@ export class DashboardDB {
253
319
  // column + the partial unique index in one step; the V11 block
254
320
  // below idempotently adds the column to nodes that upgraded
255
321
  // through versions 3..10 first.
256
- this.db.exec(`
257
- CREATE TABLE IF NOT EXISTS chat_messages (
258
- id INTEGER PRIMARY KEY AUTOINCREMENT,
259
- ts INTEGER NOT NULL,
260
- direction TEXT NOT NULL,
261
- peer TEXT NOT NULL,
262
- peer_name TEXT,
263
- text TEXT NOT NULL,
264
- delivered INTEGER,
265
- message_id TEXT
266
- );
267
- CREATE INDEX IF NOT EXISTS idx_chat_ts ON chat_messages(ts);
268
- CREATE INDEX IF NOT EXISTS idx_chat_peer ON chat_messages(peer);
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);
269
335
  `);
270
336
  }
271
337
  if (version < 4) {
272
- this.db.exec(`
273
- CREATE TABLE IF NOT EXISTS chat_persistence_jobs (
274
- turn_id TEXT PRIMARY KEY,
275
- session_id TEXT NOT NULL,
276
- user_message TEXT NOT NULL,
277
- assistant_reply TEXT NOT NULL,
278
- tool_calls_json TEXT,
279
- status TEXT NOT NULL,
280
- attempts INTEGER NOT NULL DEFAULT 0,
281
- max_attempts INTEGER NOT NULL DEFAULT 3,
282
- next_attempt_at INTEGER NOT NULL,
283
- queued_at INTEGER NOT NULL,
284
- updated_at INTEGER NOT NULL,
285
- store_ms INTEGER,
286
- error_message TEXT
287
- );
288
- CREATE INDEX IF NOT EXISTS idx_chat_persist_status_next
289
- ON chat_persistence_jobs(status, next_attempt_at);
290
- CREATE INDEX IF NOT EXISTS idx_chat_persist_session
291
- ON chat_persistence_jobs(session_id);
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);
292
358
  `);
293
359
  }
294
360
  if (version < 5) {
@@ -297,77 +363,77 @@ export class DashboardDB {
297
363
  // fresh-install CREATE so new nodes get the column + its index in one
298
364
  // step; the V16 block below idempotently adds it to nodes that upgraded
299
365
  // through versions 5..15 first (same pattern as `message_id` in V3/V11).
300
- this.db.exec(`
301
- CREATE TABLE IF NOT EXISTS notifications (
302
- id INTEGER PRIMARY KEY AUTOINCREMENT,
303
- ts INTEGER NOT NULL,
304
- type TEXT NOT NULL,
305
- title TEXT NOT NULL,
306
- message TEXT NOT NULL,
307
- source TEXT,
308
- peer TEXT,
309
- read INTEGER NOT NULL DEFAULT 0,
310
- meta TEXT,
311
- context_graph_id TEXT
312
- );
313
- CREATE INDEX IF NOT EXISTS idx_notif_ts ON notifications(ts);
314
- CREATE INDEX IF NOT EXISTS idx_notif_read ON notifications(read);
315
- CREATE INDEX IF NOT EXISTS idx_notif_cg ON notifications(context_graph_id);
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);
316
382
  `);
317
383
  }
318
384
  if (version < 6) {
319
- this.db.exec(`
320
- CREATE TABLE IF NOT EXISTS settings (
321
- key TEXT PRIMARY KEY,
322
- value TEXT NOT NULL
323
- );
385
+ this.db.exec(`
386
+ CREATE TABLE IF NOT EXISTS settings (
387
+ key TEXT PRIMARY KEY,
388
+ value TEXT NOT NULL
389
+ );
324
390
  `);
325
391
  }
326
392
  if (version < 7) {
327
- this.db.exec(`
328
- CREATE TABLE IF NOT EXISTS context_graph_subscriptions (
329
- context_graph_id TEXT PRIMARY KEY,
330
- name TEXT,
331
- subscribed INTEGER NOT NULL,
332
- synced INTEGER NOT NULL,
333
- meta_synced INTEGER,
334
- on_chain_id TEXT,
335
- sync_scoped INTEGER NOT NULL DEFAULT 1,
336
- updated_at INTEGER NOT NULL
337
- );
338
- CREATE INDEX IF NOT EXISTS idx_cg_subs_sync_scoped
339
- ON context_graph_subscriptions(sync_scoped);
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);
340
406
  `);
341
407
  }
342
408
  if (version < 8) {
343
- this.db.exec(`
344
- CREATE TABLE IF NOT EXISTS context_graph_memberships (
345
- context_graph_id TEXT NOT NULL,
346
- principal_type TEXT NOT NULL,
347
- principal_id TEXT NOT NULL,
348
- role TEXT,
349
- status TEXT NOT NULL,
350
- source TEXT,
351
- display_name TEXT,
352
- metadata TEXT,
353
- first_seen_at INTEGER NOT NULL,
354
- updated_at INTEGER NOT NULL,
355
- PRIMARY KEY (context_graph_id, principal_type, principal_id)
356
- );
357
- CREATE INDEX IF NOT EXISTS idx_cg_members_context
358
- ON context_graph_memberships(context_graph_id);
359
- CREATE INDEX IF NOT EXISTS idx_cg_members_principal
360
- ON context_graph_memberships(principal_type, principal_id);
361
- CREATE INDEX IF NOT EXISTS idx_cg_members_status
362
- ON context_graph_memberships(status);
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);
363
429
  `);
364
430
  }
365
431
  if (version < 9) {
366
432
  const columns = this.db.prepare('PRAGMA table_info(context_graph_subscriptions)').all();
367
433
  if (!columns.some((column) => column.name === 'shared_memory_synced')) {
368
- this.db.exec(`
369
- ALTER TABLE context_graph_subscriptions
370
- ADD COLUMN shared_memory_synced INTEGER;
434
+ this.db.exec(`
435
+ ALTER TABLE context_graph_subscriptions
436
+ ADD COLUMN shared_memory_synced INTEGER;
371
437
  `);
372
438
  }
373
439
  }
@@ -436,10 +502,10 @@ export class DashboardDB {
436
502
  if (!chatCols.has('message_id')) {
437
503
  this.db.exec(`ALTER TABLE chat_messages ADD COLUMN message_id TEXT;`);
438
504
  }
439
- this.db.exec(`
440
- CREATE UNIQUE INDEX IF NOT EXISTS idx_chat_msgid
441
- ON chat_messages(peer, direction, message_id)
442
- WHERE message_id IS NOT NULL;
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;
443
509
  `);
444
510
  }
445
511
  if (version < 12) {
@@ -495,33 +561,33 @@ export class DashboardDB {
495
561
  // `message_id` is enforced via `message_idempotency` instead.
496
562
  // V13 will also preserve `chat_messages.message_id` as
497
563
  // nullable + unwritten for rollback safety.
498
- this.db.exec(`
499
- CREATE TABLE IF NOT EXISTS message_idempotency (
500
- peer_id TEXT NOT NULL,
501
- protocol TEXT NOT NULL,
502
- message_id TEXT NOT NULL,
503
- direction TEXT NOT NULL CHECK (direction IN ('in', 'out')),
504
- response_blob BLOB,
505
- response_size INTEGER NOT NULL DEFAULT 0,
506
- ts INTEGER NOT NULL,
507
- PRIMARY KEY (peer_id, protocol, message_id, direction)
508
- );
509
- CREATE INDEX IF NOT EXISTS idx_idem_ts ON message_idempotency(ts);
510
-
511
- CREATE TABLE IF NOT EXISTS protocol_outbox (
512
- peer_id TEXT NOT NULL,
513
- protocol TEXT NOT NULL,
514
- message_id TEXT NOT NULL,
515
- payload BLOB NOT NULL,
516
- attempts INTEGER NOT NULL DEFAULT 0,
517
- first_failure_at INTEGER NOT NULL,
518
- last_attempt_at INTEGER NOT NULL,
519
- next_attempt_at INTEGER NOT NULL,
520
- last_error TEXT,
521
- PRIMARY KEY (peer_id, protocol, message_id)
522
- );
523
- CREATE INDEX IF NOT EXISTS idx_outbox_next_attempt
524
- ON protocol_outbox(next_attempt_at);
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);
525
591
  `);
526
592
  }
527
593
  if (version < 13) {
@@ -546,8 +612,8 @@ export class DashboardDB {
546
612
  // table rebuild on downgrade.
547
613
  //
548
614
  // No data migration. No new tables. Pure DROP INDEX.
549
- this.db.exec(`
550
- DROP INDEX IF EXISTS idx_chat_msgid;
615
+ this.db.exec(`
616
+ DROP INDEX IF EXISTS idx_chat_msgid;
551
617
  `);
552
618
  }
553
619
  if (version < 14) {
@@ -588,10 +654,10 @@ export class DashboardDB {
588
654
  // operation-correlated log views (`getOperation`,
589
655
  // `getFailedOperations`). Substring/text search moves to
590
656
  // /api/node-log, which already supports `?q=`.
591
- this.db.exec(`
592
- DROP TRIGGER IF EXISTS logs_ai;
593
- DROP TRIGGER IF EXISTS logs_ad;
594
- DROP TABLE IF EXISTS logs_fts;
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;
595
661
  `);
596
662
  // VACUUM cannot run inside a transaction; better-sqlite3 wraps
597
663
  // multi-statement exec() in implicit BEGIN/COMMIT, so we issue
@@ -655,23 +721,23 @@ export class DashboardDB {
655
721
  // re-parsing the daemon log. One row per decision point (sweep / fetch /
656
722
  // promote / already / defer / cursor-advance / core-fill). Pruned by `ts`
657
723
  // like the other event tables.
658
- this.db.exec(`
659
- CREATE TABLE IF NOT EXISTS replication_events (
660
- id INTEGER PRIMARY KEY AUTOINCREMENT,
661
- ts INTEGER NOT NULL,
662
- context_graph_id TEXT NOT NULL,
663
- on_chain_cg_id TEXT,
664
- action TEXT NOT NULL,
665
- ual TEXT,
666
- ordinal INTEGER,
667
- ka_id TEXT,
668
- from_watermark INTEGER,
669
- to_watermark INTEGER,
670
- head INTEGER,
671
- reconciled INTEGER,
672
- pending INTEGER,
673
- detail TEXT
674
- );
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
+ );
675
741
  `);
676
742
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_repl_ts ON replication_events(ts);`);
677
743
  this.db.exec(`CREATE INDEX IF NOT EXISTS idx_repl_cg ON replication_events(context_graph_id, ts);`);
@@ -710,54 +776,54 @@ export class DashboardDB {
710
776
  // the 2^63 INTEGER max and under the uint96 on-chain field. Like
711
777
  // `protocol_outbox`, this state is durable and is NEVER pruned —
712
778
  // reclaiming a number could mint a duplicate kaId.
713
- this.db.exec(`
714
- CREATE TABLE IF NOT EXISTS ka_numbers (
715
- author_address TEXT PRIMARY KEY,
716
- next_number INTEGER NOT NULL
717
- );
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
+ );
718
784
  `);
719
785
  }
720
786
  if (version < 21) {
721
- this.db.exec(`
722
- CREATE TABLE IF NOT EXISTS sync_checkpoints (
723
- key TEXT PRIMARY KEY,
724
- offset INTEGER NOT NULL CHECK (offset >= 0),
725
- updated_at INTEGER NOT NULL,
726
- expires_at INTEGER NOT NULL
727
- );
728
- CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_expires_at
729
- ON sync_checkpoints(expires_at);
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);
730
796
  `);
731
797
  }
732
798
  if (version < 22) {
733
- this.db.exec(`
734
- CREATE TABLE IF NOT EXISTS runtime_cursors (
735
- namespace TEXT NOT NULL,
736
- scope TEXT NOT NULL,
737
- key TEXT NOT NULL,
738
- value INTEGER NOT NULL CHECK (value > 0),
739
- updated_at INTEGER NOT NULL,
740
- PRIMARY KEY (namespace, scope, key)
741
- );
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
+ );
742
808
  `);
743
- this.db.exec(`
744
- CREATE INDEX IF NOT EXISTS idx_runtime_cursors_namespace_scope
745
- ON runtime_cursors(namespace, scope);
809
+ this.db.exec(`
810
+ CREATE INDEX IF NOT EXISTS idx_runtime_cursors_namespace_scope
811
+ ON runtime_cursors(namespace, scope);
746
812
  `);
747
813
  }
748
814
  if (version < 23) {
749
815
  // OT-RFC-59 SC5: durable per-(peer, contextGraph) changelog cursor. NEVER
750
816
  // pruned (see SqliteChangelogCursorStore) — a TTL would defeat the O(delta)
751
817
  // cross-restart catch-up this table exists for.
752
- this.db.exec(`
753
- CREATE TABLE IF NOT EXISTS changelog_cursors (
754
- peer_id TEXT NOT NULL,
755
- context_graph_id TEXT NOT NULL,
756
- era TEXT NOT NULL,
757
- seq INTEGER NOT NULL CHECK (seq >= 0),
758
- updated_at INTEGER NOT NULL,
759
- PRIMARY KEY (peer_id, context_graph_id)
760
- );
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
+ );
761
827
  `);
762
828
  }
763
829
  if (version < 24) {
@@ -766,15 +832,199 @@ export class DashboardDB {
766
832
  // `store.nq` restore does NOT roll back with the RDF store. node-ui.db
767
833
  // survives such a restore, so a rollback under the same era rotates the era
768
834
  // (forcing peers to full-resync instead of silently skipping). Single row.
769
- this.db.exec(`
770
- CREATE TABLE IF NOT EXISTS changelog_era (
771
- id INTEGER PRIMARY KEY CHECK (id = 1),
772
- era TEXT NOT NULL,
773
- high_seq INTEGER NOT NULL CHECK (high_seq >= 0),
774
- updated_at INTEGER NOT NULL
775
- );
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
+ );
842
+ `);
843
+ }
844
+ if (version < 25) {
845
+ // Durable security audit for private-CG open enrollment. V25/V26 also
846
+ // used this table as admission state; V27 projects those legacy rows into
847
+ // 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);
776
868
  `);
777
869
  }
870
+ if (version < 26) {
871
+ // Bound flood-generated decision noise without letting that same flood
872
+ // erase the two records needed to reconstruct a private-CG admission:
873
+ // who changed the policy, and who was automatically admitted under it.
874
+ // V26 additionally retained live reservations because they still enforced
875
+ // the rolling one-hour ceilings. V27 removes that dependency below.
876
+ //
877
+ // V25 already installed this trigger, so DROP before CREATE is required:
878
+ // CREATE TRIGGER IF NOT EXISTS would silently preserve the old predicate
879
+ // on an upgraded database.
880
+ this.db.exec('DROP TRIGGER IF EXISTS cap_cg_join_policy_audit_rows;');
881
+ }
882
+ if (version < 27) {
883
+ // Operational admission state is deliberately separate from the audit
884
+ // stream. Audit retention and event naming must never alter rate-limit,
885
+ // 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);
917
+ `);
918
+ // Best-effort one-time projection for V25/V26 databases. Invalid legacy
919
+ // JSON cannot abort startup; only rows with a typed numeric epoch can be
920
+ // authoritative operational state. For duplicate audit reservations,
921
+ // 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;
988
+ `);
989
+ // V26's trigger protected live reservation audit rows because they were
990
+ // operational state. The ledger makes that coupling obsolete, so replace
991
+ // the trigger while preserving the flood-resistant proof exemptions.
992
+ this.db.exec('DROP TRIGGER IF EXISTS cap_cg_join_policy_audit_rows;');
993
+ }
994
+ if (version < 28) {
995
+ // A reservation alone does not prove that admission crossed its
996
+ // membership boundary. Persist that distinction so an exact retry can
997
+ // finish moderation/audit after delegation expiry or daemon restart.
998
+ ensureJoinApprovalRepairMarker();
999
+ }
1000
+ // Keep this repair outside the version gate. Restored/development DBs can
1001
+ // carry the current user_version while missing a trigger; recreating it is
1002
+ // idempotent and keeps the audit bound fail-closed on every open.
1003
+ ensureJoinPolicyAuditCapTrigger();
1004
+ 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);
1020
+ `);
1021
+ }
1022
+ if (version < 30) {
1023
+ // OFFSET pagination is scoped to an immutable responder row list. Keep
1024
+ // the opaque responder token beside the verified offset so a requester
1025
+ // restart can resume that same list instead of discarding durable work.
1026
+ ensureSyncCheckpointResponderSessionColumns();
1027
+ }
778
1028
  this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
779
1029
  if (upgradedExistingDb && !this.explicitRetentionDays) {
780
1030
  this.retentionDays = LEGACY_IMPLICIT_RETENTION_DAYS;
@@ -807,7 +1057,21 @@ export class DashboardDB {
807
1057
  this.db.exec(`DELETE FROM chat_persistence_jobs WHERE updated_at < ${cutoff} AND status IN ('stored', 'failed')`);
808
1058
  this.db.exec(`DELETE FROM notifications WHERE ts < ${cutoff}`);
809
1059
  this.db.exec(`DELETE FROM replication_events WHERE ts < ${cutoff}`);
1060
+ this.db.exec(`DELETE FROM context_graph_join_policy_audit WHERE ts < ${cutoff}`);
1061
+ // The admission ledger has its own typed lifetime. It intentionally does
1062
+ // not depend on whether the corresponding audit rows still exist. Preserve
1063
+ // the historical retention envelope for durable commit idempotency while
1064
+ // allowing fully expired reservation/commit state to age out.
1065
+ this.db.prepare(`
1066
+ DELETE FROM context_graph_join_approval_ledger
1067
+ WHERE reserved_at < ?
1068
+ AND (committed_at IS NULL OR committed_at < ?)
1069
+ `).run(cutoff, cutoff);
810
1070
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE expires_at < ?`).run(Date.now());
1071
+ // Reconcile negatives are accelerators, never historical records. Once the
1072
+ // retry window elapses they must not survive indefinitely or accumulate one
1073
+ // row per previously-seen KA across restarts.
1074
+ this.db.prepare(`DELETE FROM vm_reconcile_negative_cache WHERE next_retry_at < ?`).run(Date.now());
811
1075
  // Universal Messenger idempotency table. Shorter TTL than the
812
1076
  // operator retention: no realistic dedup window extends beyond
813
1077
  // a day. The protocol_outbox table is intentionally not pruned
@@ -815,20 +1079,12 @@ export class DashboardDB {
815
1079
  // SqliteProtocolOutboxStore.dropExpired().
816
1080
  const messengerCutoff = Date.now() - 24 * 60 * 60 * 1000;
817
1081
  this.db.exec(`DELETE FROM message_idempotency WHERE ts < ${messengerCutoff}`);
818
- // Reclaim free pages from the file. Without this, the SQLite file
819
- // size only ever grows DELETE / DROP just marks pages reusable,
820
- // it does not return them to the OS. Vacuum when prune removed a
821
- // meaningful number of log rows, or when a previous migration/drop
822
- // left a large freelist behind without deleting any retained logs.
823
- const freePages = Number(this.db.pragma('freelist_count', { simple: true }) ?? 0);
824
- if (logsDeleted > LOGS_VACUUM_DELETE_THRESHOLD || freePages > VACUUM_FREE_PAGE_THRESHOLD) {
825
- try {
826
- this.db.exec(`VACUUM`);
827
- }
828
- catch {
829
- // VACUUM requires an exclusive lock. If another connection is
830
- // holding the DB open we skip and retry on the next prune.
831
- }
1082
+ // Avoid rebuilding an oversized DB at startup while millions of routine
1083
+ // rows are still live. The daemon trims that backlog in bounded batches;
1084
+ // the final batch performs one compacting VACUUM. Databases already under
1085
+ // the cap retain the established time-prune reclamation behaviour.
1086
+ if (!this.hasRoutineLogOverflow()) {
1087
+ this.reclaimFreePagesIfNeeded(logsDeleted > LOGS_VACUUM_DELETE_THRESHOLD);
832
1088
  }
833
1089
  // Return the WAL file itself to the OS. journal_size_limit bounds it
834
1090
  // in steady state, but a TRUNCATE checkpoint here shrinks it promptly
@@ -836,6 +1092,96 @@ export class DashboardDB {
836
1092
  // which rewrites the whole DB through the WAL and momentarily grows
837
1093
  // it. Runs unconditionally — independent of the VACUUM gate — because
838
1094
  // an idle node still wants its -wal reclaimed.
1095
+ this.truncateWal('prune');
1096
+ }
1097
+ /**
1098
+ * Remove one bounded batch of the oldest routine (non-warning/error) logs.
1099
+ * A count cap complements time retention: a high-rate sync storm can create
1100
+ * millions of rows inside a single day, long before a 14-day cutoff applies.
1101
+ *
1102
+ * Deletion is deliberately incremental so an upgrade does not block node
1103
+ * startup on a multi-GB transaction. Once the backlog reaches the cap, one
1104
+ * VACUUM returns the accumulated free pages to the OS and the file shrinks.
1105
+ */
1106
+ pruneLogVolumeBatch() {
1107
+ const overflowCutoff = this.routineLogOverflowCutoff();
1108
+ if (overflowCutoff === null) {
1109
+ const reclaim = this.reclaimFreePagesIfNeeded(false);
1110
+ if (!reclaim.reclaimPending)
1111
+ this.truncateWal('log-volume prune');
1112
+ return {
1113
+ deleted: 0,
1114
+ status: reclaim.reclaimPending
1115
+ ? 'reclaim-pending'
1116
+ : reclaim.compacted
1117
+ ? 'done-compacted'
1118
+ : 'done',
1119
+ };
1120
+ }
1121
+ const deleted = this.db.prepare(`
1122
+ DELETE FROM logs
1123
+ WHERE id IN (
1124
+ SELECT id
1125
+ FROM logs
1126
+ WHERE id <= @cutoff
1127
+ AND level NOT IN ('warn', 'error')
1128
+ ORDER BY id ASC
1129
+ LIMIT @batchRows
1130
+ )
1131
+ `).run({
1132
+ cutoff: overflowCutoff,
1133
+ batchRows: this.logVolumePruneBatchRows,
1134
+ }).changes;
1135
+ // If the batch filled, conservatively schedule another tick. An exact-size
1136
+ // final batch costs one extra cheap probe before compaction, which is safer
1137
+ // than running a second million-row count after every deletion.
1138
+ const hasMore = deleted === this.logVolumePruneBatchRows;
1139
+ const reclaim = hasMore
1140
+ ? { compacted: false, reclaimPending: false }
1141
+ : this.reclaimFreePagesIfNeeded(deleted > LOGS_VACUUM_DELETE_THRESHOLD);
1142
+ if (!hasMore && !reclaim.reclaimPending)
1143
+ this.truncateWal('log-volume prune');
1144
+ return {
1145
+ deleted,
1146
+ status: hasMore
1147
+ ? 'more'
1148
+ : reclaim.reclaimPending
1149
+ ? 'reclaim-pending'
1150
+ : reclaim.compacted
1151
+ ? 'done-compacted'
1152
+ : 'done',
1153
+ };
1154
+ }
1155
+ routineLogOverflowCutoff() {
1156
+ const row = this.db.prepare(`
1157
+ SELECT id
1158
+ FROM logs
1159
+ WHERE level NOT IN ('warn', 'error')
1160
+ ORDER BY id DESC
1161
+ LIMIT 1 OFFSET ?
1162
+ `).get(this.routineLogRowCap);
1163
+ return row?.id ?? null;
1164
+ }
1165
+ hasRoutineLogOverflow() {
1166
+ return this.routineLogOverflowCutoff() !== null;
1167
+ }
1168
+ reclaimFreePagesIfNeeded(force) {
1169
+ // DELETE / DROP only marks pages reusable; VACUUM is what returns them to
1170
+ // the OS. A failed exclusive-lock/disk attempt remains pending for a later
1171
+ // background retry and never prevents the node from running.
1172
+ const freePages = Number(this.db.pragma('freelist_count', { simple: true }) ?? 0);
1173
+ if (!force && freePages <= VACUUM_FREE_PAGE_THRESHOLD) {
1174
+ return { compacted: false, reclaimPending: false };
1175
+ }
1176
+ try {
1177
+ this.db.exec(`VACUUM`);
1178
+ return { compacted: true, reclaimPending: false };
1179
+ }
1180
+ catch {
1181
+ return { compacted: false, reclaimPending: true };
1182
+ }
1183
+ }
1184
+ truncateWal(reason) {
839
1185
  try {
840
1186
  // wal_checkpoint signals reader contention through its result row
841
1187
  // (`busy = 1`), NOT by throwing — so a busy checkpoint leaves the
@@ -845,7 +1191,7 @@ export class DashboardDB {
845
1191
  // rather than indistinguishable from a real one.
846
1192
  const [checkpoint] = this.db.pragma('wal_checkpoint(TRUNCATE)');
847
1193
  if (checkpoint?.busy) {
848
- console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) busy — WAL not reclaimed this prune ` +
1194
+ console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) busy — WAL not reclaimed during ${reason} ` +
849
1195
  `(log=${checkpoint.log}, checkpointed=${checkpoint.checkpointed}); retried next prune`);
850
1196
  }
851
1197
  }
@@ -856,7 +1202,7 @@ export class DashboardDB {
856
1202
  // better-sqlite3 change turns lock or I/O errors into throws, the
857
1203
  // stalled WAL reclaim is visible in the daemon log instead of only
858
1204
  // showing up later as unexplained disk growth. Never block prune.
859
- console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) failed — WAL not reclaimed this prune: ` +
1205
+ console.warn(`[DashboardDB] wal_checkpoint(TRUNCATE) failed — WAL not reclaimed during ${reason}: ` +
860
1206
  `${err instanceof Error ? err.message : String(err)}`);
861
1207
  }
862
1208
  }
@@ -913,50 +1259,50 @@ export class DashboardDB {
913
1259
  }
914
1260
  // --- Metric snapshots ---
915
1261
  insertSnapshot(snap) {
916
- this.stmt('insertSnapshot', `
917
- INSERT INTO metric_snapshots (
918
- ts, cpu_percent, mem_used_bytes, mem_total_bytes,
919
- disk_used_bytes, disk_total_bytes, heap_used_bytes, uptime_seconds,
920
- peer_count, direct_peers, relayed_peers, mesh_peers, contextGraph_count,
921
- total_triples, total_kcs, total_kas, store_bytes,
922
- confirmed_kcs, tentative_kcs, rpc_latency_ms, rpc_healthy,
923
- relay_capacity, relay_reservation_count, relay_active_circuits,
924
- relay_bytes_in, relay_bytes_out
925
- ) VALUES (
926
- @ts, @cpu_percent, @mem_used_bytes, @mem_total_bytes,
927
- @disk_used_bytes, @disk_total_bytes, @heap_used_bytes, @uptime_seconds,
928
- @peer_count, @direct_peers, @relayed_peers, @mesh_peers, @contextGraph_count,
929
- @total_triples, @total_kcs, @total_kas, @store_bytes,
930
- @confirmed_kcs, @tentative_kcs, @rpc_latency_ms, @rpc_healthy,
931
- @relay_capacity, @relay_reservation_count, @relay_active_circuits,
932
- @relay_bytes_in, @relay_bytes_out
933
- )
1262
+ this.stmt('insertSnapshot', `
1263
+ INSERT INTO metric_snapshots (
1264
+ ts, cpu_percent, mem_used_bytes, mem_total_bytes,
1265
+ disk_used_bytes, disk_total_bytes, heap_used_bytes, uptime_seconds,
1266
+ peer_count, direct_peers, relayed_peers, mesh_peers, contextGraph_count,
1267
+ total_triples, total_kcs, total_kas, store_bytes,
1268
+ confirmed_kcs, tentative_kcs, rpc_latency_ms, rpc_healthy,
1269
+ relay_capacity, relay_reservation_count, relay_active_circuits,
1270
+ relay_bytes_in, relay_bytes_out
1271
+ ) VALUES (
1272
+ @ts, @cpu_percent, @mem_used_bytes, @mem_total_bytes,
1273
+ @disk_used_bytes, @disk_total_bytes, @heap_used_bytes, @uptime_seconds,
1274
+ @peer_count, @direct_peers, @relayed_peers, @mesh_peers, @contextGraph_count,
1275
+ @total_triples, @total_kcs, @total_kas, @store_bytes,
1276
+ @confirmed_kcs, @tentative_kcs, @rpc_latency_ms, @rpc_healthy,
1277
+ @relay_capacity, @relay_reservation_count, @relay_active_circuits,
1278
+ @relay_bytes_in, @relay_bytes_out
1279
+ )
934
1280
  `).run(snap);
935
1281
  }
936
1282
  getLatestSnapshot() {
937
1283
  return this.db.prepare('SELECT * FROM metric_snapshots ORDER BY ts DESC LIMIT 1').get();
938
1284
  }
939
1285
  upsertContextGraphSubscription(record) {
940
- this.stmt('upsertContextGraphSubscription', `
941
- INSERT INTO context_graph_subscriptions (
942
- context_graph_id, name, subscribed, synced, shared_memory_synced, meta_synced,
943
- on_chain_id, on_chain_hash, last_reconciled_ordinal, core_hosted, sync_scoped, updated_at
944
- ) VALUES (
945
- @context_graph_id, @name, @subscribed, @synced, @shared_memory_synced, @meta_synced,
946
- @on_chain_id, @on_chain_hash, @last_reconciled_ordinal, @core_hosted, @sync_scoped, @updated_at
947
- )
948
- ON CONFLICT(context_graph_id) DO UPDATE SET
949
- name = excluded.name,
950
- subscribed = excluded.subscribed,
951
- synced = excluded.synced,
952
- shared_memory_synced = excluded.shared_memory_synced,
953
- meta_synced = excluded.meta_synced,
954
- on_chain_id = excluded.on_chain_id,
955
- on_chain_hash = excluded.on_chain_hash,
956
- last_reconciled_ordinal = excluded.last_reconciled_ordinal,
957
- core_hosted = excluded.core_hosted,
958
- sync_scoped = excluded.sync_scoped,
959
- updated_at = excluded.updated_at
1286
+ this.stmt('upsertContextGraphSubscription', `
1287
+ INSERT INTO context_graph_subscriptions (
1288
+ context_graph_id, name, subscribed, synced, shared_memory_synced, meta_synced,
1289
+ on_chain_id, on_chain_hash, last_reconciled_ordinal, core_hosted, sync_scoped, updated_at
1290
+ ) VALUES (
1291
+ @context_graph_id, @name, @subscribed, @synced, @shared_memory_synced, @meta_synced,
1292
+ @on_chain_id, @on_chain_hash, @last_reconciled_ordinal, @core_hosted, @sync_scoped, @updated_at
1293
+ )
1294
+ ON CONFLICT(context_graph_id) DO UPDATE SET
1295
+ name = excluded.name,
1296
+ subscribed = excluded.subscribed,
1297
+ synced = excluded.synced,
1298
+ shared_memory_synced = excluded.shared_memory_synced,
1299
+ meta_synced = excluded.meta_synced,
1300
+ on_chain_id = excluded.on_chain_id,
1301
+ on_chain_hash = excluded.on_chain_hash,
1302
+ last_reconciled_ordinal = excluded.last_reconciled_ordinal,
1303
+ core_hosted = excluded.core_hosted,
1304
+ sync_scoped = excluded.sync_scoped,
1305
+ updated_at = excluded.updated_at
960
1306
  `).run({
961
1307
  context_graph_id: record.context_graph_id,
962
1308
  name: record.name ?? null,
@@ -979,7 +1325,302 @@ export class DashboardDB {
979
1325
  return this.stmt('getContextGraphSubscription', 'SELECT * FROM context_graph_subscriptions WHERE context_graph_id = ?').get(contextGraphId);
980
1326
  }
981
1327
  deleteContextGraphSubscription(contextGraphId) {
982
- this.stmt('deleteContextGraphSubscription', 'DELETE FROM context_graph_subscriptions WHERE context_graph_id = ?').run(contextGraphId);
1328
+ const remove = this.db.transaction(() => {
1329
+ this.stmt('deleteContextGraphSubscription', 'DELETE FROM context_graph_subscriptions WHERE context_graph_id = ?').run(contextGraphId);
1330
+ this.stmt('deleteContextGraphReadinessProvenance', 'DELETE FROM settings WHERE key = ?')
1331
+ .run(this.contextGraphReadinessProvenanceKey(contextGraphId));
1332
+ });
1333
+ remove();
1334
+ }
1335
+ /**
1336
+ * CLI-owned, durable provenance for context-graph readiness flags.
1337
+ *
1338
+ * Subscription rows predate per-plane proof and can therefore contain the
1339
+ * v10.0.6 false-ready shape (`synced=1`, `shared_memory_synced=1`) after an
1340
+ * unrelated peer returned an empty response. Keep the proof out of the
1341
+ * agent-owned subscription upsert so older/custom agent stores remain
1342
+ * source-compatible and routine subscription persistence cannot overwrite a
1343
+ * proof established by the daemon's catch-up classifier.
1344
+ */
1345
+ getContextGraphReadinessProvenance(contextGraphId) {
1346
+ const row = this.stmt('getContextGraphReadinessProvenance', 'SELECT value FROM settings WHERE key = ?').get(this.contextGraphReadinessProvenanceKey(contextGraphId));
1347
+ if (!row)
1348
+ return null;
1349
+ try {
1350
+ const parsed = JSON.parse(row.value);
1351
+ if (!Number.isInteger(parsed.version) || (parsed.version ?? 0) < 1)
1352
+ return null;
1353
+ return {
1354
+ version: parsed.version,
1355
+ durableVerified: parsed.durableVerified === true,
1356
+ sharedMemoryVerified: parsed.sharedMemoryVerified === true,
1357
+ updatedAt: typeof parsed.updatedAt === 'number' && Number.isFinite(parsed.updatedAt)
1358
+ ? parsed.updatedAt
1359
+ : 0,
1360
+ };
1361
+ }
1362
+ catch {
1363
+ return null;
1364
+ }
1365
+ }
1366
+ setContextGraphReadinessProvenance(contextGraphId, provenance) {
1367
+ const record = {
1368
+ version: provenance.version,
1369
+ durableVerified: provenance.durableVerified,
1370
+ sharedMemoryVerified: provenance.sharedMemoryVerified,
1371
+ updatedAt: provenance.updatedAt ?? Date.now(),
1372
+ };
1373
+ this.stmt('setContextGraphReadinessProvenance', 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(this.contextGraphReadinessProvenanceKey(contextGraphId), JSON.stringify(record));
1374
+ }
1375
+ contextGraphReadinessProvenanceKey(contextGraphId) {
1376
+ return `contextGraphReadiness:${contextGraphId}`;
1377
+ }
1378
+ // --- Private context-graph join policy + audit ---
1379
+ getContextGraphJoinPolicy(contextGraphId) {
1380
+ const row = this.stmt('getContextGraphJoinPolicy', 'SELECT value FROM settings WHERE key = ?').get(this.contextGraphJoinPolicyKey(contextGraphId));
1381
+ if (!row)
1382
+ return null;
1383
+ try {
1384
+ return parseContextGraphJoinPolicyRecord(JSON.parse(row.value), contextGraphId);
1385
+ }
1386
+ catch {
1387
+ // Corrupt policy state is indistinguishable from no policy. The agent's
1388
+ // default is manual, so this is intentionally fail-closed.
1389
+ return null;
1390
+ }
1391
+ }
1392
+ setContextGraphJoinPolicy(record) {
1393
+ this.stmt('setContextGraphJoinPolicy', 'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)').run(this.contextGraphJoinPolicyKey(record.contextGraphId), JSON.stringify(record));
1394
+ }
1395
+ setContextGraphJoinPolicyWithAudit(record, event) {
1396
+ const transition = this.db.transaction(() => {
1397
+ this.setContextGraphJoinPolicy(record);
1398
+ this.appendContextGraphJoinPolicyAudit(event);
1399
+ });
1400
+ transition();
1401
+ }
1402
+ appendContextGraphJoinPolicyAudit(event) {
1403
+ this.stmt('appendContextGraphJoinPolicyAudit', `
1404
+ INSERT INTO context_graph_join_policy_audit (
1405
+ ts, context_graph_id, event_type, actor, agent_address, outcome,
1406
+ reason, request_digest, policy_version, details
1407
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1408
+ `).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);
1409
+ }
1410
+ reserveContextGraphAutomaticApproval(input) {
1411
+ const reserve = this.db.transaction(() => {
1412
+ const since = input.timestamp - 60 * 60 * 1000;
1413
+ const cgRow = this.stmt('countContextGraphAutomaticApprovalReservations', `
1414
+ SELECT COUNT(*) AS count
1415
+ FROM context_graph_join_approval_ledger
1416
+ WHERE context_graph_id = ?
1417
+ AND reserved_at >= ?
1418
+ `).get(input.contextGraphId, since);
1419
+ const nodeRow = this.stmt('countNodeAutomaticApprovalReservations', `
1420
+ SELECT COUNT(*) AS count
1421
+ FROM context_graph_join_approval_ledger
1422
+ WHERE reserved_at >= ?
1423
+ `).get(since);
1424
+ const contextGraphCount = Number(cgRow?.count ?? 0);
1425
+ const nodeCount = Number(nodeRow?.count ?? 0);
1426
+ const existing = this.stmt('findContextGraphAutomaticApprovalReservation', `
1427
+ SELECT 1 AS found
1428
+ FROM context_graph_join_approval_ledger
1429
+ WHERE context_graph_id = ?
1430
+ AND request_digest = ?
1431
+ AND policy_epoch = ?
1432
+ AND reserved_at >= ?
1433
+ LIMIT 1
1434
+ `).get(input.contextGraphId, input.requestDigest, input.policyEpoch, since);
1435
+ if (existing) {
1436
+ return {
1437
+ allowed: true,
1438
+ contextGraphApprovalsLastHour: contextGraphCount,
1439
+ nodeApprovalsLastHour: nodeCount,
1440
+ };
1441
+ }
1442
+ if (contextGraphCount >= input.contextGraphLimit) {
1443
+ return {
1444
+ allowed: false,
1445
+ contextGraphApprovalsLastHour: contextGraphCount,
1446
+ nodeApprovalsLastHour: nodeCount,
1447
+ reason: 'context-graph-rate-limit',
1448
+ };
1449
+ }
1450
+ if (nodeCount >= input.nodeLimit) {
1451
+ return {
1452
+ allowed: false,
1453
+ contextGraphApprovalsLastHour: contextGraphCount,
1454
+ nodeApprovalsLastHour: nodeCount,
1455
+ reason: 'node-rate-limit',
1456
+ };
1457
+ }
1458
+ // The typed ledger row and its audit projection share one transaction:
1459
+ // failures cannot consume quota without an audit event or emit an audit
1460
+ // reservation without durable operational state. An expired replay of
1461
+ // the same digest+epoch refreshes its reservation timestamp and consumes
1462
+ // the rolling-window quota again, matching the former append-only model.
1463
+ this.stmt('upsertContextGraphAutomaticApprovalReservation', `
1464
+ INSERT INTO context_graph_join_approval_ledger (
1465
+ context_graph_id, request_digest, policy_epoch, reserved_at,
1466
+ state, committed_at, actor, agent_address, policy_version
1467
+ ) VALUES (?, ?, ?, ?, 'reserved', NULL, ?, ?, ?)
1468
+ ON CONFLICT(context_graph_id, request_digest, policy_epoch) DO UPDATE SET
1469
+ reserved_at = excluded.reserved_at,
1470
+ actor = excluded.actor,
1471
+ agent_address = excluded.agent_address,
1472
+ policy_version = excluded.policy_version
1473
+ `).run(input.contextGraphId, input.requestDigest, input.policyEpoch, input.timestamp, input.actor, input.agentAddress, input.policyVersion);
1474
+ this.appendContextGraphJoinPolicyAudit({
1475
+ timestamp: input.timestamp,
1476
+ contextGraphId: input.contextGraphId,
1477
+ eventType: 'join_auto_reservation',
1478
+ actor: input.actor,
1479
+ agentAddress: input.agentAddress,
1480
+ outcome: 'reserved',
1481
+ requestDigest: input.requestDigest,
1482
+ policyVersion: input.policyVersion,
1483
+ details: {
1484
+ contextGraphLimit: input.contextGraphLimit,
1485
+ nodeLimit: input.nodeLimit,
1486
+ policyEpoch: input.policyEpoch,
1487
+ },
1488
+ });
1489
+ return {
1490
+ allowed: true,
1491
+ contextGraphApprovalsLastHour: contextGraphCount + 1,
1492
+ nodeApprovalsLastHour: nodeCount + 1,
1493
+ };
1494
+ });
1495
+ return reserve();
1496
+ }
1497
+ markContextGraphAutomaticApprovalRepairPending(input) {
1498
+ const marked = this.stmt('markContextGraphAutomaticApprovalRepairPending', `
1499
+ UPDATE context_graph_join_approval_ledger
1500
+ SET repair_pending = 1
1501
+ WHERE context_graph_id = ?
1502
+ AND request_digest = ?
1503
+ AND policy_epoch = ?
1504
+ AND state = 'reserved'
1505
+ `).run(input.contextGraphId, input.requestDigest, input.policyEpoch);
1506
+ return marked.changes === 1;
1507
+ }
1508
+ getContextGraphAutomaticApprovalRepair(contextGraphId, requestDigest) {
1509
+ const repair = this.stmt('getContextGraphAutomaticApprovalRepair', `
1510
+ SELECT policy_epoch, actor, agent_address
1511
+ FROM context_graph_join_approval_ledger
1512
+ WHERE context_graph_id = ?
1513
+ AND request_digest = ?
1514
+ AND state = 'reserved'
1515
+ AND repair_pending = 1
1516
+ ORDER BY reserved_at DESC
1517
+ LIMIT 1
1518
+ `).get(contextGraphId, requestDigest);
1519
+ return repair
1520
+ ? {
1521
+ policyEpoch: repair.policy_epoch,
1522
+ actor: repair.actor,
1523
+ agentAddress: repair.agent_address,
1524
+ }
1525
+ : null;
1526
+ }
1527
+ commitContextGraphAutomaticApproval(input) {
1528
+ const commit = this.db.transaction(() => {
1529
+ const reservation = this.stmt('findExactContextGraphAutomaticApprovalReservation', `
1530
+ SELECT context_graph_id, request_digest, policy_epoch, reserved_at,
1531
+ state, committed_at, actor, agent_address, policy_version
1532
+ FROM context_graph_join_approval_ledger
1533
+ WHERE context_graph_id = ?
1534
+ AND request_digest = ?
1535
+ AND policy_epoch = ?
1536
+ LIMIT 1
1537
+ `).get(input.contextGraphId, input.requestDigest, input.policyEpoch);
1538
+ if (!reservation)
1539
+ return false;
1540
+ if (reservation.state === 'committed')
1541
+ return true;
1542
+ const updated = this.stmt('commitContextGraphAutomaticApprovalReservation', `
1543
+ UPDATE context_graph_join_approval_ledger
1544
+ SET state = 'committed', committed_at = ?, repair_pending = 0
1545
+ WHERE context_graph_id = ?
1546
+ AND request_digest = ?
1547
+ AND policy_epoch = ?
1548
+ AND state = 'reserved'
1549
+ `).run(input.timestamp, reservation.context_graph_id, reservation.request_digest, reservation.policy_epoch);
1550
+ if (updated.changes !== 1)
1551
+ return false;
1552
+ this.appendContextGraphJoinPolicyAudit({
1553
+ timestamp: input.timestamp,
1554
+ contextGraphId: input.contextGraphId,
1555
+ eventType: 'join_admission_committed',
1556
+ actor: reservation.actor || input.actor,
1557
+ agentAddress: reservation.agent_address || input.agentAddress,
1558
+ outcome: 'approved',
1559
+ requestDigest: input.requestDigest,
1560
+ policyVersion: reservation.policy_version,
1561
+ details: {
1562
+ ...input.details,
1563
+ policyEpoch: reservation.policy_epoch,
1564
+ },
1565
+ });
1566
+ return true;
1567
+ });
1568
+ return commit();
1569
+ }
1570
+ getContextGraphAutomaticApprovalUsage(contextGraphId, timestamp) {
1571
+ const since = timestamp - 60 * 60 * 1000;
1572
+ const contextGraphApprovalsLastHour = Number(this.stmt('getContextGraphAutomaticApprovalUsage', `
1573
+ SELECT COUNT(*) AS count
1574
+ FROM context_graph_join_approval_ledger
1575
+ WHERE context_graph_id = ?
1576
+ AND reserved_at >= ?
1577
+ `).get(contextGraphId, since)?.count ?? 0);
1578
+ const nodeApprovalsLastHour = Number(this.stmt('getNodeAutomaticApprovalUsage', `
1579
+ SELECT COUNT(*) AS count
1580
+ FROM context_graph_join_approval_ledger
1581
+ WHERE reserved_at >= ?
1582
+ `).get(since)?.count ?? 0);
1583
+ return { contextGraphApprovalsLastHour, nodeApprovalsLastHour };
1584
+ }
1585
+ listContextGraphJoinPolicyAudit(contextGraphId) {
1586
+ return this.stmt('listContextGraphJoinPolicyAudit', `
1587
+ SELECT id, ts, context_graph_id, event_type, actor, agent_address,
1588
+ outcome, reason, request_digest, policy_version, details
1589
+ FROM context_graph_join_policy_audit
1590
+ WHERE context_graph_id = ?
1591
+ ORDER BY id ASC
1592
+ `).all(contextGraphId);
1593
+ }
1594
+ contextGraphJoinPolicyKey(contextGraphId) {
1595
+ return `contextGraphJoinPolicy:${contextGraphId}`;
1596
+ }
1597
+ upsertVmReconcileNegative(record) {
1598
+ this.stmt('upsertVmReconcileNegative', `
1599
+ INSERT INTO vm_reconcile_negative_cache (
1600
+ cache_key, context_graph_id, failures, next_retry_at, swm_gen,
1601
+ candidate_namespaces, peer_topology_key, updated_at
1602
+ ) VALUES (
1603
+ @cache_key, @context_graph_id, @failures, @next_retry_at, @swm_gen,
1604
+ @candidate_namespaces, @peer_topology_key, @updated_at
1605
+ )
1606
+ ON CONFLICT(cache_key) DO UPDATE SET
1607
+ context_graph_id = excluded.context_graph_id,
1608
+ failures = excluded.failures,
1609
+ next_retry_at = excluded.next_retry_at,
1610
+ swm_gen = excluded.swm_gen,
1611
+ candidate_namespaces = excluded.candidate_namespaces,
1612
+ peer_topology_key = excluded.peer_topology_key,
1613
+ updated_at = excluded.updated_at
1614
+ `).run(record);
1615
+ }
1616
+ getVmReconcileNegative(cacheKey) {
1617
+ return this.stmt('getVmReconcileNegative', 'SELECT * FROM vm_reconcile_negative_cache WHERE cache_key = ?').get(cacheKey);
1618
+ }
1619
+ deleteVmReconcileNegative(cacheKey) {
1620
+ this.stmt('deleteVmReconcileNegative', 'DELETE FROM vm_reconcile_negative_cache WHERE cache_key = ?').run(cacheKey);
1621
+ }
1622
+ deleteVmReconcileNegativesForContextGraph(contextGraphId) {
1623
+ this.stmt('deleteVmReconcileNegativesForContextGraph', 'DELETE FROM vm_reconcile_negative_cache WHERE context_graph_id = ?').run(contextGraphId);
983
1624
  }
984
1625
  // --- Phase F: chain-driven VM reconciliation telemetry ---
985
1626
  /**
@@ -988,14 +1629,14 @@ export class DashboardDB {
988
1629
  * coerced defensively so a malformed event can never abort the insert.
989
1630
  */
990
1631
  insertReplicationEvent(ev) {
991
- this.stmt('insertReplicationEvent', `
992
- INSERT INTO replication_events (
993
- ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
994
- from_watermark, to_watermark, head, reconciled, pending, detail
995
- ) VALUES (
996
- @ts, @context_graph_id, @on_chain_cg_id, @action, @ual, @ordinal, @ka_id,
997
- @from_watermark, @to_watermark, @head, @reconciled, @pending, @detail
998
- )
1632
+ this.stmt('insertReplicationEvent', `
1633
+ INSERT INTO replication_events (
1634
+ ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1635
+ from_watermark, to_watermark, head, reconciled, pending, detail
1636
+ ) VALUES (
1637
+ @ts, @context_graph_id, @on_chain_cg_id, @action, @ual, @ordinal, @ka_id,
1638
+ @from_watermark, @to_watermark, @head, @reconciled, @pending, @detail
1639
+ )
999
1640
  `).run({
1000
1641
  ts: ev.ts,
1001
1642
  context_graph_id: ev.context_graph_id,
@@ -1042,15 +1683,15 @@ export class DashboardDB {
1042
1683
  // and inflate the reported latency. fetch/promote for one version always
1043
1684
  // share the same ordinal, so this pairs them exactly; a fast-path promote
1044
1685
  // with no fetch for its ordinal correctly contributes a 0ms latency.
1045
- const latencyRows = this.db.prepare(`
1046
- SELECT p.ts AS promote_ts,
1047
- (SELECT MAX(f.ts) FROM replication_events f
1048
- WHERE f.action = 'fetch'
1049
- AND f.context_graph_id = p.context_graph_id
1050
- AND f.ordinal = p.ordinal
1051
- AND f.ts <= p.ts AND f.ts >= ?) AS fetch_ts
1052
- FROM replication_events p
1053
- WHERE p.action = 'promote' AND p.ts >= ? AND p.ordinal IS NOT NULL
1686
+ const latencyRows = this.db.prepare(`
1687
+ SELECT p.ts AS promote_ts,
1688
+ (SELECT MAX(f.ts) FROM replication_events f
1689
+ WHERE f.action = 'fetch'
1690
+ AND f.context_graph_id = p.context_graph_id
1691
+ AND f.ordinal = p.ordinal
1692
+ AND f.ts <= p.ts AND f.ts >= ?) AS fetch_ts
1693
+ FROM replication_events p
1694
+ WHERE p.action = 'promote' AND p.ts >= ? AND p.ordinal IS NOT NULL
1054
1695
  `).all(cutoff, cutoff);
1055
1696
  const latencies = latencyRows
1056
1697
  .map((r) => (r.fetch_ts != null ? r.promote_ts - r.fetch_ts : 0))
@@ -1080,20 +1721,20 @@ export class DashboardDB {
1080
1721
  }
1081
1722
  _computeReplicationPerCg(periodMs) {
1082
1723
  const cutoff = Date.now() - periodMs;
1083
- return this.db.prepare(`
1084
- SELECT context_graph_id,
1085
- on_chain_cg_id,
1086
- SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1087
- SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1088
- SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1089
- SUM(CASE WHEN action = 'cursor-advance' THEN 1 ELSE 0 END) AS cursor_advances,
1090
- MAX(to_watermark) AS last_watermark,
1091
- MAX(head) AS last_head,
1092
- MAX(ts) AS last_event_ts
1093
- FROM replication_events
1094
- WHERE ts >= ?
1095
- GROUP BY context_graph_id, on_chain_cg_id
1096
- ORDER BY last_event_ts DESC
1724
+ return this.db.prepare(`
1725
+ SELECT context_graph_id,
1726
+ on_chain_cg_id,
1727
+ SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1728
+ SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1729
+ SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1730
+ SUM(CASE WHEN action = 'cursor-advance' THEN 1 ELSE 0 END) AS cursor_advances,
1731
+ MAX(to_watermark) AS last_watermark,
1732
+ MAX(head) AS last_head,
1733
+ MAX(ts) AS last_event_ts
1734
+ FROM replication_events
1735
+ WHERE ts >= ?
1736
+ GROUP BY context_graph_id, on_chain_cg_id
1737
+ ORDER BY last_event_ts DESC
1097
1738
  `).all(cutoff);
1098
1739
  }
1099
1740
  /**
@@ -1112,16 +1753,16 @@ export class DashboardDB {
1112
1753
  const cutoff = Date.now() - opts.periodMs;
1113
1754
  const bucket = Math.max(1, Math.floor(opts.bucketMs));
1114
1755
  const where = opts.contextGraphId ? 'AND context_graph_id = @cg' : '';
1115
- return this.db.prepare(`
1116
- SELECT (ts / @bucket) * @bucket AS bucket,
1117
- SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1118
- SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1119
- SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1120
- COUNT(*) AS total
1121
- FROM replication_events
1122
- WHERE ts >= @cutoff ${where}
1123
- GROUP BY bucket
1124
- ORDER BY bucket ASC
1756
+ return this.db.prepare(`
1757
+ SELECT (ts / @bucket) * @bucket AS bucket,
1758
+ SUM(CASE WHEN action = 'promote' THEN 1 ELSE 0 END) AS promotes,
1759
+ SUM(CASE WHEN action = 'fetch' THEN 1 ELSE 0 END) AS fetches,
1760
+ SUM(CASE WHEN action = 'defer' THEN 1 ELSE 0 END) AS defers,
1761
+ COUNT(*) AS total
1762
+ FROM replication_events
1763
+ WHERE ts >= @cutoff ${where}
1764
+ GROUP BY bucket
1765
+ ORDER BY bucket ASC
1125
1766
  `).all({ cutoff, bucket, cg: opts.contextGraphId ?? null });
1126
1767
  }
1127
1768
  /**
@@ -1133,13 +1774,13 @@ export class DashboardDB {
1133
1774
  const safeLimit = Number.isFinite(limit)
1134
1775
  ? Math.max(1, Math.min(1000, Math.trunc(limit)))
1135
1776
  : 100;
1136
- return this.db.prepare(`
1137
- SELECT ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1138
- from_watermark, to_watermark, head, reconciled, pending, detail
1139
- FROM replication_events
1140
- WHERE context_graph_id = ?
1141
- ORDER BY ts DESC
1142
- LIMIT ?
1777
+ return this.db.prepare(`
1778
+ SELECT ts, context_graph_id, on_chain_cg_id, action, ual, ordinal, ka_id,
1779
+ from_watermark, to_watermark, head, reconciled, pending, detail
1780
+ FROM replication_events
1781
+ WHERE context_graph_id = ?
1782
+ ORDER BY ts DESC
1783
+ LIMIT ?
1143
1784
  `).all(contextGraphId, safeLimit);
1144
1785
  }
1145
1786
  /**
@@ -1148,38 +1789,38 @@ export class DashboardDB {
1148
1789
  * on-disk truth the Replication tab's cursor inspector renders.
1149
1790
  */
1150
1791
  getReplicationCursors() {
1151
- return this.db.prepare(`
1152
- SELECT s.context_graph_id AS context_graph_id,
1153
- s.on_chain_id AS on_chain_id,
1154
- s.last_reconciled_ordinal AS last_reconciled_ordinal,
1155
- s.core_hosted AS core_hosted,
1156
- s.subscribed AS subscribed,
1157
- (SELECT MAX(head) FROM replication_events e
1158
- WHERE e.context_graph_id = s.context_graph_id) AS last_head,
1159
- (SELECT MAX(ts) FROM replication_events e
1160
- WHERE e.context_graph_id = s.context_graph_id) AS last_event_ts
1161
- FROM context_graph_subscriptions s
1162
- ORDER BY s.context_graph_id ASC
1792
+ return this.db.prepare(`
1793
+ SELECT s.context_graph_id AS context_graph_id,
1794
+ s.on_chain_id AS on_chain_id,
1795
+ s.last_reconciled_ordinal AS last_reconciled_ordinal,
1796
+ s.core_hosted AS core_hosted,
1797
+ s.subscribed AS subscribed,
1798
+ (SELECT MAX(head) FROM replication_events e
1799
+ WHERE e.context_graph_id = s.context_graph_id) AS last_head,
1800
+ (SELECT MAX(ts) FROM replication_events e
1801
+ WHERE e.context_graph_id = s.context_graph_id) AS last_event_ts
1802
+ FROM context_graph_subscriptions s
1803
+ ORDER BY s.context_graph_id ASC
1163
1804
  `).all();
1164
1805
  }
1165
1806
  upsertContextGraphMember(record) {
1166
1807
  const firstSeenAt = record.first_seen_at ?? record.updated_at;
1167
- this.stmt('upsertContextGraphMember', `
1168
- INSERT INTO context_graph_memberships (
1169
- context_graph_id, principal_type, principal_id, role, status, source,
1170
- display_name, metadata, first_seen_at, updated_at
1171
- ) VALUES (
1172
- @context_graph_id, @principal_type, @principal_id, @role, @status, @source,
1173
- @display_name, @metadata, @first_seen_at, @updated_at
1174
- )
1175
- ON CONFLICT(context_graph_id, principal_type, principal_id) DO UPDATE SET
1176
- role = excluded.role,
1177
- status = excluded.status,
1178
- source = excluded.source,
1179
- display_name = excluded.display_name,
1180
- metadata = excluded.metadata,
1181
- first_seen_at = context_graph_memberships.first_seen_at,
1182
- updated_at = excluded.updated_at
1808
+ this.stmt('upsertContextGraphMember', `
1809
+ INSERT INTO context_graph_memberships (
1810
+ context_graph_id, principal_type, principal_id, role, status, source,
1811
+ display_name, metadata, first_seen_at, updated_at
1812
+ ) VALUES (
1813
+ @context_graph_id, @principal_type, @principal_id, @role, @status, @source,
1814
+ @display_name, @metadata, @first_seen_at, @updated_at
1815
+ )
1816
+ ON CONFLICT(context_graph_id, principal_type, principal_id) DO UPDATE SET
1817
+ role = excluded.role,
1818
+ status = excluded.status,
1819
+ source = excluded.source,
1820
+ display_name = excluded.display_name,
1821
+ metadata = excluded.metadata,
1822
+ first_seen_at = context_graph_memberships.first_seen_at,
1823
+ updated_at = excluded.updated_at
1183
1824
  `).run({
1184
1825
  context_graph_id: record.context_graph_id,
1185
1826
  principal_type: record.principal_type,
@@ -1195,15 +1836,15 @@ export class DashboardDB {
1195
1836
  }
1196
1837
  listContextGraphMembers(contextGraphId) {
1197
1838
  if (contextGraphId) {
1198
- return this.db.prepare(`
1199
- SELECT * FROM context_graph_memberships
1200
- WHERE context_graph_id = ?
1201
- ORDER BY principal_type ASC, principal_id ASC
1839
+ return this.db.prepare(`
1840
+ SELECT * FROM context_graph_memberships
1841
+ WHERE context_graph_id = ?
1842
+ ORDER BY principal_type ASC, principal_id ASC
1202
1843
  `).all(contextGraphId);
1203
1844
  }
1204
- return this.db.prepare(`
1205
- SELECT * FROM context_graph_memberships
1206
- ORDER BY context_graph_id ASC, principal_type ASC, principal_id ASC
1845
+ return this.db.prepare(`
1846
+ SELECT * FROM context_graph_memberships
1847
+ ORDER BY context_graph_id ASC, principal_type ASC, principal_id ASC
1207
1848
  `).all();
1208
1849
  }
1209
1850
  deleteContextGraphMember(contextGraphId, principalType, principalId) {
@@ -1215,18 +1856,18 @@ export class DashboardDB {
1215
1856
  return this.db.prepare('SELECT * FROM metric_snapshots WHERE ts >= ? AND ts <= ? ORDER BY ts').all(from, to);
1216
1857
  }
1217
1858
  const step = Math.ceil(total.c / maxPoints);
1218
- return this.db.prepare(`
1219
- SELECT * FROM (
1220
- SELECT *, ROW_NUMBER() OVER (ORDER BY ts) as rn
1221
- FROM metric_snapshots WHERE ts >= ? AND ts <= ?
1222
- ) WHERE rn % ? = 0 ORDER BY ts
1859
+ return this.db.prepare(`
1860
+ SELECT * FROM (
1861
+ SELECT *, ROW_NUMBER() OVER (ORDER BY ts) as rn
1862
+ FROM metric_snapshots WHERE ts >= ? AND ts <= ?
1863
+ ) WHERE rn % ? = 0 ORDER BY ts
1223
1864
  `).all(from, to, step);
1224
1865
  }
1225
1866
  // --- Operations ---
1226
1867
  insertOperation(op) {
1227
- this.stmt('insertOp', `
1228
- INSERT INTO operations (operation_id, operation_name, started_at, status, peer_id, contextGraph_id, details)
1229
- VALUES (@operation_id, @operation_name, @started_at, 'in_progress', @peer_id, @contextGraph_id, @details)
1868
+ this.stmt('insertOp', `
1869
+ INSERT INTO operations (operation_id, operation_name, started_at, status, peer_id, contextGraph_id, details)
1870
+ VALUES (@operation_id, @operation_name, @started_at, 'in_progress', @peer_id, @contextGraph_id, @details)
1230
1871
  `).run({
1231
1872
  operation_id: op.operation_id,
1232
1873
  operation_name: op.operation_name,
@@ -1237,10 +1878,10 @@ export class DashboardDB {
1237
1878
  });
1238
1879
  }
1239
1880
  completeOperation(op) {
1240
- this.stmt('completeOp', `
1241
- UPDATE operations SET status = 'success', duration_ms = @duration_ms,
1242
- triple_count = @triple_count, details = COALESCE(@details, details)
1243
- WHERE operation_id = @operation_id AND status = 'in_progress'
1881
+ this.stmt('completeOp', `
1882
+ UPDATE operations SET status = 'success', duration_ms = @duration_ms,
1883
+ triple_count = @triple_count, details = COALESCE(@details, details)
1884
+ WHERE operation_id = @operation_id AND status = 'in_progress'
1244
1885
  `).run({
1245
1886
  operation_id: op.operation_id,
1246
1887
  duration_ms: op.duration_ms,
@@ -1249,10 +1890,10 @@ export class DashboardDB {
1249
1890
  });
1250
1891
  }
1251
1892
  failOperation(op) {
1252
- this.stmt('failOp', `
1253
- UPDATE operations SET status = 'error', duration_ms = @duration_ms,
1254
- error_message = @error_message
1255
- WHERE operation_id = @operation_id AND status = 'in_progress'
1893
+ this.stmt('failOp', `
1894
+ UPDATE operations SET status = 'error', duration_ms = @duration_ms,
1895
+ error_message = @error_message
1896
+ WHERE operation_id = @operation_id AND status = 'in_progress'
1256
1897
  `).run(op);
1257
1898
  }
1258
1899
  getOperations(opts = {}) {
@@ -1309,22 +1950,22 @@ export class DashboardDB {
1309
1950
  }
1310
1951
  getErrorHotspots(periodMs = 7 * 86_400_000) {
1311
1952
  const cutoff = Date.now() - periodMs;
1312
- return this.db.prepare(`
1313
- SELECT
1314
- p.phase,
1315
- o.operation_name,
1316
- COUNT(*) as error_count,
1317
- (SELECT p2.details FROM operation_phases p2
1318
- JOIN operations o2 ON o2.operation_id = p2.operation_id
1319
- WHERE p2.phase = p.phase AND o2.operation_name = o.operation_name
1320
- AND p2.status = 'error' AND p2.started_at >= ?
1321
- ORDER BY p2.started_at DESC LIMIT 1) as last_error,
1322
- MAX(p.started_at) as last_occurred
1323
- FROM operation_phases p
1324
- JOIN operations o ON o.operation_id = p.operation_id
1325
- WHERE p.status = 'error' AND p.started_at >= ?
1326
- GROUP BY p.phase, o.operation_name
1327
- ORDER BY error_count DESC
1953
+ return this.db.prepare(`
1954
+ SELECT
1955
+ p.phase,
1956
+ o.operation_name,
1957
+ COUNT(*) as error_count,
1958
+ (SELECT p2.details FROM operation_phases p2
1959
+ JOIN operations o2 ON o2.operation_id = p2.operation_id
1960
+ WHERE p2.phase = p.phase AND o2.operation_name = o.operation_name
1961
+ AND p2.status = 'error' AND p2.started_at >= ?
1962
+ ORDER BY p2.started_at DESC LIMIT 1) as last_error,
1963
+ MAX(p.started_at) as last_occurred
1964
+ FROM operation_phases p
1965
+ JOIN operations o ON o.operation_id = p.operation_id
1966
+ WHERE p.status = 'error' AND p.started_at >= ?
1967
+ GROUP BY p.phase, o.operation_name
1968
+ ORDER BY error_count DESC
1328
1969
  `).all(cutoff, cutoff);
1329
1970
  }
1330
1971
  getFailedOperations(opts = {}) {
@@ -1346,17 +1987,17 @@ export class DashboardDB {
1346
1987
  params.push(like, like, like);
1347
1988
  }
1348
1989
  params.push(limit);
1349
- const rows = this.db.prepare(`
1350
- SELECT
1351
- o.*,
1352
- p.phase AS phase,
1353
- p.details AS phase_error,
1354
- p.started_at AS phase_started_at
1355
- FROM operation_phases p
1356
- JOIN operations o ON o.operation_id = p.operation_id
1357
- WHERE ${where}
1358
- ORDER BY p.started_at DESC
1359
- LIMIT ?
1990
+ const rows = this.db.prepare(`
1991
+ SELECT
1992
+ o.*,
1993
+ p.phase AS phase,
1994
+ p.details AS phase_error,
1995
+ p.started_at AS phase_started_at
1996
+ FROM operation_phases p
1997
+ JOIN operations o ON o.operation_id = p.operation_id
1998
+ WHERE ${where}
1999
+ ORDER BY p.started_at DESC
2000
+ LIMIT ?
1360
2001
  `).all(...params);
1361
2002
  const operations = rows.map(row => {
1362
2003
  const logs = this.db.prepare('SELECT * FROM logs WHERE operation_id = ? ORDER BY ts DESC LIMIT 20').all(row.operation_id);
@@ -1373,42 +2014,42 @@ export class DashboardDB {
1373
2014
  }
1374
2015
  // --- Operation phases ---
1375
2016
  insertPhase(op) {
1376
- this.stmt('insertPhase', `
1377
- INSERT INTO operation_phases (operation_id, phase, started_at, status)
1378
- VALUES (@operation_id, @phase, @started_at, 'in_progress')
2017
+ this.stmt('insertPhase', `
2018
+ INSERT INTO operation_phases (operation_id, phase, started_at, status)
2019
+ VALUES (@operation_id, @phase, @started_at, 'in_progress')
1379
2020
  `).run(op);
1380
2021
  }
1381
2022
  completePhase(op) {
1382
- this.stmt('completePhase', `
1383
- UPDATE operation_phases SET status = 'success', duration_ms = @duration_ms
1384
- WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2023
+ this.stmt('completePhase', `
2024
+ UPDATE operation_phases SET status = 'success', duration_ms = @duration_ms
2025
+ WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
1385
2026
  `).run(op);
1386
2027
  }
1387
2028
  failPhase(op) {
1388
- this.stmt('failPhase', `
1389
- UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
1390
- details = @error_message
1391
- WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
2029
+ this.stmt('failPhase', `
2030
+ UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
2031
+ details = @error_message
2032
+ WHERE operation_id = @operation_id AND phase = @phase AND status = 'in_progress'
1392
2033
  `).run(op);
1393
2034
  }
1394
2035
  failAllPhases(op) {
1395
- this.stmt('failAllPhases', `
1396
- UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
1397
- details = @error_message
1398
- WHERE operation_id = @operation_id AND status = 'in_progress'
2036
+ this.stmt('failAllPhases', `
2037
+ UPDATE operation_phases SET status = 'error', duration_ms = @duration_ms,
2038
+ details = @error_message
2039
+ WHERE operation_id = @operation_id AND status = 'in_progress'
1399
2040
  `).run(op);
1400
2041
  }
1401
2042
  // --- Operation cost & tx ---
1402
2043
  setOperationCost(op) {
1403
- this.stmt('setCost', `
1404
- UPDATE operations SET
1405
- gas_used = COALESCE(@gas_used, gas_used),
1406
- gas_price_gwei = COALESCE(@gas_price_gwei, gas_price_gwei),
1407
- gas_cost_eth = COALESCE(@gas_cost_eth, gas_cost_eth),
1408
- trac_cost = COALESCE(@trac_cost, trac_cost),
1409
- tx_hash = COALESCE(@tx_hash, tx_hash),
1410
- chain_id = COALESCE(@chain_id, chain_id)
1411
- WHERE operation_id = @operation_id
2044
+ this.stmt('setCost', `
2045
+ UPDATE operations SET
2046
+ gas_used = COALESCE(@gas_used, gas_used),
2047
+ gas_price_gwei = COALESCE(@gas_price_gwei, gas_price_gwei),
2048
+ gas_cost_eth = COALESCE(@gas_cost_eth, gas_cost_eth),
2049
+ trac_cost = COALESCE(@trac_cost, trac_cost),
2050
+ tx_hash = COALESCE(@tx_hash, tx_hash),
2051
+ chain_id = COALESCE(@chain_id, chain_id)
2052
+ WHERE operation_id = @operation_id
1412
2053
  `).run({
1413
2054
  operation_id: op.operation_id,
1414
2055
  gas_used: op.gas_used ?? null,
@@ -1426,17 +2067,17 @@ export class DashboardDB {
1426
2067
  const params = [cutoff];
1427
2068
  if (opts.name)
1428
2069
  params.push(opts.name);
1429
- const summaryRow = this.db.prepare(`
1430
- SELECT
1431
- COUNT(*) as totalCount,
1432
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
1433
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount,
1434
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
1435
- AVG(gas_cost_eth) as avgGasCostEth,
1436
- SUM(gas_cost_eth) as totalGasCostEth,
1437
- AVG(trac_cost) as avgTracCost,
1438
- SUM(trac_cost) as totalTracCost
1439
- FROM operations WHERE started_at >= ? ${nameFilter}
2070
+ const summaryRow = this.db.prepare(`
2071
+ SELECT
2072
+ COUNT(*) as totalCount,
2073
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2074
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount,
2075
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2076
+ AVG(gas_cost_eth) as avgGasCostEth,
2077
+ SUM(gas_cost_eth) as totalGasCostEth,
2078
+ AVG(trac_cost) as avgTracCost,
2079
+ SUM(trac_cost) as totalTracCost
2080
+ FROM operations WHERE started_at >= ? ${nameFilter}
1440
2081
  `).get(...params);
1441
2082
  const summary = {
1442
2083
  totalCount: summaryRow.totalCount ?? 0,
@@ -1453,17 +2094,17 @@ export class DashboardDB {
1453
2094
  const tsParams = [bucketSize, cutoff];
1454
2095
  if (opts.name)
1455
2096
  tsParams.push(opts.name);
1456
- const timeSeries = this.db.prepare(`
1457
- SELECT
1458
- (CAST(started_at / ? AS INTEGER) * ?) as bucket,
1459
- COUNT(*) as count,
1460
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
1461
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
1462
- AVG(gas_cost_eth) as avgGasCostEth,
1463
- SUM(gas_cost_eth) as totalGasCostEth
1464
- FROM operations
1465
- WHERE started_at >= ? ${nameFilter}
1466
- GROUP BY bucket ORDER BY bucket
2097
+ const timeSeries = this.db.prepare(`
2098
+ SELECT
2099
+ (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2100
+ COUNT(*) as count,
2101
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2102
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgDurationMs,
2103
+ AVG(gas_cost_eth) as avgGasCostEth,
2104
+ SUM(gas_cost_eth) as totalGasCostEth
2105
+ FROM operations
2106
+ WHERE started_at >= ? ${nameFilter}
2107
+ GROUP BY bucket ORDER BY bucket
1467
2108
  `).all(bucketSize, bucketSize, ...params);
1468
2109
  return {
1469
2110
  summary,
@@ -1480,16 +2121,16 @@ export class DashboardDB {
1480
2121
  // --- Per-type time series ---
1481
2122
  getPerTypeTimeSeries(opts) {
1482
2123
  const cutoff = Date.now() - opts.periodMs;
1483
- const rows = this.db.prepare(`
1484
- SELECT
1485
- (CAST(started_at / ? AS INTEGER) * ?) as bucket,
1486
- operation_name as type,
1487
- COUNT(*) as count,
1488
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs,
1489
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
1490
- SUM(gas_cost_eth) as gasCostEth
1491
- FROM operations WHERE started_at >= ?
1492
- GROUP BY bucket, operation_name ORDER BY bucket
2124
+ const rows = this.db.prepare(`
2125
+ SELECT
2126
+ (CAST(started_at / ? AS INTEGER) * ?) as bucket,
2127
+ operation_name as type,
2128
+ COUNT(*) as count,
2129
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs,
2130
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
2131
+ SUM(gas_cost_eth) as gasCostEth
2132
+ FROM operations WHERE started_at >= ?
2133
+ GROUP BY bucket, operation_name ORDER BY bucket
1493
2134
  `).all(opts.bucketMs, opts.bucketMs, cutoff);
1494
2135
  const bucketSet = new Set();
1495
2136
  const typeSet = new Set();
@@ -1519,15 +2160,15 @@ export class DashboardDB {
1519
2160
  // --- Success rates by operation type ---
1520
2161
  getSuccessRatesByType(periodMs) {
1521
2162
  const cutoff = Date.now() - periodMs;
1522
- return this.db.prepare(`
1523
- SELECT
1524
- operation_name as type,
1525
- COUNT(*) as total,
1526
- SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
1527
- SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
1528
- AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs
1529
- FROM operations WHERE started_at >= ?
1530
- GROUP BY operation_name ORDER BY total DESC
2163
+ return this.db.prepare(`
2164
+ SELECT
2165
+ operation_name as type,
2166
+ COUNT(*) as total,
2167
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as success,
2168
+ SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as error,
2169
+ AVG(CASE WHEN status = 'success' THEN duration_ms END) as avgMs
2170
+ FROM operations WHERE started_at >= ?
2171
+ GROUP BY operation_name ORDER BY total DESC
1531
2172
  `).all(cutoff).map(r => ({
1532
2173
  ...r,
1533
2174
  rate: r.total > 0 ? r.success / r.total : 0,
@@ -1550,16 +2191,16 @@ export class DashboardDB {
1550
2191
  // on-chain (Verifiable Memory commits) are counted, so the publish
1551
2192
  // count and the TRAC total are consistent. Free SWM/local/testnet
1552
2193
  // publishes record trac_cost = 0 and are intentionally excluded.
1553
- const row = this.db.prepare(`
1554
- SELECT
1555
- SUM(CASE WHEN trac_cost > 0 THEN 1 ELSE 0 END) as publishCount,
1556
- SUM(CASE WHEN status = 'success' AND trac_cost > 0 THEN 1 ELSE 0 END) as successCount,
1557
- COALESCE(SUM(gas_cost_eth), 0) as totalGasEth,
1558
- COALESCE(SUM(trac_cost), 0) as totalTrac,
1559
- COALESCE(AVG(CASE WHEN trac_cost > 0 THEN gas_cost_eth END), 0) as avgGasEth,
1560
- COALESCE(AVG(CASE WHEN trac_cost > 0 THEN trac_cost END), 0) as avgTrac
1561
- FROM operations
1562
- WHERE operation_name = 'publish' AND trac_cost > 0 AND started_at >= ?
2194
+ const row = this.db.prepare(`
2195
+ SELECT
2196
+ SUM(CASE WHEN trac_cost > 0 THEN 1 ELSE 0 END) as publishCount,
2197
+ SUM(CASE WHEN status = 'success' AND trac_cost > 0 THEN 1 ELSE 0 END) as successCount,
2198
+ COALESCE(SUM(gas_cost_eth), 0) as totalGasEth,
2199
+ COALESCE(SUM(trac_cost), 0) as totalTrac,
2200
+ COALESCE(AVG(CASE WHEN trac_cost > 0 THEN gas_cost_eth END), 0) as avgGasEth,
2201
+ COALESCE(AVG(CASE WHEN trac_cost > 0 THEN trac_cost END), 0) as avgTrac
2202
+ FROM operations
2203
+ WHERE operation_name = 'publish' AND trac_cost > 0 AND started_at >= ?
1563
2204
  `).get(cutoff);
1564
2205
  results.periods.push({
1565
2206
  label: p.label,
@@ -1594,9 +2235,9 @@ export class DashboardDB {
1594
2235
  * still throw — pinned by db.test.ts.
1595
2236
  */
1596
2237
  insertChatMessage(msg) {
1597
- const info = this.stmt('insertChat', `
1598
- INSERT INTO chat_messages (ts, direction, peer, peer_name, text, delivered, message_id)
1599
- VALUES (@ts, @direction, @peer, @peer_name, @text, @delivered, @message_id)
2238
+ const info = this.stmt('insertChat', `
2239
+ INSERT INTO chat_messages (ts, direction, peer, peer_name, text, delivered, message_id)
2240
+ VALUES (@ts, @direction, @peer, @peer_name, @text, @delivered, @message_id)
1600
2241
  `).run({
1601
2242
  ts: msg.ts,
1602
2243
  direction: msg.direction,
@@ -1687,16 +2328,16 @@ export class DashboardDB {
1687
2328
  return this.db.prepare('SELECT * FROM chat_persistence_jobs WHERE turn_id = ?').get(turnId);
1688
2329
  }
1689
2330
  insertChatPersistenceJob(job) {
1690
- this.stmt('insertChatPersistenceJob', `
1691
- INSERT INTO chat_persistence_jobs (
1692
- turn_id, session_id, user_message, assistant_reply, tool_calls_json,
1693
- status, attempts, max_attempts, next_attempt_at, queued_at, updated_at,
1694
- store_ms, error_message
1695
- ) VALUES (
1696
- @turn_id, @session_id, @user_message, @assistant_reply, @tool_calls_json,
1697
- @status, @attempts, @max_attempts, @next_attempt_at, @queued_at, @updated_at,
1698
- @store_ms, @error_message
1699
- )
2331
+ this.stmt('insertChatPersistenceJob', `
2332
+ INSERT INTO chat_persistence_jobs (
2333
+ turn_id, session_id, user_message, assistant_reply, tool_calls_json,
2334
+ status, attempts, max_attempts, next_attempt_at, queued_at, updated_at,
2335
+ store_ms, error_message
2336
+ ) VALUES (
2337
+ @turn_id, @session_id, @user_message, @assistant_reply, @tool_calls_json,
2338
+ @status, @attempts, @max_attempts, @next_attempt_at, @queued_at, @updated_at,
2339
+ @store_ms, @error_message
2340
+ )
1700
2341
  `).run({
1701
2342
  ...job,
1702
2343
  tool_calls_json: job.tool_calls_json ?? null,
@@ -1705,46 +2346,46 @@ export class DashboardDB {
1705
2346
  });
1706
2347
  }
1707
2348
  markChatPersistenceInProgress(turnId, attempts, updatedAt) {
1708
- this.stmt('markChatPersistenceInProgress', `
1709
- UPDATE chat_persistence_jobs
1710
- SET status = 'in_progress', attempts = ?, updated_at = ?, error_message = NULL
1711
- WHERE turn_id = ?
2349
+ this.stmt('markChatPersistenceInProgress', `
2350
+ UPDATE chat_persistence_jobs
2351
+ SET status = 'in_progress', attempts = ?, updated_at = ?, error_message = NULL
2352
+ WHERE turn_id = ?
1712
2353
  `).run(attempts, updatedAt, turnId);
1713
2354
  }
1714
2355
  markChatPersistenceStored(turnId, storeMs, updatedAt) {
1715
- this.stmt('markChatPersistenceStored', `
1716
- UPDATE chat_persistence_jobs
1717
- SET status = 'stored', store_ms = ?, updated_at = ?, error_message = NULL
1718
- WHERE turn_id = ?
2356
+ this.stmt('markChatPersistenceStored', `
2357
+ UPDATE chat_persistence_jobs
2358
+ SET status = 'stored', store_ms = ?, updated_at = ?, error_message = NULL
2359
+ WHERE turn_id = ?
1719
2360
  `).run(storeMs, updatedAt, turnId);
1720
2361
  }
1721
2362
  markChatPersistencePendingRetry(turnId, attempts, nextAttemptAt, updatedAt, errorMessage) {
1722
- this.stmt('markChatPersistencePendingRetry', `
1723
- UPDATE chat_persistence_jobs
1724
- SET status = 'pending', attempts = ?, next_attempt_at = ?, updated_at = ?, error_message = ?
1725
- WHERE turn_id = ?
2363
+ this.stmt('markChatPersistencePendingRetry', `
2364
+ UPDATE chat_persistence_jobs
2365
+ SET status = 'pending', attempts = ?, next_attempt_at = ?, updated_at = ?, error_message = ?
2366
+ WHERE turn_id = ?
1726
2367
  `).run(attempts, nextAttemptAt, updatedAt, errorMessage, turnId);
1727
2368
  }
1728
2369
  markChatPersistenceFailed(turnId, attempts, updatedAt, errorMessage) {
1729
- this.stmt('markChatPersistenceFailed', `
1730
- UPDATE chat_persistence_jobs
1731
- SET status = 'failed', attempts = ?, updated_at = ?, error_message = ?
1732
- WHERE turn_id = ?
2370
+ this.stmt('markChatPersistenceFailed', `
2371
+ UPDATE chat_persistence_jobs
2372
+ SET status = 'failed', attempts = ?, updated_at = ?, error_message = ?
2373
+ WHERE turn_id = ?
1733
2374
  `).run(attempts, updatedAt, errorMessage, turnId);
1734
2375
  }
1735
2376
  recoverInProgressChatPersistenceJobs(now) {
1736
- this.stmt('recoverInProgressChatPersistenceJobs', `
1737
- UPDATE chat_persistence_jobs
1738
- SET status = 'pending', next_attempt_at = ?, updated_at = ?
1739
- WHERE status = 'in_progress'
2377
+ this.stmt('recoverInProgressChatPersistenceJobs', `
2378
+ UPDATE chat_persistence_jobs
2379
+ SET status = 'pending', next_attempt_at = ?, updated_at = ?
2380
+ WHERE status = 'in_progress'
1740
2381
  `).run(now, now);
1741
2382
  }
1742
2383
  getRunnableChatPersistenceJobs(now, limit = 10) {
1743
- return this.db.prepare(`
1744
- SELECT * FROM chat_persistence_jobs
1745
- WHERE status = 'pending' AND next_attempt_at <= ?
1746
- ORDER BY next_attempt_at ASC, queued_at ASC
1747
- LIMIT ?
2384
+ return this.db.prepare(`
2385
+ SELECT * FROM chat_persistence_jobs
2386
+ WHERE status = 'pending' AND next_attempt_at <= ?
2387
+ ORDER BY next_attempt_at ASC, queued_at ASC
2388
+ LIMIT ?
1748
2389
  `).all(now, limit);
1749
2390
  }
1750
2391
  getNextPendingChatPersistenceAt() {
@@ -1752,19 +2393,19 @@ export class DashboardDB {
1752
2393
  return row?.next_at ?? null;
1753
2394
  }
1754
2395
  getChatPersistenceHealth(now) {
1755
- const counts = this.db.prepare(`
1756
- SELECT
1757
- SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
1758
- SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress_count,
1759
- SUM(CASE WHEN status = 'stored' THEN 1 ELSE 0 END) AS stored_count,
1760
- SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
1761
- SUM(CASE WHEN status = 'pending' AND next_attempt_at < ? THEN 1 ELSE 0 END) AS overdue_pending_count
1762
- FROM chat_persistence_jobs
2396
+ const counts = this.db.prepare(`
2397
+ SELECT
2398
+ SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
2399
+ SUM(CASE WHEN status = 'in_progress' THEN 1 ELSE 0 END) AS in_progress_count,
2400
+ SUM(CASE WHEN status = 'stored' THEN 1 ELSE 0 END) AS stored_count,
2401
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed_count,
2402
+ SUM(CASE WHEN status = 'pending' AND next_attempt_at < ? THEN 1 ELSE 0 END) AS overdue_pending_count
2403
+ FROM chat_persistence_jobs
1763
2404
  `).get(now);
1764
- const oldest = this.db.prepare(`
1765
- SELECT MIN(queued_at) AS oldest_pending_queued_at
1766
- FROM chat_persistence_jobs
1767
- WHERE status = 'pending'
2405
+ const oldest = this.db.prepare(`
2406
+ SELECT MIN(queued_at) AS oldest_pending_queued_at
2407
+ FROM chat_persistence_jobs
2408
+ WHERE status = 'pending'
1768
2409
  `).get();
1769
2410
  return {
1770
2411
  pending_count: counts?.pending_count ?? 0,
@@ -1777,9 +2418,9 @@ export class DashboardDB {
1777
2418
  }
1778
2419
  // --- Logs ---
1779
2420
  insertLog(entry) {
1780
- this.stmt('insertLog', `
1781
- INSERT INTO logs (ts, level, operation_name, operation_id, module, message)
1782
- VALUES (@ts, @level, @operation_name, @operation_id, @module, @message)
2421
+ this.stmt('insertLog', `
2422
+ INSERT INTO logs (ts, level, operation_name, operation_id, module, message)
2423
+ VALUES (@ts, @level, @operation_name, @operation_id, @module, @message)
1783
2424
  `).run({
1784
2425
  ts: entry.ts,
1785
2426
  level: entry.level,
@@ -1834,9 +2475,9 @@ export class DashboardDB {
1834
2475
  }
1835
2476
  // --- Query history ---
1836
2477
  insertQueryHistory(entry) {
1837
- this.stmt('insertQueryHistory', `
1838
- INSERT INTO query_history (ts, sparql, duration_ms, result_count, error)
1839
- VALUES (@ts, @sparql, @duration_ms, @result_count, @error)
2478
+ this.stmt('insertQueryHistory', `
2479
+ INSERT INTO query_history (ts, sparql, duration_ms, result_count, error)
2480
+ VALUES (@ts, @sparql, @duration_ms, @result_count, @error)
1840
2481
  `).run({
1841
2482
  ts: Date.now(),
1842
2483
  sparql: entry.sparql,
@@ -1880,9 +2521,9 @@ export class DashboardDB {
1880
2521
  }
1881
2522
  // --- Notifications ---
1882
2523
  insertNotification(n) {
1883
- const result = this.stmt('insertNotif', `
1884
- INSERT INTO notifications (ts, type, title, message, source, peer, read, meta, context_graph_id)
1885
- VALUES (@ts, @type, @title, @message, @source, @peer, 0, @meta, @contextGraphId)
2524
+ const result = this.stmt('insertNotif', `
2525
+ INSERT INTO notifications (ts, type, title, message, source, peer, read, meta, context_graph_id)
2526
+ VALUES (@ts, @type, @title, @message, @source, @peer, 0, @meta, @contextGraphId)
1886
2527
  `).run({
1887
2528
  ts: n.ts,
1888
2529
  type: n.type,
@@ -1918,8 +2559,8 @@ export class DashboardDB {
1918
2559
  if (cgIds.length === 0)
1919
2560
  return [];
1920
2561
  const placeholders = cgIds.map(() => '?').join(',');
1921
- return this.db.prepare(`SELECT * FROM notifications
1922
- WHERE context_graph_id IN (${placeholders})
2562
+ return this.db.prepare(`SELECT * FROM notifications
2563
+ WHERE context_graph_id IN (${placeholders})
1923
2564
  ORDER BY ts DESC LIMIT ?`).all(...cgIds, limit);
1924
2565
  }
1925
2566
  /**
@@ -1950,8 +2591,8 @@ export class DashboardDB {
1950
2591
  if (types.length === 0)
1951
2592
  return [];
1952
2593
  const placeholders = types.map(() => '?').join(',');
1953
- return this.db.prepare(`SELECT * FROM notifications
1954
- WHERE type IN (${placeholders})
2594
+ return this.db.prepare(`SELECT * FROM notifications
2595
+ WHERE type IN (${placeholders})
1955
2596
  ORDER BY ts DESC LIMIT ?`).all(...types, limit);
1956
2597
  }
1957
2598
  /**
@@ -1971,9 +2612,9 @@ export class DashboardDB {
1971
2612
  const wallet = walletAddress.trim().toLowerCase();
1972
2613
  if (!wallet)
1973
2614
  return [];
1974
- return this.db.prepare(`SELECT * FROM notifications
1975
- WHERE type = 'pca_cost_covered'
1976
- AND lower(json_extract(meta, '$.publisherAddress')) = ?
2615
+ return this.db.prepare(`SELECT * FROM notifications
2616
+ WHERE type = 'pca_cost_covered'
2617
+ AND lower(json_extract(meta, '$.publisherAddress')) = ?
1977
2618
  ORDER BY ts DESC LIMIT ?`).all(wallet, limit);
1978
2619
  }
1979
2620
  markNotificationsRead(ids) {
@@ -2003,10 +2644,10 @@ export class DashboardDB {
2003
2644
  return [];
2004
2645
  const windowStart = parsed.windowBucket * ACTIVITY_DIGEST_WINDOW_MS;
2005
2646
  const windowEnd = windowStart + ACTIVITY_DIGEST_WINDOW_MS;
2006
- const rows = this.db.prepare(`SELECT id FROM notifications
2007
- WHERE context_graph_id = ?
2008
- AND type = ?
2009
- AND ts >= ? AND ts < ?
2647
+ const rows = this.db.prepare(`SELECT id FROM notifications
2648
+ WHERE context_graph_id = ?
2649
+ AND type = ?
2650
+ AND ts >= ? AND ts < ?
2010
2651
  AND json_extract(meta, '$.kind') = ?`).all(parsed.contextGraphId, ASSERTION_ACTIVITY_TYPE, windowStart, windowEnd, parsed.kind);
2011
2652
  return rows.map((r) => r.id);
2012
2653
  }
@@ -2027,32 +2668,72 @@ export class SqliteSyncCheckpointStore {
2027
2668
  }
2028
2669
  get(key) {
2029
2670
  const now = this.clock();
2030
- const row = this.db.prepare(`SELECT offset, updated_at, expires_at FROM sync_checkpoints WHERE key = ?`).get(key);
2671
+ const row = this.db.prepare(`SELECT offset, updated_at, expires_at,
2672
+ responder_session_id, responder_session_expires_at
2673
+ FROM sync_checkpoints WHERE key = ?`).get(key);
2031
2674
  if (!row)
2032
2675
  return undefined;
2033
2676
  if (row.expires_at < now) {
2034
2677
  this.delete(key);
2035
2678
  return undefined;
2036
2679
  }
2680
+ const hasFreshResponderSession = Boolean(row.responder_session_id)
2681
+ && Number.isSafeInteger(row.responder_session_expires_at)
2682
+ && (row.responder_session_expires_at ?? 0) > now;
2683
+ if (row.responder_session_id && !hasFreshResponderSession) {
2684
+ this.clearResponderSession(key);
2685
+ }
2037
2686
  return {
2038
2687
  offset: row.offset,
2039
2688
  updatedAtMs: row.updated_at,
2040
2689
  expiresAtMs: row.expires_at,
2690
+ ...(hasFreshResponderSession
2691
+ ? {
2692
+ responderSessionId: row.responder_session_id,
2693
+ responderSessionExpiresAtMs: row.responder_session_expires_at,
2694
+ }
2695
+ : {}),
2041
2696
  };
2042
2697
  }
2043
2698
  set(key, value, nowMs = this.clock()) {
2044
2699
  if (!Number.isSafeInteger(value) || value < 0) {
2045
2700
  throw new Error(`Invalid sync checkpoint offset for ${key}: ${value}`);
2046
2701
  }
2047
- this.db.prepare(`
2048
- INSERT INTO sync_checkpoints (key, offset, updated_at, expires_at)
2049
- VALUES (?, ?, ?, ?)
2050
- ON CONFLICT(key) DO UPDATE SET
2051
- offset = excluded.offset,
2052
- updated_at = excluded.updated_at,
2053
- expires_at = excluded.expires_at
2702
+ this.db.prepare(`
2703
+ INSERT INTO sync_checkpoints (key, offset, updated_at, expires_at)
2704
+ VALUES (?, ?, ?, ?)
2705
+ ON CONFLICT(key) DO UPDATE SET
2706
+ offset = excluded.offset,
2707
+ updated_at = excluded.updated_at,
2708
+ expires_at = excluded.expires_at
2054
2709
  `).run(key, value, nowMs, nowMs + this.ttlMs);
2055
2710
  }
2711
+ setResponderSession(key, sessionId, expiresAtMs, nowMs = this.clock()) {
2712
+ if (!sessionId || !Number.isSafeInteger(expiresAtMs)) {
2713
+ throw new Error(`Invalid sync responder session for ${key}`);
2714
+ }
2715
+ if (expiresAtMs <= nowMs) {
2716
+ this.clearResponderSession(key);
2717
+ return;
2718
+ }
2719
+ this.db.prepare(`
2720
+ INSERT INTO sync_checkpoints (
2721
+ key, offset, updated_at, expires_at,
2722
+ responder_session_id, responder_session_expires_at
2723
+ ) VALUES (?, 0, ?, ?, ?, ?)
2724
+ ON CONFLICT(key) DO UPDATE SET
2725
+ responder_session_id = excluded.responder_session_id,
2726
+ responder_session_expires_at = excluded.responder_session_expires_at
2727
+ `).run(key, nowMs, nowMs + this.ttlMs, sessionId, expiresAtMs);
2728
+ }
2729
+ clearResponderSession(key) {
2730
+ this.db.prepare(`
2731
+ UPDATE sync_checkpoints
2732
+ SET responder_session_id = NULL,
2733
+ responder_session_expires_at = NULL
2734
+ WHERE key = ?
2735
+ `).run(key);
2736
+ }
2056
2737
  delete(key) {
2057
2738
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE key = ?`).run(key);
2058
2739
  }
@@ -2084,13 +2765,13 @@ export class SqliteChangelogCursorStore {
2084
2765
  if (!Number.isSafeInteger(seq) || seq < 0) {
2085
2766
  throw new Error(`Invalid changelog cursor seq for ${peerId}/${contextGraphId}: ${seq}`);
2086
2767
  }
2087
- this.db.prepare(`
2088
- INSERT INTO changelog_cursors (peer_id, context_graph_id, era, seq, updated_at)
2089
- VALUES (?, ?, ?, ?, ?)
2090
- ON CONFLICT(peer_id, context_graph_id) DO UPDATE SET
2091
- era = excluded.era,
2092
- seq = excluded.seq,
2093
- updated_at = excluded.updated_at
2768
+ this.db.prepare(`
2769
+ INSERT INTO changelog_cursors (peer_id, context_graph_id, era, seq, updated_at)
2770
+ VALUES (?, ?, ?, ?, ?)
2771
+ ON CONFLICT(peer_id, context_graph_id) DO UPDATE SET
2772
+ era = excluded.era,
2773
+ seq = excluded.seq,
2774
+ updated_at = excluded.updated_at
2094
2775
  `).run(peerId, contextGraphId, era, seq, nowMs);
2095
2776
  }
2096
2777
  }
@@ -2118,13 +2799,13 @@ export class SqliteChangelogEraGuard {
2118
2799
  if (!Number.isSafeInteger(highSeq) || highSeq < 0) {
2119
2800
  throw new Error(`Invalid changelog era high_seq: ${highSeq}`);
2120
2801
  }
2121
- this.db.prepare(`
2122
- INSERT INTO changelog_era (id, era, high_seq, updated_at)
2123
- VALUES (1, ?, ?, ?)
2124
- ON CONFLICT(id) DO UPDATE SET
2125
- era = excluded.era,
2126
- high_seq = excluded.high_seq,
2127
- updated_at = excluded.updated_at
2802
+ this.db.prepare(`
2803
+ INSERT INTO changelog_era (id, era, high_seq, updated_at)
2804
+ VALUES (1, ?, ?, ?)
2805
+ ON CONFLICT(id) DO UPDATE SET
2806
+ era = excluded.era,
2807
+ high_seq = excluded.high_seq,
2808
+ updated_at = excluded.updated_at
2128
2809
  `).run(era, highSeq, this.clock());
2129
2810
  }
2130
2811
  }
@@ -2158,7 +2839,7 @@ export class SqliteMessageIdempotencyStore {
2158
2839
  }
2159
2840
  check(peer, protocol, messageId, direction) {
2160
2841
  const row = this.db
2161
- .prepare(`SELECT response_blob FROM message_idempotency
2842
+ .prepare(`SELECT response_blob FROM message_idempotency
2162
2843
  WHERE peer_id = ? AND protocol = ? AND message_id = ? AND direction = ?`)
2163
2844
  .get(peer, protocol, messageId, direction);
2164
2845
  if (!row)
@@ -2188,9 +2869,9 @@ export class SqliteMessageIdempotencyStore {
2188
2869
  // SqliteError so the substrate's bug doesn't disguise itself as
2189
2870
  // a normal duplicate.
2190
2871
  this.db
2191
- .prepare(`INSERT INTO message_idempotency
2192
- (peer_id, protocol, message_id, direction, response_blob, response_size, ts)
2193
- VALUES (?, ?, ?, ?, ?, ?, ?)
2872
+ .prepare(`INSERT INTO message_idempotency
2873
+ (peer_id, protocol, message_id, direction, response_blob, response_size, ts)
2874
+ VALUES (?, ?, ?, ?, ?, ?, ?)
2194
2875
  ON CONFLICT (peer_id, protocol, message_id, direction) DO NOTHING`)
2195
2876
  .run(peer, protocol, messageId, direction, blob, responseSize, this.clock());
2196
2877
  }
@@ -2215,15 +2896,15 @@ export class SqliteProtocolOutboxStore {
2215
2896
  }
2216
2897
  enqueue(peer, protocol, messageId, payload, error, now) {
2217
2898
  const existing = this.db
2218
- .prepare(`SELECT * FROM protocol_outbox
2899
+ .prepare(`SELECT * FROM protocol_outbox
2219
2900
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2220
2901
  .get(peer, protocol, messageId);
2221
2902
  if (existing) {
2222
2903
  const newAttempts = existing.attempts + 1;
2223
2904
  const nextAttemptAt = now + this.backoffFor(newAttempts);
2224
2905
  this.db
2225
- .prepare(`UPDATE protocol_outbox
2226
- SET attempts = ?, last_attempt_at = ?, next_attempt_at = ?, last_error = ?
2906
+ .prepare(`UPDATE protocol_outbox
2907
+ SET attempts = ?, last_attempt_at = ?, next_attempt_at = ?, last_error = ?
2227
2908
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2228
2909
  .run(newAttempts, now, nextAttemptAt, error, peer, protocol, messageId);
2229
2910
  return {
@@ -2242,9 +2923,9 @@ export class SqliteProtocolOutboxStore {
2242
2923
  const nextAttemptAt = now + this.backoffFor(attempts);
2243
2924
  const blob = Buffer.from(payload);
2244
2925
  this.db
2245
- .prepare(`INSERT INTO protocol_outbox
2246
- (peer_id, protocol, message_id, payload, attempts,
2247
- first_failure_at, last_attempt_at, next_attempt_at, last_error)
2926
+ .prepare(`INSERT INTO protocol_outbox
2927
+ (peer_id, protocol, message_id, payload, attempts,
2928
+ first_failure_at, last_attempt_at, next_attempt_at, last_error)
2248
2929
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2249
2930
  .run(peer, protocol, messageId, blob, attempts, now, now, nextAttemptAt, error);
2250
2931
  return {
@@ -2261,14 +2942,14 @@ export class SqliteProtocolOutboxStore {
2261
2942
  }
2262
2943
  markDelivered(peer, protocol, messageId) {
2263
2944
  const result = this.db
2264
- .prepare(`DELETE FROM protocol_outbox
2945
+ .prepare(`DELETE FROM protocol_outbox
2265
2946
  WHERE peer_id = ? AND protocol = ? AND message_id = ?`)
2266
2947
  .run(peer, protocol, messageId);
2267
2948
  return result.changes > 0;
2268
2949
  }
2269
2950
  hasEntry(peer, protocol, messageId) {
2270
2951
  const row = this.db
2271
- .prepare(`SELECT 1 FROM protocol_outbox
2952
+ .prepare(`SELECT 1 FROM protocol_outbox
2272
2953
  WHERE peer_id = ? AND protocol = ? AND message_id = ? LIMIT 1`)
2273
2954
  .get(peer, protocol, messageId);
2274
2955
  return row !== undefined;
@@ -2278,27 +2959,27 @@ export class SqliteProtocolOutboxStore {
2278
2959
  }
2279
2960
  pendingFor(peer) {
2280
2961
  const rows = this.db
2281
- .prepare(`SELECT * FROM protocol_outbox
2282
- WHERE peer_id = ?
2962
+ .prepare(`SELECT * FROM protocol_outbox
2963
+ WHERE peer_id = ?
2283
2964
  ORDER BY first_failure_at ASC, protocol ASC, message_id ASC`)
2284
2965
  .all(peer);
2285
2966
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
2286
2967
  }
2287
2968
  due(now) {
2288
2969
  const rows = this.db
2289
- .prepare(`SELECT * FROM protocol_outbox
2290
- WHERE next_attempt_at <= ?
2291
- ORDER BY next_attempt_at ASC, first_failure_at ASC,
2970
+ .prepare(`SELECT * FROM protocol_outbox
2971
+ WHERE next_attempt_at <= ?
2972
+ ORDER BY next_attempt_at ASC, first_failure_at ASC,
2292
2973
  peer_id ASC, protocol ASC, message_id ASC`)
2293
2974
  .all(now);
2294
2975
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
2295
2976
  }
2296
2977
  duePage(now, limit) {
2297
2978
  const rows = this.db
2298
- .prepare(`SELECT * FROM protocol_outbox
2299
- WHERE next_attempt_at <= ?
2300
- ORDER BY next_attempt_at ASC, first_failure_at ASC,
2301
- peer_id ASC, protocol ASC, message_id ASC
2979
+ .prepare(`SELECT * FROM protocol_outbox
2980
+ WHERE next_attempt_at <= ?
2981
+ ORDER BY next_attempt_at ASC, first_failure_at ASC,
2982
+ peer_id ASC, protocol ASC, message_id ASC
2302
2983
  LIMIT ?`)
2303
2984
  .all(now, limit);
2304
2985
  return rows.map(SqliteProtocolOutboxStore.rowToEntry);
@@ -2388,9 +3069,9 @@ export class SqliteKaNumberStore {
2388
3069
  // `safeIntegers(true)` opts INTO bigint returns so the counter is
2389
3070
  // exact past `Number.MAX_SAFE_INTEGER` (codex PR #976 F6).
2390
3071
  const row = this.db
2391
- .prepare(`INSERT INTO ka_numbers (author_address, next_number)
2392
- VALUES (?, 1)
2393
- ON CONFLICT(author_address) DO UPDATE SET next_number = next_number + 1
3072
+ .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3073
+ VALUES (?, 1)
3074
+ ON CONFLICT(author_address) DO UPDATE SET next_number = next_number + 1
2394
3075
  RETURNING next_number - 1 AS number`)
2395
3076
  .safeIntegers(true)
2396
3077
  .get(author);
@@ -2402,9 +3083,9 @@ export class SqliteKaNumberStore {
2402
3083
  // `excluded.next_number` is the proposed floor from the VALUES row.
2403
3084
  // `better-sqlite3` accepts bigint statement parameters natively.
2404
3085
  this.db
2405
- .prepare(`INSERT INTO ka_numbers (author_address, next_number)
2406
- VALUES (?, ?)
2407
- ON CONFLICT(author_address) DO UPDATE SET
3086
+ .prepare(`INSERT INTO ka_numbers (author_address, next_number)
3087
+ VALUES (?, ?)
3088
+ ON CONFLICT(author_address) DO UPDATE SET
2408
3089
  next_number = MAX(next_number, excluded.next_number)`)
2409
3090
  .run(author, nextNumberFloor);
2410
3091
  }