@origintrail-official/dkg-node-ui 10.0.13 → 10.0.15

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 (46) hide show
  1. package/dist/chat-memory.d.ts +43 -2
  2. package/dist/chat-memory.d.ts.map +1 -1
  3. package/dist/chat-memory.js +31 -7
  4. package/dist/chat-memory.js.map +1 -1
  5. package/dist/db.d.ts +23 -16
  6. package/dist/db.d.ts.map +1 -1
  7. package/dist/db.js +217 -108
  8. package/dist/db.js.map +1 -1
  9. package/dist/gelf-push-worker.d.ts +1 -1
  10. package/dist/gelf-push-worker.d.ts.map +1 -1
  11. package/dist/index.d.ts +2 -2
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +2 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/otlp-log-worker.d.ts +3 -5
  16. package/dist/otlp-log-worker.d.ts.map +1 -1
  17. package/dist/otlp-log-worker.js +7 -5
  18. package/dist/otlp-log-worker.js.map +1 -1
  19. package/dist/routine-log-retention.d.ts +40 -0
  20. package/dist/routine-log-retention.d.ts.map +1 -0
  21. package/dist/routine-log-retention.js +262 -0
  22. package/dist/routine-log-retention.js.map +1 -0
  23. package/dist/structured-logger.d.ts.map +1 -1
  24. package/dist/structured-logger.js.map +1 -1
  25. package/dist-ui/assets/{3d-force-graph-C9KowW4u.js → 3d-force-graph-CnBoK4hA.js} +1 -1
  26. package/dist-ui/assets/{AgentHub-BVrcom9_.js → AgentHub-BOUZTvCO.js} +1 -1
  27. package/dist-ui/assets/{AgentProfilePage-C9XaTt7j.js → AgentProfilePage-DpF722ai.js} +1 -1
  28. package/dist-ui/assets/{ApproveWalletsModal-BdR2mshx.js → ApproveWalletsModal-CYQqAWZs.js} +3 -3
  29. package/dist-ui/assets/{ConvictionDetailView-cao48GL1.js → ConvictionDetailView-DYsWNc9M.js} +1 -1
  30. package/dist-ui/assets/{Network-B3jCYzr5.js → Network-JVFsFYhR.js} +1 -1
  31. package/dist-ui/assets/{OnChainProvenanceCard-DboMX4Aq.js → OnChainProvenanceCard-DISbYKx1.js} +1 -1
  32. package/dist-ui/assets/{Operations-Dg1gfLXv.js → Operations-Cmt3YpaX.js} +1 -1
  33. package/dist-ui/assets/{PublishingConviction-Byp6t69e.js → PublishingConviction-L-wHlnRP.js} +1 -1
  34. package/dist-ui/assets/{Settings-CxI0SKeV.js → Settings-CvBKeoTW.js} +1 -1
  35. package/dist-ui/assets/{ccip-wJ3OMRuz.js → ccip-m-rmKwvf.js} +1 -1
  36. package/dist-ui/assets/index-BfrYentv.js +1645 -0
  37. package/dist-ui/assets/{index-5DS6xjw0.js → index-UAg6wmCv.js} +3 -3
  38. package/dist-ui/assets/index-VYV5TH6q.css +1 -0
  39. package/dist-ui/assets/{jsonld-32FQRO67-CDirPi8u.js → jsonld-32FQRO67-iGNyLix6.js} +2 -2
  40. package/dist-ui/assets/{jsonld-CwHW9UDN.js → jsonld-CDhTpz6H.js} +1 -1
  41. package/dist-ui/assets/{renderer-3d-2EVDZII7-BNXV6iSu.js → renderer-3d-2EVDZII7-BdEgi2VZ.js} +2 -2
  42. package/dist-ui/assets/{shikiHighlighter-C-LWPPSu.js → shikiHighlighter-Dhz3-Gg5.js} +1 -1
  43. package/dist-ui/index.html +2 -2
  44. package/package.json +5 -4
  45. package/dist-ui/assets/index-DOjsv0r7.css +0 -1
  46. package/dist-ui/assets/index-KoAnx9JE.js +0 -1661
