@shawnstack/quickforge 1.7.10 → 1.7.12

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 (124) hide show
  1. package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
  2. package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
  4. package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
  7. package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
  8. package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
  9. package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
  10. package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
  11. package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
  12. package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
  13. package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
  14. package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
  15. package/dist/assets/channel-CeXY-wZo.js +1 -0
  16. package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
  17. package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
  18. package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
  19. package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
  20. package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
  21. package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
  22. package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
  23. package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
  24. package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
  25. package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
  26. package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
  27. package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
  28. package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
  29. package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
  30. package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
  31. package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
  32. package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
  33. package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
  34. package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
  35. package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
  36. package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
  37. package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
  38. package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
  39. package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
  40. package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
  41. package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
  42. package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
  43. package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
  44. package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
  46. package/dist/assets/index-BWf6y3Kh.css +3 -0
  47. package/dist/assets/index-D3GWkXJR.js +66 -0
  48. package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
  50. package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
  51. package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
  52. package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
  53. package/dist/assets/lit-vendor-CinchGmI.js +2 -0
  54. package/dist/assets/local-tools-DY0yEU3T.js +350 -0
  55. package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
  56. package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
  57. package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
  58. package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
  59. package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
  60. package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
  62. package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
  63. package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
  64. package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
  65. package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
  66. package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
  67. package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
  68. package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
  69. package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
  70. package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
  71. package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
  72. package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
  74. package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
  75. package/dist/index.html +6 -6
  76. package/package.json +1 -1
  77. package/server/acp/server.mjs +3 -6
  78. package/server/agent-manager.mjs +192 -48
  79. package/server/approval-store.mjs +1 -0
  80. package/server/ask-store.mjs +78 -0
  81. package/server/auto-archive.mjs +55 -8
  82. package/server/index.mjs +172 -51
  83. package/server/lan-access-cutover.mjs +21 -4
  84. package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
  85. package/server/maintenance/export-session-state-v1.mjs +1 -11
  86. package/server/routes/agent.mjs +9 -0
  87. package/server/routes/backup.mjs +1 -1
  88. package/server/routes/storage.mjs +63 -49
  89. package/server/scheduled-runs-cutover.mjs +15 -1
  90. package/server/session-index-service.mjs +57 -391
  91. package/server/session-state-backup.mjs +56 -5
  92. package/server/session-state-import.mjs +174 -0
  93. package/server/session-state-maintenance.mjs +126 -0
  94. package/server/session-state-service.mjs +128 -210
  95. package/server/share-cutover.mjs +21 -4
  96. package/server/sqlite/database.mjs +142 -7
  97. package/server/sqlite/lan-access-repository.mjs +6 -4
  98. package/server/sqlite/migrations.mjs +109 -0
  99. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  100. package/server/sqlite/session-index-repository.mjs +59 -208
  101. package/server/sqlite/session-state-repository.mjs +337 -387
  102. package/server/sqlite/share-repository.mjs +56 -45
  103. package/server/startup-state.mjs +107 -0
  104. package/server/storage.mjs +218 -454
  105. package/server/tools/definitions.mjs +18 -0
  106. package/server/tools/index.mjs +14 -2
  107. package/server/utils/logger.mjs +15 -4
  108. package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
  109. package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
  110. package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
  111. package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
  112. package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
  113. package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
  114. package/dist/assets/channel-Duu1CuH9.js +0 -1
  115. package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
  116. package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
  117. package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
  118. package/dist/assets/index-DQBOm9gt.js +0 -66
  119. package/dist/assets/index-ps85guTs.css +0 -3
  120. package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
  121. package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
  122. package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
  123. package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
  124. package/server/session-state-cutover.mjs +0 -563
@@ -1,16 +1,39 @@
1
1
  import { createHash } from 'node:crypto'
2
- import { getSqliteStorage } from './database.mjs'
2
+ import { logger } from '../utils/logger.mjs'
3
+ import { getSqliteStorage, runSharedSqliteQuickCheck } from './database.mjs'
3
4
 
4
- const STATE_COLUMNS = `
5
- scope, project_id, session_id, revision, state_version, state_json, state_digest,
6
- metadata_json, metadata_digest, created_at, updated_at
5
+ const SESSION_ROW_COLUMNS = `
6
+ scope, project_id, session_id, revision, state_version, created_at, updated_at,
7
+ message_count, title, title_source, harness, task_status, archived_at, pinned_at,
8
+ body_json, meta_json, updated_at_ms
7
9
  `
8
10
 
9
- // F9 split-message representation: a session whose messages live in
10
- // `session_messages` stores a body without the `messages` key plus this marker.
11
+ // v2 message representation (schema v11): every session's messages live in
12
+ // `session_messages`; the stored body carries this marker and never carries a
13
+ // `messages` key. Kept for the service's assembleState/plan readers.
11
14
  export const MESSAGES_SPLIT_VALUE = 'split'
12
15
  export const MESSAGES_PAGE_LIMIT_MAX = 5000
13
16
 
17
+ // Runtime hot-path statement cache (review P4): save/append used to re-prepare
18
+ // the same handful of statements on every call. Statements are cached per
19
+ // storage handle; the WeakMap releases the whole cache when the handle is
20
+ // dropped, so a closed DatabaseSync never leaks stale statements.
21
+ const statementCache = new WeakMap()
22
+
23
+ function cachedStatement(database, sql) {
24
+ let statements = statementCache.get(database)
25
+ if (!statements) {
26
+ statements = new Map()
27
+ statementCache.set(database, statements)
28
+ }
29
+ let statement = statements.get(sql)
30
+ if (!statement) {
31
+ statement = database.prepare(sql)
32
+ statements.set(sql, statement)
33
+ }
34
+ return statement
35
+ }
36
+
14
37
  function isPlainObject(value) {
15
38
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
16
39
  }
@@ -42,7 +65,7 @@ export function messageDigest(message) {
42
65
 
43
66
  // Digest over an ordered message array (seq = array index). Mirrors the
44
67
  // per-session aggregate computed from stored rows, so assembled plan/backup
45
- // values hash identically to their stored (split) representation.
68
+ // values hash identically to their stored representation.
46
69
  export function messagesDigestFromValues(messages) {
47
70
  if (!Array.isArray(messages) || messages.length === 0) return ''
48
71
  const rows = messages.map((message, seq) => ({ seq, message_digest: messageDigest(message) }))
@@ -57,27 +80,28 @@ function messagesDigest(rows) {
57
80
 
58
81
  // Canonical digest line shared by the repository snapshot digest, the JSON
59
82
  // cutover snapshot, restore-plan digest and offline tools. A session with no
60
- // separate message rows contributes an empty `messagesDigest`.
83
+ // message rows contributes an empty `messagesDigest`.
61
84
  export function snapshotDigestLine(scope, projectId, sessionId, stateDigest, metadataDigest, messagesDigestValue = '') {
62
85
  return `${scope}\0${projectId || ''}\0${sessionId}\0${stateDigest}\0${metadataDigest}\0${messagesDigestValue || ''}`
63
86
  }
64
87
 
65
- // Split a (possibly assembled) state into its stored body and message array.
66
- // - `messageStorage: 'split'` bodies: messages are stripped out and returned.
67
- // - inline `messages` bodies: kept whole (non-split) and return no messages.
68
- // Never mutates the input and never forces a marker onto legacy bodies.
88
+ // Split a (possibly assembled) state into its stored body and message array,
89
+ // mirroring the repository's v2 extraction rule: ANY body carrying a plain
90
+ // `messages` array (inline or split-marked) has it extracted wholesale, and
91
+ // the stored body carries the split marker. Never mutates the input.
69
92
  export function splitStateForStorage(state) {
70
93
  const copy = structuredClone(state)
71
94
  if (!isPlainObject(copy)) throw new TypeError('state must be a plain object')
72
- if (copy.messageStorage === MESSAGES_SPLIT_VALUE) {
73
- const messages = Array.isArray(copy.messages) ? copy.messages : []
95
+ if (Array.isArray(copy.messages)) {
96
+ const messages = copy.messages
74
97
  delete copy.messages
98
+ copy.messageStorage = MESSAGES_SPLIT_VALUE
75
99
  return { storedState: copy, messages }
76
100
  }
77
101
  return { storedState: copy, messages: undefined }
78
102
  }
79
103
 
80
- function encodeMessage(message, timestamp) {
104
+ function encodeMessage(message) {
81
105
  if (!isPlainObject(message)) throw new TypeError('message must be a plain object')
82
106
  let parsed
83
107
  try {
@@ -92,14 +116,12 @@ function encodeMessage(message, timestamp) {
92
116
  messageId,
93
117
  messageJson: json,
94
118
  messageDigest: createHash('sha256').update(json).digest('hex'),
95
- created: timestamp,
96
- updated: timestamp,
97
119
  }
98
120
  }
99
121
 
100
- function encodeMessages(messages, timestamp) {
122
+ function encodeMessages(messages) {
101
123
  if (!Array.isArray(messages)) throw new TypeError('messages must be an array')
102
- return messages.map((message) => encodeMessage(message, timestamp))
124
+ return messages.map((message) => encodeMessage(message))
103
125
  }
104
126
 
105
127
  function nonEmptyString(value, field) {
@@ -125,6 +147,17 @@ function normalizeExpectedStateVersion(value) {
125
147
  return value
126
148
  }
127
149
 
150
+ function nullableString(value) {
151
+ return typeof value === 'string' ? value : null
152
+ }
153
+
154
+ // Column projection timestamps: updated_at_ms / tombstone deleted_at carry
155
+ // epoch milliseconds derived from the injected clock.
156
+ function epochMillis(timestamp) {
157
+ const parsed = Date.parse(timestamp)
158
+ return Number.isFinite(parsed) ? parsed : Date.now()
159
+ }
160
+
128
161
  function normalizeRecord(input, timestamp) {
129
162
  if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('session state record must be an object')
130
163
  const state = structuredClone(input.state)
@@ -151,10 +184,15 @@ function normalizeRecord(input, timestamp) {
151
184
  else delete value.projectId
152
185
  }
153
186
 
154
- // Optional split-message payload: when `input.messages` is provided the body
155
- // is stored without `messages` and carries the split marker; the message rows
156
- // are written in the same transaction by the caller-selected mode.
157
- const messages = input.messages !== undefined ? encodeMessages(input.messages, timestamp) : undefined
187
+ // v2 unified message handling: ANY body carrying `messages` (a plain inline
188
+ // array, or a legacy split-marked body with its inline copy) is extracted
189
+ // wholesale the caller-selected mode ('replace' by default, 'append' for
190
+ // incremental tails) writes the rows, and body_json never contains the
191
+ // messages array. A body without messages (body-only save) leaves the
192
+ // stored message rows untouched.
193
+ let incomingMessages = input.messages
194
+ if (incomingMessages === undefined && Array.isArray(state.messages)) incomingMessages = state.messages
195
+ const messages = incomingMessages !== undefined ? encodeMessages(incomingMessages) : undefined
158
196
  if (messages !== undefined) {
159
197
  delete state.messages
160
198
  state.messageStorage = MESSAGES_SPLIT_VALUE
@@ -210,91 +248,20 @@ function mapRow(row) {
210
248
  sessionId: row.session_id,
211
249
  revision: Number(row.revision),
212
250
  stateVersion: Number(row.state_version),
213
- state: JSON.parse(row.state_json),
214
- stateDigest: row.state_digest,
215
- metadata: JSON.parse(row.metadata_json),
216
- metadataDigest: row.metadata_digest,
251
+ state: JSON.parse(row.body_json),
252
+ stateDigest: createHash('sha256').update(row.body_json).digest('hex'),
253
+ metadata: JSON.parse(row.meta_json),
254
+ metadataDigest: createHash('sha256').update(row.meta_json).digest('hex'),
217
255
  createdAt: row.created_at,
218
256
  updatedAt: row.updated_at,
219
257
  }
220
258
  }
221
259
 
222
- function nullableString(value) {
223
- return typeof value === 'string' ? value : null
224
- }
225
-
226
- function nullableInteger(value) {
227
- return Number.isInteger(value) && value >= 0 ? value : null
228
- }
229
-
230
- function upsertIndex(database, record) {
231
- const metadata = record.metadata
232
- database.prepare(`
233
- INSERT INTO session_index (
234
- scope, project_id, session_id, created_at, last_modified, message_count,
235
- pinned_at, archived_at, is_pinned, is_archived, state_version,
236
- metadata_json, metadata_digest, indexed_at
237
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
238
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
239
- created_at = excluded.created_at,
240
- last_modified = excluded.last_modified,
241
- message_count = excluded.message_count,
242
- pinned_at = excluded.pinned_at,
243
- archived_at = excluded.archived_at,
244
- is_pinned = excluded.is_pinned,
245
- is_archived = excluded.is_archived,
246
- state_version = excluded.state_version,
247
- metadata_json = excluded.metadata_json,
248
- metadata_digest = excluded.metadata_digest,
249
- indexed_at = excluded.indexed_at
250
- `).run(
251
- record.scope, record.projectId, record.sessionId,
252
- nullableString(metadata.createdAt), nullableString(metadata.lastModified), nullableInteger(metadata.messageCount),
253
- nullableString(metadata.pinnedAt), nullableString(metadata.archivedAt), metadata.pinnedAt ? 1 : 0, metadata.archivedAt ? 1 : 0,
254
- record.stateVersion, record.metadataJson, record.metadataDigest, record.now,
255
- )
256
- }
257
-
258
- function enqueueMirror(database, record, operation, revision) {
259
- database.prepare(`
260
- INSERT INTO session_json_mirror_queue (
261
- scope, project_id, session_id, operation, revision, state_json, metadata_json, attempts, last_error, updated_at
262
- ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)
263
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
264
- operation = excluded.operation,
265
- revision = excluded.revision,
266
- state_json = excluded.state_json,
267
- metadata_json = excluded.metadata_json,
268
- attempts = 0,
269
- last_error = NULL,
270
- updated_at = excluded.updated_at
271
- `).run(
272
- record.scope, record.projectId, record.sessionId, operation, revision,
273
- operation === 'upsert' ? record.stateJson : null,
274
- operation === 'upsert' ? record.metadataJson : null,
275
- record.now,
276
- )
277
- }
278
-
279
- function digestRows(database) {
280
- const rows = database.prepare('SELECT scope, project_id, session_id, state_digest, metadata_digest FROM session_states').all()
281
- const groups = new Map()
282
- for (const row of database.prepare('SELECT scope, project_id, session_id, seq, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()) {
283
- const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
284
- if (!groups.has(key)) groups.set(key, [])
285
- groups.get(key).push(row)
286
- }
287
- return rows.map((row) => ({
288
- scope: row.scope,
289
- project_id: row.project_id,
290
- session_id: row.session_id,
291
- state_digest: row.state_digest,
292
- metadata_digest: row.metadata_digest,
293
- messages_digest: messagesDigest(groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []),
294
- }))
295
- }
296
-
297
- function digestFromLines(lines) {
260
+ // Canonical digest over digest lines: lines are sorted in UTF-16 code unit
261
+ // (byte) order before hashing. Source-side cutover digests MUST be computed
262
+ // through this function so verification never diverges on collation-sensitive
263
+ // session ids.
264
+ export function digestFromLines(lines) {
298
265
  return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
299
266
  }
300
267
 
@@ -316,46 +283,128 @@ export function sessionStateSnapshotDigest(records) {
316
283
  }
317
284
 
318
285
  function actualRevision(database, record) {
319
- const active = database.prepare('SELECT revision, state_version, created_at FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
286
+ const active = cachedStatement(database, 'SELECT revision, state_version, created_at, message_count FROM sessions WHERE scope = ? AND project_id = ? AND session_id = ?')
320
287
  .get(record.scope, record.projectId, record.sessionId)
321
- if (active) return { revision: Number(active.revision), stateVersion: Number(active.state_version), createdAt: active.created_at, active: true }
322
- const tombstone = database.prepare('SELECT revision FROM session_state_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
288
+ if (active) {
289
+ return { revision: Number(active.revision), stateVersion: Number(active.state_version), createdAt: active.created_at, messageCount: Number(active.message_count), active: true }
290
+ }
291
+ // v2 tombstones carry no revision: a same-key tombstone resolves to
292
+ // revision 0, so a stale CAS write (expectedRevision > 0) still conflicts
293
+ // while a fresh recreate (expectedRevision null/0) succeeds.
294
+ const tombstone = cachedStatement(database, 'SELECT deleted_at FROM session_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
323
295
  .get(record.scope, record.projectId, record.sessionId)
324
- return { revision: Number(tombstone?.revision || 0), stateVersion: null, createdAt: null, active: false }
296
+ return { revision: 0, stateVersion: null, createdAt: null, messageCount: 0, tombstoneAt: tombstone ? Number(tombstone.deleted_at) : null, active: false }
325
297
  }
326
298
 
327
299
  function assertNoCrossBucketDuplicate(database, record) {
328
- const other = database.prepare(`SELECT scope, project_id FROM session_states
300
+ const other = cachedStatement(database, `SELECT scope, project_id FROM sessions
329
301
  WHERE session_id = ? AND NOT (scope = ? AND project_id = ?) LIMIT 1`)
330
302
  .get(record.sessionId, record.scope, record.projectId)
331
303
  if (other) throw duplicate(record.sessionId)
332
304
  }