package/dist/db.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { join } from 'node:path';
3
- import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, } from '@origintrail-official/dkg-core';
3
+ import { RESPONSE_CACHE_BYTES, parseContextGraphJoinPolicyRecord, DEFAULT_SYNC_CHECKPOINT_TTL_MS, isValidSyncCheckpointEntry, transitionSyncCheckpointManifestOffset, transitionSyncCheckpointOffset, transitionSyncCheckpointResponderSession, withoutSyncCheckpointResponderSession, } from '@origintrail-official/dkg-core';
4
+ import { RoutineLogRetention, installRoutineLogRetentionSchema, } from './routine-log-retention.js';
4
5
  export { SqliteChainEventCursorStore, SqliteContextGraphRegistryScanCursorStore, } from './chain-cursor-stores.js';
5
- const SCHEMA_VERSION = 31;
6
+ export const SCHEMA_VERSION = 35;
6
7
  // Default operator retention. Lowered from 90 → 14 days on V15 (2026-05) after
7
8
  // a production incident in which the `logs` table + its FTS5 shadow tables
8
9
  // grew to ~9 GB on a 12-day-old node and corrupted the SQLite page (header
@@ -12,9 +13,9 @@ const SCHEMA_VERSION = 31;
12
13
  // operator-driven post-mortem, but the mainnet sync storm proved that time
13
14
  // retention alone cannot bound worst-case growth. Operators who want longer
14
15
  // retention can override via `setRetentionDays()`; the setting is persisted
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
+ // in the `settings` table and re-read on next boot. The daemon no longer writes
17
+ // routine logs to SQLite; the established count cap still bounds compatibility
18
+ // writers while warning/error rows keep the operator-selected time window.
18
19
  const DEFAULT_RETENTION_DAYS = 14;
19
20
  const LEGACY_IMPLICIT_RETENTION_DAYS = 90;
20
21
  const DEFAULT_ROUTINE_LOG_ROW_CAP = 1_000_000;
@@ -104,17 +105,17 @@ export class DashboardDB {
104
105
  dataDir;
105
106
  retentionDays;
106
107
  explicitRetentionDays;
107
- routineLogRowCap;
108
- logVolumePruneBatchRows;
108
+ routineLogRetention;
109
109
  constructor(opts) {
110
110
  this.dataDir = opts.dataDir;
111
111
  this.explicitRetentionDays = opts.retentionDays !== undefined;
112
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));
113
+ const routineLogRowCap = Math.max(0, Math.floor(opts.routineLogRowCap ?? DEFAULT_ROUTINE_LOG_ROW_CAP));
114
+ const logVolumePruneBatchRows = Math.max(1, Math.floor(opts.logVolumePruneBatchRows ?? DEFAULT_LOG_VOLUME_PRUNE_BATCH_ROWS));
115
115
  this._memoTtlMs = resolveCacheTtlMs(opts.cacheTtlMs);
116
116
  const dbPath = join(opts.dataDir, 'node-ui.db');
117
117
  this.db = new Database(dbPath);
118
+ this.routineLogRetention = new RoutineLogRetention(this.db, routineLogRowCap, logVolumePruneBatchRows);
118
119
  this.db.pragma('journal_mode = WAL');
119
120
  this.db.pragma('synchronous = NORMAL');
120
121
  // Cap the persisted WAL file. A PASSIVE autocheckpoint resets WAL
@@ -172,7 +173,7 @@ export class DashboardDB {
172
173
  );
173
174
  `);
174
175
  };
175
- const ensureSyncCheckpointResponderSessionColumns = () => {
176
+ const ensureSyncCheckpointResumeColumns = () => {
176
177
  const table = this.db.prepare(`
177
178
  SELECT 1 AS found FROM sqlite_master
178
179
  WHERE type = 'table' AND name = 'sync_checkpoints'