333
305
 
334
- function writeMessages(database, record, mode, timestamp) {
306
+ const INSERT_MESSAGE_SQL = `
307
+ INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest)
308
+ VALUES (?, ?, ?, ?, ?, ?, ?)
309
+ `
310
+ const MESSAGE_COUNT_SQL = 'SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?'
311
+ // Append dedup probes stored ids in bounded IN(...) chunks (review P1).
312
+ const MESSAGE_ID_DEDUP_BATCH_SIZE = 500
313
+
314
+ const UPSERT_SESSION_SQL = `
315
+ INSERT INTO sessions (
316
+ scope, project_id, session_id, title, title_source, created_at, updated_at,
317
+ message_count, state_version, harness, task_status, archived_at, pinned_at,
318
+ body_json, meta_json, revision, updated_at_ms
319
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
320
+ ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
321
+ title = excluded.title,
322
+ title_source = excluded.title_source,
323
+ updated_at = excluded.updated_at,
324
+ message_count = excluded.message_count,
325
+ state_version = excluded.state_version,
326
+ harness = excluded.harness,
327
+ task_status = excluded.task_status,
328
+ archived_at = excluded.archived_at,
329
+ pinned_at = excluded.pinned_at,
330
+ body_json = excluded.body_json,
331
+ meta_json = excluded.meta_json,
332
+ revision = excluded.revision,
333
+ updated_at_ms = excluded.updated_at_ms
334
+ `
335
+
336
+ // sessions.message_count is derived state (never trusted from callers): after
337
+ // any message write it is recomputed from the stored rows in the same
338
+ // transaction, and full integrity verification re-checks it per session.
339
+ const SYNC_MESSAGE_COUNT_SQL = `
340
+ UPDATE sessions SET message_count = (
341
+ SELECT COUNT(*) FROM session_messages
342
+ WHERE scope = sessions.scope AND project_id = sessions.project_id AND session_id = sessions.session_id
343
+ ) WHERE scope = ? AND project_id = ? AND session_id = ?
344
+ `
345
+
346
+ // Column values for the sessions projection; list columns come from the
347
+ // normalized state/metadata pair (state wins, metadata is the fallback).
348
+ function sessionRowValues(record, { revision, createdAt, messageCount }) {
349
+ const state = record.state
350
+ const metadata = record.metadata
351
+ return [
352
+ record.scope, record.projectId, record.sessionId,
353
+ nullableString(state.title) ?? nullableString(metadata.title) ?? '',
354
+ nullableString(state.titleSource) ?? nullableString(metadata.titleSource),
355
+ createdAt, record.now,
356
+ messageCount, record.stateVersion,
357
+ nullableString(state.harness) ?? nullableString(metadata.harness),
358
+ nullableString(state.taskStatus) ?? nullableString(metadata.taskStatus),
359
+ nullableString(state.archivedAt) ?? nullableString(metadata.archivedAt),
360
+ nullableString(state.pinnedAt) ?? nullableString(metadata.pinnedAt),
361
+ record.stateJson, record.metadataJson, revision,
362
+ epochMillis(record.now),
363
+ ]
364
+ }
365
+
366
+ function writeMessages(database, record, mode) {
335
367
  if (record.messages === undefined) return
336
368
  if (mode !== 'replace' && mode !== 'append') throw new TypeError('messagesMode must be replace or append')
337
369
  const params = [record.scope, record.projectId, record.sessionId]
338
370
  if (mode === 'replace') {
339
- database.prepare('DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').run(...params)
340
- const insert = database.prepare(`
341
- INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
342
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
343
- `)
371
+ cachedStatement(database, 'DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').run(...params)
372
+ const insert = cachedStatement(database, INSERT_MESSAGE_SQL)
344
373
  record.messages.forEach((message, seq) => {
345
- insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest, timestamp, timestamp)
374
+ insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest)
346
375
  })
347
376
  return
348
377
  }
349
- const maxSeq = Number(database.prepare('SELECT COALESCE(MAX(seq), -1) AS max_seq FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').get(...params).max_seq)
350
- const existingIds = new Set(database.prepare('SELECT message_id FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ? AND message_id IS NOT NULL').all(...params).map((row) => row.message_id))
351
- const insert = database.prepare(`
352
- INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
353
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
354
- `)
378
+ // Conservative retry dedup for id-less batches: when every incoming message
379
+ // lacks an id AND the batch digest sequence exactly equals the stored tail
380
+ // (same length, same order), the batch is a retry of an append that already
381
+ // persisted and is skipped wholesale. Partial overlaps and mixed id/no-id
382
+ // batches are appended as usual, so a genuinely new message whose content
383
+ // repeats is only skipped when the ENTIRE batch replays the tail.
384
+ if (record.messages.length > 0 && record.messages.every((message) => message.messageId === null)) {
385
+ const tailRows = cachedStatement(database, `SELECT message_digest FROM session_messages
386
+ WHERE scope = ? AND project_id = ? AND session_id = ? ORDER BY seq DESC LIMIT ?`).all(...params, record.messages.length)
387
+ if (tailRows.length === record.messages.length
388
+ && tailRows.every((row, index) => row.message_digest === record.messages[record.messages.length - 1 - index].messageDigest)) {
389
+ return
390
+ }
391
+ }
392
+ const maxSeq = Number(cachedStatement(database, 'SELECT COALESCE(MAX(seq), -1) AS max_seq FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').get(...params).max_seq)
393
+ // Id dedup costs O(incoming) instead of O(stored): only the ids this batch
394
+ // carries are probed against the stored rows.
395
+ const existingIds = new Set()
396
+ const incomingIds = [...new Set(record.messages.map((message) => message.messageId).filter((messageId) => messageId !== null))]
397
+ for (let start = 0; start < incomingIds.length; start += MESSAGE_ID_DEDUP_BATCH_SIZE) {
398
+ const chunk = incomingIds.slice(start, start + MESSAGE_ID_DEDUP_BATCH_SIZE)
399
+ const probe = cachedStatement(database, `SELECT message_id FROM session_messages
400
+ WHERE scope = ? AND project_id = ? AND session_id = ? AND message_id IN (${chunk.map(() => '?').join(', ')})`)
401
+ for (const row of probe.all(...params, ...chunk)) existingIds.add(row.message_id)
402
+ }
403
+ const insert = cachedStatement(database, INSERT_MESSAGE_SQL)
355
404
  let seq = maxSeq + 1
356
405
  for (const message of record.messages) {
357
406
  if (message.messageId !== null && existingIds.has(message.messageId)) continue
358
- insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest, timestamp, timestamp)
407
+ insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest)
359
408
  if (message.messageId !== null) existingIds.add(message.messageId)
360
409
  seq += 1
361
410
  }