@@ -187,6 +188,18 @@ export class DashboardDB {
187
188
  if (!columns.has('responder_session_expires_at')) {
188
189
  this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_expires_at INTEGER;`);
189
190
  }
191
+ if (!columns.has('responder_session_offset')) {
192
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN responder_session_offset INTEGER;`);
193
+ }
194
+ if (!columns.has('manifest_digest')) {
195
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN manifest_digest TEXT;`);
196
+ }
197
+ if (!columns.has('manifest_prefix_digest')) {
198
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN manifest_prefix_digest TEXT;`);
199
+ }
200
+ if (!columns.has('terminal')) {
201
+ this.db.exec(`ALTER TABLE sync_checkpoints ADD COLUMN terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1));`);
202
+ }
190
203
  };
191
204
  if (version > SCHEMA_VERSION)
192
205
  return;
@@ -194,8 +207,9 @@ export class DashboardDB {
194
207
  // Repair restored/development databases that carry the current version
195
208
  // but lost an idempotent schema adjunct.
196
209
  ensureJoinApprovalRepairMarker();
197
- ensureSyncCheckpointResponderSessionColumns();
210
+ ensureSyncCheckpointResumeColumns();
198
211
  ensureJoinPolicyAuditCapTrigger();
212
+ installRoutineLogRetentionSchema(this.db);
199
213
  return;
200
214
  }