@@ -367,27 +416,26 @@ function saveInTransaction(database, record, expectedRevision, expectedStateVers
367
416
  if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
368
417
  if (expectedStateVersion !== null && current.stateVersion !== expectedStateVersion) throw stateVersionConflict(record.sessionId, expectedStateVersion, current.stateVersion)
369
418
  const revision = current.revision + 1
370
- database.prepare('DELETE FROM session_state_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
419
+ // A same-key tombstone whose deletion is below the live CAS chain is
420
+ // collected here: the CAS chain has taken over resurrection protection
421
+ // (tombstones of never-recreated sessions are kept by design).
422
+ cachedStatement(database, 'DELETE FROM session_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
371
423
  .run(record.scope, record.projectId, record.sessionId)
372
- if (record.messages !== undefined) writeMessages(database, record, messagesMode ?? 'replace', record.now)
373
- database.prepare(`
374
- INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
375
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
376
- revision = excluded.revision,
377
- state_version = excluded.state_version,
378
- state_json = excluded.state_json,
379
- state_digest = excluded.state_digest,
380
- metadata_json = excluded.metadata_json,
381
- metadata_digest = excluded.metadata_digest,
382
- updated_at = excluded.updated_at
383
- `).run(
384
- record.scope, record.projectId, record.sessionId, revision, record.stateVersion,
385
- record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest,
386
- current.createdAt || record.now, record.now,
387
- )
388
- upsertIndex(database, record)
389
- enqueueMirror(database, record, 'upsert', revision)
390
- return { ...record, revision, createdAt: current.createdAt || record.now, updatedAt: record.now }
424
+ const createdAt = current.createdAt || nullableString(record.metadata.createdAt) || record.now
425
+ // The sessions row (FK parent) goes first; message rows reference it.
426
+ cachedStatement(database, UPSERT_SESSION_SQL).run(...sessionRowValues(record, {
427
+ revision,
428
+ createdAt,
429
+ messageCount: current.messageCount,
430
+ }))
431
+ if (record.messages !== undefined) {
432
+ writeMessages(database, record, messagesMode ?? 'replace')
433
+ cachedStatement(database, SYNC_MESSAGE_COUNT_SQL).run(record.scope, record.projectId, record.sessionId)
434
+ }
435
+ const messageCount = record.messages !== undefined
436
+ ? Number(cachedStatement(database, MESSAGE_COUNT_SQL).get(record.scope, record.projectId, record.sessionId).count)
437
+ : current.messageCount
438
+ return { ...record, revision, createdAt, updatedAt: record.now, messageCount }
391
439
  }
392
440
 
393
441
  function deleteInTransaction(database, input, expectedRevision, timestamp) {
@@ -396,32 +444,28 @@ function deleteInTransaction(database, input, expectedRevision, timestamp) {
396
444
  const current = actualRevision(database, record)
397
445
  if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
398
446
  if (!current.active) return false
399
- const deletionRevision = current.revision + 1
400
- database.prepare('DELETE FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
401
- .run(record.scope, record.projectId, record.sessionId)
402
- database.prepare('DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
447
+ // Message rows follow through the FK ON DELETE CASCADE.
448
+ cachedStatement(database, 'DELETE FROM sessions WHERE scope = ? AND project_id = ? AND session_id = ?')
403
449
  .run(record.scope, record.projectId, record.sessionId)
404
- database.prepare('DELETE FROM session_index WHERE scope = ? AND project_id = ? AND session_id = ?')
405
- .run(record.scope, record.projectId, record.sessionId)
406
- database.prepare(`INSERT INTO session_state_tombstones (scope, project_id, session_id, revision, deleted_at)
407
- VALUES (?, ?, ?, ?, ?)
408
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET revision = excluded.revision, deleted_at = excluded.deleted_at`)
409
- .run(record.scope, record.projectId, record.sessionId, deletionRevision, timestamp)
410
- enqueueMirror(database, { ...record, stateJson: null, metadataJson: null, now: timestamp }, 'delete', deletionRevision)
450
+ cachedStatement(database, `INSERT INTO session_tombstones (scope, project_id, session_id, deleted_at)
451
+ VALUES (?, ?, ?, ?)
452
+ ON CONFLICT(scope, project_id, session_id) DO UPDATE SET deleted_at = excluded.deleted_at`)
453
+ .run(record.scope, record.projectId, record.sessionId, epochMillis(timestamp))
411
454
  return true
412
455
  }
413
456
 
414
- function updateStorageState(database, values) {
415
- if (!values) return
416
- database.prepare(`UPDATE session_storage_state SET phase = ?, state_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
417
- WHERE singleton = 1`).run(
418
- values.phase,
419
- values.stateCount ?? null,
420
- values.digest ?? null,
421
- values.backupFile ?? null,
422
- values.diagnostic ? JSON.stringify(values.diagnostic) : null,
423
- values.updatedAt,
424
- )
457
+ // Space reclamation (schema v11): with auto_vacuum = INCREMENTAL a bounded
458
+ // incremental_vacuum returns freed pages to the OS. Best-effort by design —
459
+ // it runs OUTSIDE any transaction after a deletion actually removed rows and
460
+ // a failure only logs (never fails the caller's operation).
461
+ function vacuumAfterDelete(storageHandle) {
462
+ try {
463
+ storageHandle.prepare('PRAGMA incremental_vacuum(512)').all()
464
+ } catch (error) {
465
+ logger.warn('SQLite incremental_vacuum after session delete failed', {
466
+ error: error instanceof Error ? error.message : String(error),
467
+ })
468
+ }
425
469
  }
426
470
 
427
471
  export function createSessionStateRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
@@ -433,13 +477,13 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
433
477
  function get(scope, projectId, sessionId) {
434
478
  const normalizedBucket = bucket(scope, projectId)
435
479
  nonEmptyString(sessionId, 'sessionId')
436
- return mapRow(storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?`)
480
+ return mapRow(cachedStatement(storage, `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE scope = ? AND project_id = ? AND session_id = ?`)
437
481
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId))
438
482
  }
439
483
 
440
484
  function findBySessionId(sessionId) {
441
485
  nonEmptyString(sessionId, 'sessionId')
442
- const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE session_id = ? ORDER BY scope, project_id`).all(sessionId)
486
+ const rows = cachedStatement(storage, `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE session_id = ? ORDER BY scope, project_id`).all(sessionId)
443
487
  if (rows.length > 1) throw duplicate(sessionId)
444
488
  return mapRow(rows[0])
445
489
  }
@@ -471,13 +515,6 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
471
515
  const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
472
516
  const record = normalizeRecord({ ...input, messages }, now())
473
517
  return storage.transaction((database) => {
474
- const current = actualRevision(database, record)
475
- if (current.active) {
476
- const stored = database.prepare('SELECT state_json FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
477
- .get(record.scope, record.projectId, record.sessionId)
478
- const parsed = JSON.parse(stored.state_json)
479
- if (Array.isArray(parsed.messages)) throw new TypeError('Session is not split; use replaceMessages to split it first')
480
- }
481
518
  const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'append' })
482
519
  beforeCommit?.(database)
483
520
  return result
@@ -499,10 +536,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
499
536
  } else if (offset > 0) {
500
537
  limitParams = [limit, offset]
501
538
  }
502
- const rows = storage.prepare(`SELECT seq, message_id, message_json, message_digest FROM session_messages
539
+ const rows = cachedStatement(storage, `SELECT seq, message_json, message_digest FROM session_messages
503
540
  WHERE ${where.join(' AND ')} ORDER BY seq ASC LIMIT ?${offset > 0 && afterSeq === null ? ' OFFSET ?' : ''}`)
504
541
  .all(...params, ...limitParams)
505
- const total = Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
542
+ const total = Number(cachedStatement(storage, MESSAGE_COUNT_SQL)
506
543
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
507
544
  const lastSeq = rows.length > 0 ? Number(rows[rows.length - 1].seq) : null
508
545
  return {
@@ -519,18 +556,43 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
519
556
  function messageCount({ scope, projectId, sessionId }) {
520
557
  const normalizedBucket = bucket(scope, projectId)
521
558
  nonEmptyString(sessionId, 'sessionId')
522
- return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
559
+ return Number(cachedStatement(storage, MESSAGE_COUNT_SQL)
523
560
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
524
561
  }
525
562
 
563
+ // O(log n) tail probe: the primary key ends with `seq`, so DESC LIMIT 1
564
+ // rides the index instead of scanning a deep OFFSET.
565
+ function readLastMessage({ scope, projectId, sessionId }) {
566
+ const normalizedBucket = bucket(scope, projectId)
567
+ nonEmptyString(sessionId, 'sessionId')
568
+ const row = cachedStatement(storage, `SELECT seq, message_json, message_digest FROM session_messages
569
+ WHERE scope = ? AND project_id = ? AND session_id = ? ORDER BY seq DESC LIMIT 1`)
570
+ .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId)
571
+ if (!row) return null
572
+ return { seq: Number(row.seq), message: JSON.parse(row.message_json), digest: row.message_digest }
573
+ }
574
+
575
+ // Single-row digest probe used by the service's body-only plan check.
576
+ function readMessageDigestAt({ scope, projectId, sessionId, seq }) {
577
+ const normalizedBucket = bucket(scope, projectId)
578
+ nonEmptyString(sessionId, 'sessionId')
579
+ if (!Number.isInteger(seq) || seq < 0) throw new TypeError('seq must be a non-negative integer')
580
+ const row = cachedStatement(storage, `SELECT message_digest FROM session_messages
581
+ WHERE scope = ? AND project_id = ? AND session_id = ? AND seq = ?`)
582
+ .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId, seq)
583
+ return row ? row.message_digest : null
584
+ }
585
+
526
586
  function deleteRecord({ scope, projectId, sessionId, expectedRevision = null, beforeCommit } = {}) {
527
587
  const expected = normalizeRevision(expectedRevision, { optional: true })
528
588
  const timestamp = now()
529
- return storage.transaction((database) => {
589
+ const deleted = storage.transaction((database) => {
530
590
  const result = deleteInTransaction(database, { scope, projectId, sessionId }, expected, timestamp)
531
591
  beforeCommit?.(database)
532
592
  return result
533
593
  }, { mode: 'immediate' })
594
+ if (deleted) vacuumAfterDelete(storage)
595
+ return deleted
534
596
  }
535
597
 
536
598
  function deleteBySessionId(sessionId, options = {}) {
@@ -547,8 +609,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
547
609
  const timestamp = now()
548
610
  const normalizedUpserts = upserts.map((entry) => {
549
611
  const input = entry.record ?? entry
550
- const messages = entry.messages !== undefined ? entry.messages : input.messages
551
- const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
612
+ const record = normalizeRecord(entry.messages !== undefined ? { ...input, messages: entry.messages } : input, timestamp)
552
613
  return {
553
614
  record,
554
615
  expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
@@ -566,12 +627,14 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
566
627
  if (keys.has(key)) throw new TypeError(`Duplicate session batch key: ${entry.sessionId}`)
567
628
  keys.add(key)
568
629
  }
569
- return storage.transaction((database) => {
630
+ const result = storage.transaction((database) => {
570
631
  const saved = normalizedUpserts.map(({ record, expectedRevision, expectedStateVersion, messagesMode }) => saveInTransaction(database, record, expectedRevision, expectedStateVersion, { messagesMode }))
571
632
  const deleted = normalizedDeletes.map(({ input, expectedRevision }) => deleteInTransaction(database, input, expectedRevision, timestamp))
572
633
  beforeCommit?.(database)
573
634
  return { saved, deleted }
574
635
  }, { mode: 'immediate' })
636
+ if (result.deleted.some(Boolean)) vacuumAfterDelete(storage)
637
+ return result
575
638
  }
576
639
 
577
640
  function saveMany(inputs, options = {}) {
@@ -585,122 +648,44 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
585
648
  }, options).saved
586
649
  }
587
650
 
588
- function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest, mirrorDeletes = [] } = {}) {
651
+ // Fresh-import semantics inside one immediate transaction: every table is
652
+ // wiped, then each record (inline messages or legacy split-marked bodies —
653
+ // both extracted exactly like save) is inserted with revision 1. The
654
+ // count/digest verification runs before COMMIT so a mismatch rolls the
655
+ // wipe back.
656
+ function replaceAll(inputs, { beforeCommit, expectedCount, expectedDigest } = {}) {
589
657
  if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
590
- if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
591
658
  const timestamp = now()
592
- const records = inputs.map((input) => {
593
- let messages = input.messages
594
- if (messages === undefined && input.state?.messageStorage === 'split') {
595
- messages = Array.isArray(input.state.messages) ? input.state.messages : []
596
- }
597
- return normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
598
- })
659
+ const records = inputs.map((input) => normalizeRecord(input, timestamp))
599
660
  const ids = new Set()
600
661
  for (const record of records) {
601
662
  if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
602
663
  ids.add(record.sessionId)
603
664
  }
604
- const deletes = mirrorDeletes.map((input) => ({ ...bucket(input.scope, input.projectId), sessionId: nonEmptyString(input.sessionId, 'sessionId') }))
605
- const recordKeys = new Set(records.map((record) => JSON.stringify([record.scope, record.projectId, record.sessionId])))
606
- for (const entry of deletes) {
607
- if (recordKeys.has(JSON.stringify([entry.scope, entry.projectId, entry.sessionId]))) {
608
- throw new TypeError(`mirrorDeletes entry duplicates a record key: ${entry.sessionId}`)
609
- }
610
- }
611
- return storage.transaction((database) => {
612
- database.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
665
+ const count = storage.transaction((database) => {
666
+ database.exec('DELETE FROM sessions; DELETE FROM session_messages; DELETE FROM session_tombstones;')
667
+ const insert = database.prepare(UPSERT_SESSION_SQL)
613
668
  for (const record of records) {
614
- database.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
615
- .run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
616
- if (record.messages !== undefined) writeMessages(database, record, 'replace', timestamp)
617
- upsertIndex(database, record)
618
- enqueueMirror(database, record, 'upsert', 1)
619
- }
620
- // Orphan deletes intentionally have no session_states/session_index rows
621
- // (they were dropped as stale residue); enqueue mirror deletes so the
622
- // drain physically clears leftover JSON files (idempotent when absent).
623
- for (const entry of deletes) {
624
- enqueueMirror(database, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
669
+ insert.run(...sessionRowValues(record, {
670
+ revision: 1,
671
+ createdAt: nullableString(record.metadata.createdAt) || timestamp,
672
+ messageCount: record.messages !== undefined ? record.messages.length : 0,
673
+ }))
674
+ if (record.messages !== undefined) writeMessages(database, record, 'replace')
625
675
  }
626
676
  const digest = verificationDigest(digestRows(database))
627
677
  if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
628
678
  if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
629
- updateStorageState(database, storageState ? { ...storageState, stateCount: records.length, digest, updatedAt: timestamp } : null)
630
679
  beforeCommit?.(database)
631
680
  return records.length
632
681
  }, { mode: 'immediate' })
633
- }
634
-
635
- // Streaming equivalent of replaceAll for sources that cannot be materialized
636
- // in memory (large JSON cutover imports). Consumes a sync or async iterable
637
- // record-by-record inside one immediate transaction; only the per-record
638
- // digest lines accumulate. `storage.transaction` requires a synchronous
639
- // callback, so the transaction is managed manually: any error — including
640
- // one thrown by the iterator mid-stream — rolls everything back, preserving
641
- // replaceAll's all-or-nothing semantics. Callers must hold the session
642
- // state maintenance lock (a concurrent writer hitting the open transaction
643
- // fails loudly instead of interleaving).
644
- async function replaceAllStream(recordIterable, { beforeCommit, storageState, expectedCount, expectedDigest, mirrorDeletes = [] } = {}) {
645
- if (!recordIterable || (typeof recordIterable[Symbol.asyncIterator] !== 'function' && typeof recordIterable[Symbol.iterator] !== 'function')) {
646
- throw new TypeError('records must be a sync or async iterable')
647
- }
648
- if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
649
- const timestamp = now()
650
- const deletes = mirrorDeletes.map((input) => ({ ...bucket(input.scope, input.projectId), sessionId: nonEmptyString(input.sessionId, 'sessionId') }))
651
- // Record keys arrive incrementally while streaming, so the mirrorDeletes
652
- // conflict check runs per record against the precomputed delete keys (the
653
- // mirror image of replaceAll's post-hoc set comparison).
654
- const deleteKeys = new Set(deletes.map((entry) => JSON.stringify([entry.scope, entry.projectId, entry.sessionId])))
655
- storage.exec('BEGIN IMMEDIATE')
656
- let count = 0
657
- const digestLines = []
658
- try {
659
- storage.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
660
- const insert = storage.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
661
- const ids = new Set()
662
- for await (const input of recordIterable) {
663
- let messages = input.messages
664
- if (messages === undefined && input.state?.messageStorage === 'split') {
665
- messages = Array.isArray(input.state.messages) ? input.state.messages : []
666
- }
667
- const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
668
- if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
669
- ids.add(record.sessionId)
670
- if (deleteKeys.has(JSON.stringify([record.scope, record.projectId, record.sessionId]))) {
671
- throw new TypeError(`mirrorDeletes entry duplicates a record key: ${record.sessionId}`)
672
- }
673
- insert.run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
674
- if (record.messages !== undefined) writeMessages(storage, record, 'replace', timestamp)
675
- upsertIndex(storage, record)
676
- enqueueMirror(storage, record, 'upsert', 1)
677
- // Streamed imports are non-split (messages inline, none in
678
- // session_messages), so the empty messages digest matches what
679
- // digestRows computes for freshly imported non-split rows.
680
- digestLines.push(snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, ''))
681
- count += 1
682
- }
683
- // Orphan deletes intentionally have no session_states/session_index rows;
684
- // see replaceAll for the mirror-drain rationale.
685
- for (const entry of deletes) {
686
- enqueueMirror(storage, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
687
- }
688
- const digest = digestFromLines(digestLines)
689
- if (expectedCount !== undefined && count !== expectedCount) throw new Error('Session state replace count verification failed')
690
- if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
691
- updateStorageState(storage, storageState ? { ...storageState, stateCount: count, digest, updatedAt: timestamp } : null)
692
- beforeCommit?.(storage)
693
- storage.exec('COMMIT')
694
- return count
695
- } catch (error) {
696
- try { storage.exec('ROLLBACK') } catch { /* transaction already closed */ }
697
- throw error
698
- }
682
+ vacuumAfterDelete(storage)
683
+ return count
699
684
  }
700
685
 
701
686
  function exportSnapshot() {
702
687
  return storage.transaction((database) => {
703
- const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
688
+ const rows = database.prepare(`SELECT ${SESSION_ROW_COLUMNS} FROM sessions ORDER BY scope, project_id, session_id`).all()
704
689
  const messageRows = database.prepare('SELECT scope, project_id, session_id, seq, message_json, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()
705
690
  const groups = new Map()
706
691
  for (const row of messageRows) {
@@ -711,7 +696,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
711
696
  const records = rows.map((row) => {
712
697
  const group = groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []
713
698
  const record = mapRow(row)
714
- if (group.length > 0) record.messages = group.map((messageRow) => JSON.parse(messageRow.message_json))
699
+ // Old-shape snapshot record: state carries the reassembled messages so
700
+ // consumers (backups, replaceAll round-trips) never see a torn body.
701
+ record.state = { ...record.state, messages: group.map((messageRow) => JSON.parse(messageRow.message_json)) }
702
+ record.messages = group.map((messageRow) => JSON.parse(messageRow.message_json))
715
703
  record.messagesDigest = messagesDigest(group)
716
704
  return record
717
705
  })
@@ -720,7 +708,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
720
708
  }
721
709
 
722
710
  function count() {
723
- return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count)
711
+ return Number(storage.prepare('SELECT COUNT(*) AS count FROM sessions').get().count)
724
712
  }
725
713
 
726
714
  function digest() {
@@ -731,70 +719,68 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
731
719
  // bodies are loaded; joins and aggregates run inside SQLite).
732
720
  function sqlIntegrityCounts() {
733
721
  return {
734
- missingIndex: Number(storage.prepare(`
735
- SELECT COUNT(*) AS count FROM session_states s
736
- LEFT JOIN session_index i ON i.scope = s.scope AND i.project_id = s.project_id AND i.session_id = s.session_id
737
- WHERE i.session_id IS NULL OR i.metadata_digest <> s.metadata_digest OR i.state_version <> s.state_version
738
- `).get().count),
739
- orphanIndex: Number(storage.prepare(`
740
- SELECT COUNT(*) AS count FROM session_index i
741
- LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
742
- WHERE s.session_id IS NULL
743
- `).get().count),
744
- duplicateIds: Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count),
745
- activeTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
746
- JOIN session_states s ON s.scope = t.scope AND s.project_id = t.project_id AND s.session_id = t.session_id`).get().count),
747
- staleTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
748
- JOIN session_states s ON s.session_id = t.session_id AND NOT (s.scope = t.scope AND s.project_id = t.project_id)`).get().count),
722
+ duplicateIds: Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM sessions GROUP BY session_id HAVING COUNT(*) > 1)').get().count),
723
+ activeTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_tombstones t
724
+ JOIN sessions s ON s.scope = t.scope AND s.project_id = t.project_id AND s.session_id = t.session_id`).get().count),
725
+ staleTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_tombstones t
726
+ JOIN sessions s ON s.session_id = t.session_id AND NOT (s.scope = t.scope AND s.project_id = t.project_id)`).get().count),
749
727
  orphanMessages: Number(storage.prepare(`
750
728
  SELECT COUNT(*) AS count FROM session_messages m
751
- LEFT JOIN session_states s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
729
+ LEFT JOIN sessions s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
752
730
  WHERE s.session_id IS NULL
753
731
  `).get().count),
732
+ messageCountMismatches: Number(storage.prepare(`
733
+ SELECT COUNT(*) AS count FROM sessions s
734
+ WHERE s.message_count <> (
735
+ SELECT COUNT(*) FROM session_messages m
736
+ WHERE m.scope = s.scope AND m.project_id = s.project_id AND m.session_id = s.session_id
737
+ )
738
+ `).get().count),
754
739
  }
755
740
  }
756
741
 
757
- function verifyIntegrity({ quickCheck = false } = {}) {
742
+ function verifyIntegrity({ quickCheck = false, forceQuickCheck = false } = {}) {
758
743
  const counts = sqlIntegrityCounts()
744
+ const structuralOk = counts.duplicateIds === 0 && counts.activeTombstones === 0 && counts.staleTombstones === 0
745
+ && counts.orphanMessages === 0 && counts.messageCountMismatches === 0
759
746
  if (quickCheck) {
760
- const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
761
- if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
762
- // Lightweight mode: SQL-level checks only. Per-row JSON.parse and digest
763
- // recomputation (invalidRecords/invalidDigests/invalidIndexDigests/
764
- // invalidMessageDigests/invalidMessageRepresentations) and the snapshot
765
- // digest are left to a full verification (maintenance entry points).
747
+ // Shared quick_check gate (process cache + marker cadence) see
748
+ // runSharedSqliteQuickCheck in database.mjs; `forceQuickCheck` is the
749
+ // manual-maintenance escape hatch that always runs a real scan.
750
+ runSharedSqliteQuickCheck(storage, { force: forceQuickCheck === true })
751
+ // Lightweight mode: SQL-level checks only (row-count consistency and
752
+ // the derived message_count projection). Per-row JSON.parse and digest
753
+ // recomputation are left to a full verification (maintenance entry
754
+ // points).
766
755
  return {
767
- ok: counts.missingIndex === 0 && counts.orphanIndex === 0 && counts.duplicateIds === 0
768
- && counts.activeTombstones === 0 && counts.staleTombstones === 0 && counts.orphanMessages === 0,
769
- count: Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count),
756
+ ok: structuralOk,
757
+ count: Number(storage.prepare('SELECT COUNT(*) AS count FROM sessions').get().count),
770
758
  digest: null,
771
759
  ...counts,
772
760
  invalidRecords: 0,
773
- invalidDigests: 0,
774
- invalidIndexDigests: 0,
775
761
  invalidMessageDigests: 0,
776
762
  invalidMessageRepresentations: 0,
777
763
  lightweight: true,
778
764
  }
779
765
  }
780
- const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states`).all()
766
+ const rows = storage.prepare(`SELECT ${SESSION_ROW_COLUMNS} FROM sessions`).all()
781
767
  let invalidRecords = 0
782
- let invalidDigests = 0
783
768
  let invalidMessageRepresentations = 0
784
769
  for (const row of rows) {
785
770
  try {
786
- const stateEncoded = jsonAndDigest(JSON.parse(row.state_json), 'state')
787
- const metadataEncoded = jsonAndDigest(JSON.parse(row.metadata_json), 'metadata')
788
- if (stateEncoded.digest !== row.state_digest || metadataEncoded.digest !== row.metadata_digest) invalidDigests += 1
771
+ const parsedState = JSON.parse(row.body_json)
772
+ JSON.parse(row.meta_json)
773
+ // v2 bodies never carry a messages array (the rows are authoritative).
774
+ if (Array.isArray(parsedState.messages)) invalidMessageRepresentations += 1
775
+ // Canonical round-trip: normalizeRecord must reproduce body_json and
776
+ // meta_json byte-for-byte.
789
777
  const record = normalizeRecord(mapRow(row), row.updated_at)
790
- if (record.stateJson !== row.state_json || record.metadataJson !== row.metadata_json) invalidRecords += 1
791
- const parsedState = JSON.parse(row.state_json)
792
- if (parsedState.messageStorage === 'split' && Array.isArray(parsedState.messages)) invalidMessageRepresentations += 1
778
+ if (record.stateJson !== row.body_json || record.metadataJson !== row.meta_json) invalidRecords += 1
793
779
  } catch {
794
780
  invalidRecords += 1
795
781
  }
796
782
  }
797
- const messageRows = storage.prepare('SELECT * FROM session_messages').all()
783
+ const messageRows = storage.prepare('SELECT message_json, message_digest FROM session_messages').all()
798
784
  let invalidMessageDigests = 0
799
785
  for (const row of messageRows) {
800
786
  try {
@@ -803,80 +789,24 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
803
789
  invalidMessageDigests += 1
804
790
  }
805
791
  }
806
- const indexRows = storage.prepare('SELECT metadata_json, metadata_digest FROM session_index').all()
807
- let invalidIndexDigests = 0
808
- for (const row of indexRows) {
809
- try {
810
- if (jsonAndDigest(JSON.parse(row.metadata_json), 'metadata').digest !== row.metadata_digest) invalidIndexDigests += 1
811
- } catch {
812
- invalidIndexDigests += 1
813
- }
814
- }
815
- const { missingIndex, orphanIndex, duplicateIds, activeTombstones, staleTombstones, orphanMessages } = counts
816
792
  return {
817
- ok: missingIndex === 0 && orphanIndex === 0 && duplicateIds === 0 && activeTombstones === 0 && staleTombstones === 0
818
- && invalidRecords === 0 && invalidDigests === 0 && invalidIndexDigests === 0
819
- && invalidMessageDigests === 0 && orphanMessages === 0 && invalidMessageRepresentations === 0,
793
+ ok: structuralOk && invalidRecords === 0 && invalidMessageDigests === 0 && invalidMessageRepresentations === 0,
820
794
  count: rows.length,
821
795
  digest: verificationDigest(digestRows(storage)),
822
- missingIndex,
823
- orphanIndex,
824
- duplicateIds,
825
- activeTombstones,
826
- staleTombstones,
796
+ ...counts,
827
797
  invalidRecords,
828
- invalidDigests,
829
- invalidIndexDigests,
830
798
  invalidMessageDigests,
831
- orphanMessages,
832
799
  invalidMessageRepresentations,
833
800
  }
834
801
  }
835
802
 
836
- function rebuildIndex() {
837
- return storage.transaction((database) => {
838
- database.exec('DELETE FROM session_index')
839
- const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
840
- for (const row of rows) upsertIndex(database, normalizeRecord(mapRow(row), row.updated_at))
841
- const indexCount = Number(database.prepare('SELECT COUNT(*) AS count FROM session_index').get().count)
842
- if (indexCount !== rows.length) throw new Error('Session index rebuild verification failed')
843
- return rows.length
844
- }, { mode: 'immediate' })
845
- }
846
-
847
- function listMirrorQueue({ limit } = {}) {
848
- if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw new TypeError('limit must be a positive integer')
849
- // The queue rows carry full state_json payloads, so callers draining large
850
- // queues page through with `limit` instead of loading every row at once.
851
- const rows = storage.prepare(`SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id${limit !== undefined ? ' LIMIT ?' : ''}`)
852
- .all(...(limit !== undefined ? [limit] : []))
853
- return rows.map((row) => ({
854
- scope: row.scope,
855
- projectId: row.scope === 'project' ? row.project_id : null,
856
- sessionId: row.session_id,
857
- operation: row.operation,
858
- revision: Number(row.revision),
859
- state: row.state_json ? JSON.parse(row.state_json) : null,
860
- metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null,
861
- attempts: Number(row.attempts),
862
- lastError: row.last_error,
863
- updatedAt: row.updated_at,
864
- }))
865
- }
866
-
867
- function countMirrorQueue() {
868
- return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_json_mirror_queue').get().count)
869
- }
870
-
871
- function acknowledgeMirror(entry) {
872
- return Number(storage.prepare('DELETE FROM session_json_mirror_queue WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?')
873
- .run(entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision).changes) === 1
874
- }
875
-
876
- function failMirror(entry, error) {
877
- storage.prepare(`UPDATE session_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ?
878
- WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?`)
879
- .run(String(error?.message || error).slice(0, 1000), now(), entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision)
803
+ // Truncates the WAL back into the main database file. A bulk import (or
804
+ // repeated failed migration attempts) can leave the WAL far larger than the
805
+ // database itself; TRUNCATE resets it to zero bytes once the checkpoint
806
+ // completes. The returned pragma row carries `busy` (nonzero when another
807
+ // reader held the WAL and some frames were skipped — non-fatal).
808
+ function checkpointWal() {
809
+ return storage.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get()
880
810
  }
881
811
 
882
812
  return Object.freeze({
@@ -887,20 +817,40 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
887
817
  appendMessages,
888
818
  readMessagesPage,
889
819
  messageCount,
820
+ readLastMessage,
821
+ readMessageDigestAt,
890
822
  saveMany,
891
823
  applyBatch,
892
824
  delete: deleteRecord,
893
825
  deleteBySessionId,
894
826
  replaceAll,
895
- replaceAllStream,
896
827
  exportSnapshot,
897
828
  verifyIntegrity,
898
829
  count,
899
830
  digest,
900
- rebuildIndex,
901
- listMirrorQueue,
902
- countMirrorQueue,
903
- acknowledgeMirror,
904
- failMirror,
831
+ checkpointWal,
905
832
  })
906
833
  }
834
+
835
+ // Digest rows for the canonical snapshot digest: one line per session with
836
+ // the body/meta digests recomputed from the stored canonical JSON (they are
837
+ // not persisted as columns in schema v11 — bodies are small by design, so the
838
+ // recompute is cheap even for maintenance-time full scans) and the per-
839
+ // session aggregate message digest from the stored rows.
840
+ function digestRows(database) {
841
+ const rows = database.prepare('SELECT scope, project_id, session_id, body_json, meta_json FROM sessions').all()
842
+ const groups = new Map()
843
+ for (const row of database.prepare('SELECT scope, project_id, session_id, seq, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()) {
844
+ const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
845
+ if (!groups.has(key)) groups.set(key, [])
846
+ groups.get(key).push(row)
847
+ }
848
+ return rows.map((row) => ({
849
+ scope: row.scope,
850
+ project_id: row.project_id,
851
+ session_id: row.session_id,
852
+ state_digest: createHash('sha256').update(row.body_json).digest('hex'),
853
+ metadata_digest: createHash('sha256').update(row.meta_json).digest('hex'),
854
+ messages_digest: messagesDigest(groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []),
855
+ }))
856
+ }