201
215
  if (version < 1) {
@@ -789,7 +803,13 @@ export class DashboardDB {
789
803
  key TEXT PRIMARY KEY,
790
804
  offset INTEGER NOT NULL CHECK (offset >= 0),
791
805
  updated_at INTEGER NOT NULL,
792
- expires_at INTEGER NOT NULL
806
+ expires_at INTEGER NOT NULL,
807
+ responder_session_id TEXT,
808
+ responder_session_expires_at INTEGER,
809
+ responder_session_offset INTEGER CHECK (responder_session_offset >= 0),
810
+ manifest_digest TEXT,
811
+ manifest_prefix_digest TEXT,
812
+ terminal INTEGER NOT NULL DEFAULT 0 CHECK (terminal IN (0, 1))
793
813
  );
794
814
  CREATE INDEX IF NOT EXISTS idx_sync_checkpoints_expires_at
795
815
  ON sync_checkpoints(expires_at);
@@ -1023,7 +1043,7 @@ export class DashboardDB {
1023
1043
  // OFFSET pagination is scoped to an immutable responder row list. Keep
1024
1044
  // the opaque responder token beside the verified offset so a requester
1025
1045
  // restart can resume that same list instead of discarding durable work.
1026
- ensureSyncCheckpointResponderSessionColumns();
1046
+ ensureSyncCheckpointResumeColumns();
1027
1047
  }
1028
1048
  if (version < 31) {
1029
1049
  this.db.exec(`
@@ -1043,6 +1063,49 @@ export class DashboardDB {
1043
1063
  );
1044
1064
  `);
1045
1065
  }
1066
+ if (version < 32) {
1067
+ this.db.exec(`
1068
+ CREATE TABLE IF NOT EXISTS selected_vm_reconcile_cursors (
1069
+ deployment_id TEXT NOT NULL CHECK (length(trim(deployment_id)) > 0),
1070
+ context_graph_id TEXT NOT NULL,
1071
+ on_chain_context_graph_id TEXT NOT NULL,
1072
+ name_hash TEXT NOT NULL,
1073
+ watermark INTEGER NOT NULL CHECK (watermark >= 0),
1074
+ updated_at INTEGER NOT NULL,
1075
+ PRIMARY KEY (deployment_id, context_graph_id, on_chain_context_graph_id)
1076
+ );
1077
+ CREATE INDEX IF NOT EXISTS idx_selected_vm_reconcile_cursor_cg
1078
+ ON selected_vm_reconcile_cursors(deployment_id, context_graph_id);
1079
+ `);
1080
+ }
1081
+ if (version < 33) {
1082
+ // A durable DATA offset is reusable only with cryptographic proof of the
1083
+ // canonical META generation and verified descriptor prefix that gave it
1084
+ // meaning. Persist both digests beside the offset/session so production
1085
+ // SQLite checkpoints have the same safety contract as the in-memory
1086
+ // requester store and survive daemon restarts.
1087
+ ensureSyncCheckpointResumeColumns();
1088
+ }
1089
+ if (version < 34) {
1090
+ // The V33 manifest-bound continuation must not share a key with V32,
1091
+ // which interpreted OFFSET as an unverified raw responder coordinate.
1092
+ // Drop every pre-versioned durable DATA row before new code starts using
1093
+ // the `|checkpoint:v2` namespace, so a later rollback also fails closed.
1094
+ ensureSyncCheckpointResumeColumns();
1095
+ this.db.prepare(`
1096
+ DELETE FROM sync_checkpoints
1097
+ WHERE key LIKE '%|durable|data%'
1098
+ AND key NOT LIKE '%|durable|data|checkpoint:v2%'
1099
+ `).run();
1100
+ }
1101
+ if (version < 35) {
1102
+ // Retention probes used to find the (cap + 1)th newest routine row via
1103
+ // OFFSET, synchronously scanning the million-row cap on every cleanup
1104
+ // tick. Maintain the exact count transactionally and index only routine
1105
+ // row ids, so overflow checks are O(1) and each prune touches at most one
1106
+ // configured batch.
1107
+ installRoutineLogRetentionSchema(this.db);
1108
+ }
1046
1109
  this.db.pragma(`user_version = ${SCHEMA_VERSION}`);
1047
1110
  if (upgradedExistingDb && !this.explicitRetentionDays) {
1048
1111
  this.retentionDays = LEGACY_IMPLICIT_RETENTION_DAYS;
@@ -1059,14 +1122,7 @@ export class DashboardDB {
1059
1122
  }
1060
1123
  prune() {
1061
1124
  const cutoff = Date.now() - this.retentionDays * 86_400_000;
1062
- // Count total rows actually deleted across all DELETE statements.
1063
- // SQLite's per-statement change count is exposed via better-sqlite3's
1064
- // `Database.run().changes`, but `exec()` returns nothing — for a
1065
- // proper accounting we'd switch each statement to `prepare/run`. For
1066
- // the VACUUM gating decision below we only need to know whether
1067
- // *something* substantial was deleted, so we sample the only table
1068
- // that actually grows fast in practice: `logs`.
1069
- const logsDeleted = this.db.prepare(`DELETE FROM logs WHERE ts < ?`).run(cutoff).changes;
1125
+ this.db.prepare(`DELETE FROM logs WHERE ts < ?`).run(cutoff);
1070
1126
  this.db.exec(`DELETE FROM metric_snapshots WHERE ts < ${cutoff}`);
1071
1127
  this.db.exec(`DELETE FROM operation_phases WHERE started_at < ${cutoff}`);
1072
1128
  this.db.exec(`DELETE FROM operations WHERE started_at < ${cutoff}`);
@@ -1098,32 +1154,32 @@ export class DashboardDB {
1098
1154
  const messengerCutoff = Date.now() - 24 * 60 * 60 * 1000;
1099
1155
  this.db.exec(`DELETE FROM message_idempotency WHERE ts < ${messengerCutoff}`);
1100
1156
  // Avoid rebuilding an oversized DB at startup while millions of routine
1101
- // rows are still live. The daemon trims that backlog in bounded batches;
1102
- // the final batch performs one compacting VACUUM. Databases already under
1103
- // the cap retain the established time-prune reclamation behaviour.
1157
+ // rows are still live. The independent volume pruner trims only rows above
1158
+ // the published count cap, in bounded batches, and performs the final
1159
+ // compaction. Ambiguous rows below the cap remain untouched.
1104
1160
  if (!this.hasRoutineLogOverflow()) {
1105
- this.reclaimFreePagesIfNeeded(logsDeleted > LOGS_VACUUM_DELETE_THRESHOLD);
1161
+ this.reclaimFreePagesIfNeeded(false);
1106
1162
  }
1107
1163
  // Return the WAL file itself to the OS. journal_size_limit bounds it
1108
1164
  // in steady state, but a TRUNCATE checkpoint here shrinks it promptly
1109
- // on the prune cadence (~6h) and immediately after the VACUUM above,
1110
- // which rewrites the whole DB through the WAL and momentarily grows
1111
- // it. Runs unconditionally independent of the VACUUM gate — because
1112
- // an idle node still wants its -wal reclaimed.
1165
+ // on the prune cadence (~6h) and after a cleanup VACUUM, which rewrites
1166
+ // the whole DB through the WAL and momentarily grows it. Runs
1167
+ // unconditionally because an idle node still wants its -wal reclaimed.
1113
1168
  this.truncateWal('prune');
1114
1169
  }
1115
1170
  /**
1116
- * Remove one bounded batch of the oldest routine (non-warning/error) logs.
1117
- * A count cap complements time retention: a high-rate sync storm can create
1118
- * millions of rows inside a single day, long before a 14-day cutoff applies.
1171
+ * Remove one bounded batch of the oldest routine (non-warning/error) logs
1172
+ * above the public count cap. The cap is the only ownership-neutral rule we
1173
+ * can safely apply to pre-upgrade rows: historical daemon sink records and
1174
+ * published compatibility API records have identical schemas.
1119
1175
  *
1120
1176
  * Deletion is deliberately incremental so an upgrade does not block node
1121
1177
  * startup on a multi-GB transaction. Once the backlog reaches the cap, one
1122
1178
  * VACUUM returns the accumulated free pages to the OS and the file shrinks.
1123
1179
  */
1124
1180
  pruneLogVolumeBatch() {
1125
- const overflowCutoff = this.routineLogOverflowCutoff();
1126
- if (overflowCutoff === null) {
1181
+ const batch = this.routineLogRetention.pruneOverflowBatch();
1182
+ if (!batch.hadOverflow) {
1127
1183
  const reclaim = this.reclaimFreePagesIfNeeded(false);
1128
1184
  if (!reclaim.reclaimPending)
1129
1185
  this.truncateWal('log-volume prune');
@@ -1136,24 +1192,7 @@ export class DashboardDB {
1136
1192
  : 'done',
1137
1193
  };
1138
1194
  }
1139
- const deleted = this.db.prepare(`
1140
- DELETE FROM logs
1141
- WHERE id IN (
1142
- SELECT id
1143
- FROM logs
1144
- WHERE id <= @cutoff
1145
- AND level NOT IN ('warn', 'error')
1146
- ORDER BY id ASC
1147
- LIMIT @batchRows
1148
- )
1149
- `).run({
1150
- cutoff: overflowCutoff,
1151
- batchRows: this.logVolumePruneBatchRows,
1152
- }).changes;
1153
- // If the batch filled, conservatively schedule another tick. An exact-size
1154
- // final batch costs one extra cheap probe before compaction, which is safer
1155
- // than running a second million-row count after every deletion.
1156
- const hasMore = deleted === this.logVolumePruneBatchRows;
1195
+ const { deleted, hasMore } = batch;
1157
1196
  const reclaim = hasMore
1158
1197
  ? { compacted: false, reclaimPending: false }
1159
1198
  : this.reclaimFreePagesIfNeeded(deleted > LOGS_VACUUM_DELETE_THRESHOLD);
@@ -1170,18 +1209,8 @@ export class DashboardDB {
1170
1209
  : 'done',
1171
1210
  };
1172
1211
  }
1173
- routineLogOverflowCutoff() {
1174
- const row = this.db.prepare(`
1175
- SELECT id
1176
- FROM logs
1177
- WHERE level NOT IN ('warn', 'error')
1178
- ORDER BY id DESC
1179
- LIMIT 1 OFFSET ?
1180
- `).get(this.routineLogRowCap);
1181
- return row?.id ?? null;
1182
- }
1183
1212
  hasRoutineLogOverflow() {
1184
- return this.routineLogOverflowCutoff() !== null;
1213
+ return this.routineLogRetention.hasOverflow();
1185
1214
  }
1186
1215
  reclaimFreePagesIfNeeded(force) {
1187
1216
  // DELETE / DROP only marks pages reusable; VACUUM is what returns them to
@@ -1640,6 +1669,29 @@ export class DashboardDB {
1640
1669
  deleteVmReconcileNegativesForContextGraph(contextGraphId) {
1641
1670
  this.stmt('deleteVmReconcileNegativesForContextGraph', 'DELETE FROM vm_reconcile_negative_cache WHERE context_graph_id = ?').run(contextGraphId);
1642
1671
  }
1672
+ upsertSelectedVmReconcileCursor(record) {
1673
+ this.stmt('upsertSelectedVmReconcileCursor', `
1674
+ INSERT INTO selected_vm_reconcile_cursors (
1675
+ deployment_id, context_graph_id, on_chain_context_graph_id,
1676
+ name_hash, watermark, updated_at
1677
+ ) VALUES (
1678
+ @deployment_id, @context_graph_id, @on_chain_context_graph_id,
1679
+ @name_hash, @watermark, @updated_at
1680
+ )
1681
+ ON CONFLICT(deployment_id, context_graph_id, on_chain_context_graph_id) DO UPDATE SET
1682
+ name_hash = excluded.name_hash,
1683
+ watermark = excluded.watermark,
1684
+ updated_at = excluded.updated_at
1685
+ `).run(record);
1686
+ }
1687
+ getSelectedVmReconcileCursor(deploymentId, contextGraphId, onChainContextGraphId) {
1688
+ return this.stmt('getSelectedVmReconcileCursor', `
1689
+ SELECT * FROM selected_vm_reconcile_cursors
1690
+ WHERE deployment_id = ?
1691
+ AND context_graph_id = ?
1692
+ AND on_chain_context_graph_id = ?
1693
+ `).get(deploymentId, contextGraphId, onChainContextGraphId);
1694
+ }
1643
1695
  // --- Phase F: chain-driven VM reconciliation telemetry ---
1644
1696
  /**
1645
1697
  * Persist one {@link ReplicationEvent} (best-effort sink target). `ordinal`,
@@ -2464,6 +2516,7 @@ export class DashboardDB {
2464
2516
  module: entry.module,
2465
2517
  message: entry.message,
2466
2518
  });
2519
+ this.routineLogRetention.noteCommittedInsert(entry.level);
2467
2520
  }
2468
2521
  /**
2469
2522
  * Backwards-compatible DB-backed log search. V15 deliberately removed
@@ -2687,11 +2740,12 @@ export class DashboardDB {
2687
2740
  return rows.map((r) => r.id);
2688
2741
  }
2689
2742
  close() {
2743
+ if (!this.db.open)
2744
+ return;
2690
2745
  this.db.close();
2691
2746
  }
2692
2747
  }
2693
2748
  // --- Sync requester checkpoints (issue #1138 A3) ---
2694
- const DEFAULT_SYNC_CHECKPOINT_TTL_MS = 24 * 60 * 60 * 1000;
2695
2749
  export class SqliteSyncCheckpointStore {
2696
2750
  db;
2697
2751
  clock;
@@ -2701,73 +2755,128 @@ export class SqliteSyncCheckpointStore {
2701
2755
  this.clock = options.clock ?? (() => Date.now());
2702
2756
  this.ttlMs = options.ttlMs ?? DEFAULT_SYNC_CHECKPOINT_TTL_MS;
2703
2757
  }
2704
- get(key) {
2705
- const now = this.clock();
2758
+ readRow(key) {
2706
2759
  const row = this.db.prepare(`SELECT offset, updated_at, expires_at,
2707
- responder_session_id, responder_session_expires_at
2760
+ responder_session_id, responder_session_expires_at, responder_session_offset,
2761
+ manifest_digest, manifest_prefix_digest, terminal
2708
2762
  FROM sync_checkpoints WHERE key = ?`).get(key);
2709
2763
  if (!row)
2710
2764
  return undefined;
2711
- if (row.expires_at < now) {
2712
- this.delete(key);
2713
- return undefined;
2714
- }
2715
- const hasFreshResponderSession = Boolean(row.responder_session_id)
2716
- && Number.isSafeInteger(row.responder_session_expires_at)
2717
- && (row.responder_session_expires_at ?? 0) > now;
2718
- if (row.responder_session_id && !hasFreshResponderSession) {
2719
- this.clearResponderSession(key);
2720
- }
2721
2765
  return {
2722
2766
  offset: row.offset,
2723
2767
  updatedAtMs: row.updated_at,
2724
2768
  expiresAtMs: row.expires_at,
2725
- ...(hasFreshResponderSession
2726
- ? {
2727
- responderSessionId: row.responder_session_id,
2728
- responderSessionExpiresAtMs: row.responder_session_expires_at,
2729
- }
2769
+ ...(row.terminal === 1 ? { terminal: true } : {}),
2770
+ ...(row.manifest_digest ? { manifestDigest: row.manifest_digest } : {}),
2771
+ ...(row.manifest_prefix_digest
2772
+ ? { manifestPrefixDigest: row.manifest_prefix_digest }
2773
+ : {}),
2774
+ // Preserve each nullable session column independently so the shared
2775
+ // validator can distinguish an absent session from a torn/malformed
2776
+ // persisted session. Collapsing a partial row to no session fields would
2777
+ // turn corrupt durable state into an apparently valid ordinary offset.
2778
+ ...(row.responder_session_id !== null
2779
+ ? { responderSessionId: row.responder_session_id }
2780
+ : {}),
2781
+ ...(row.responder_session_expires_at !== null
2782
+ ? { responderSessionExpiresAtMs: row.responder_session_expires_at }
2783
+ : {}),
2784
+ ...(row.responder_session_offset !== null
2785
+ ? { responderSessionOffset: row.responder_session_offset }
2730
2786
  : {}),
2731
2787
  };
2732
2788
  }
2733
- set(key, value, nowMs = this.clock()) {
2734
- if (!Number.isSafeInteger(value) || value < 0) {
2735
- throw new Error(`Invalid sync checkpoint offset for ${key}: ${value}`);
2789
+ get(key, now = this.clock()) {
2790
+ const entry = this.readRow(key);
2791
+ if (!entry)
2792
+ return undefined;
2793
+ if (!isValidSyncCheckpointEntry(entry) || entry.expiresAtMs < now) {
2794
+ this.delete(key);
2795
+ return undefined;
2796
+ }
2797
+ if (entry.responderSessionId
2798
+ && (entry.responderSessionExpiresAtMs ?? 0) <= now) {
2799
+ const withoutExpiredSession = withoutSyncCheckpointResponderSession(entry);
2800
+ this.writeEntry(key, withoutExpiredSession);
2801
+ return withoutExpiredSession;
2736
2802
  }
2803
+ return entry;
2804
+ }
2805
+ writeEntry(key, entry) {
2737
2806
  this.db.prepare(`
2738
- INSERT INTO sync_checkpoints (key, offset, updated_at, expires_at)
2739
- VALUES (?, ?, ?, ?)
2807
+ INSERT INTO sync_checkpoints (
2808
+ key, offset, updated_at, expires_at,
2809
+ responder_session_id, responder_session_expires_at, responder_session_offset,
2810
+ manifest_digest, manifest_prefix_digest, terminal
2811
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
2740
2812
  ON CONFLICT(key) DO UPDATE SET
2741
2813
  offset = excluded.offset,
2742
2814
  updated_at = excluded.updated_at,
2743
- expires_at = excluded.expires_at
2744
- `).run(key, value, nowMs, nowMs + this.ttlMs);
2815
+ expires_at = excluded.expires_at,
2816
+ responder_session_id = excluded.responder_session_id,
2817
+ responder_session_expires_at = excluded.responder_session_expires_at,
2818
+ responder_session_offset = excluded.responder_session_offset,
2819
+ manifest_digest = excluded.manifest_digest,
2820
+ manifest_prefix_digest = excluded.manifest_prefix_digest,
2821
+ terminal = excluded.terminal
2822
+ `).run(key, entry.offset, entry.updatedAtMs, entry.expiresAtMs, entry.responderSessionId ?? null, entry.responderSessionExpiresAtMs ?? null, entry.responderSessionOffset ?? null, entry.manifestDigest ?? null, entry.manifestPrefixDigest ?? null, entry.terminal ? 1 : 0);
2823
+ }
2824
+ set(key, value, nowMs = this.clock(), responderSessionOffset) {
2825
+ const transition = this.db.transaction(() => {
2826
+ this.writeEntry(key, transitionSyncCheckpointOffset({
2827
+ key,
2828
+ existing: this.get(key, nowMs),
2829
+ value,
2830
+ nowMs,
2831
+ ttlMs: this.ttlMs,
2832
+ responderSessionOffset,
2833
+ }));
2834
+ });
2835
+ transition();
2745
2836
  }
2746
- setResponderSession(key, sessionId, expiresAtMs, nowMs = this.clock()) {
2747
- if (!sessionId || !Number.isSafeInteger(expiresAtMs)) {
2748
- throw new Error(`Invalid sync responder session for ${key}`);
2749
- }
2837
+ setManifestBoundOffset(key, value, manifestDigest, nowMs = this.clock(), manifestPrefixDigest, terminal = false, responderSessionOffset) {
2838
+ const transition = this.db.transaction(() => {
2839
+ this.writeEntry(key, transitionSyncCheckpointManifestOffset({
2840
+ key,
2841
+ existing: this.get(key, nowMs),
2842
+ value,
2843
+ manifestDigest,
2844
+ nowMs,
2845
+ ttlMs: this.ttlMs,
2846
+ manifestPrefixDigest,
2847
+ terminal,
2848
+ responderSessionOffset,
2849
+ }));
2850
+ });
2851
+ transition();
2852
+ }
2853
+ setResponderSession(key, sessionId, expiresAtMs, nowMs = this.clock(), manifestDigest, manifestPrefixDigest, responderSessionOffset) {
2750
2854
  if (expiresAtMs <= nowMs) {
2751
2855
  this.clearResponderSession(key);
2752
2856
  return;
2753
2857
  }
2754
- this.db.prepare(`
2755
- INSERT INTO sync_checkpoints (
2756
- key, offset, updated_at, expires_at,
2757
- responder_session_id, responder_session_expires_at
2758
- ) VALUES (?, 0, ?, ?, ?, ?)
2759
- ON CONFLICT(key) DO UPDATE SET
2760
- responder_session_id = excluded.responder_session_id,
2761
- responder_session_expires_at = excluded.responder_session_expires_at
2762
- `).run(key, nowMs, nowMs + this.ttlMs, sessionId, expiresAtMs);
2858
+ const transition = this.db.transaction(() => {
2859
+ this.writeEntry(key, transitionSyncCheckpointResponderSession({
2860
+ key,
2861
+ existing: this.get(key, nowMs),
2862
+ sessionId,
2863
+ expiresAtMs,
2864
+ nowMs,
2865
+ ttlMs: this.ttlMs,
2866
+ manifestDigest,
2867
+ manifestPrefixDigest,
2868
+ responderSessionOffset,
2869
+ }));
2870
+ });
2871
+ transition();
2763
2872
  }
2764
2873
  clearResponderSession(key) {
2765
- this.db.prepare(`
2766
- UPDATE sync_checkpoints
2767
- SET responder_session_id = NULL,
2768
- responder_session_expires_at = NULL
2769
- WHERE key = ?
2770
- `).run(key);
2874
+ const transition = this.db.transaction(() => {
2875
+ const existing = this.readRow(key);
2876
+ if (existing)
2877
+ this.writeEntry(key, withoutSyncCheckpointResponderSession(existing));
2878
+ });
2879
+ transition();
2771
2880
  }
2772
2881
  delete(key) {
2773
2882
  this.db.prepare(`DELETE FROM sync_checkpoints WHERE key = ?`).run(key);