@shawnstack/quickforge 1.7.9 → 1.7.11

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 (53) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
  2. package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
  9. package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
  11. package/dist/assets/index-BgMvQW1b.js +66 -0
  12. package/dist/assets/index-CB1g2D0f.css +3 -0
  13. package/dist/assets/local-tools-Dopjg9xq.js +270 -0
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/acp/server.mjs +3 -6
  19. package/server/agent-manager.mjs +125 -43
  20. package/server/auto-archive.mjs +55 -8
  21. package/server/auto-compaction.mjs +0 -2
  22. package/server/context-usage.mjs +9 -7
  23. package/server/index.mjs +172 -51
  24. package/server/lan-access-cutover.mjs +21 -4
  25. package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
  26. package/server/maintenance/export-session-state-v1.mjs +5 -13
  27. package/server/routes/backup.mjs +8 -1
  28. package/server/routes/storage.mjs +85 -48
  29. package/server/scheduled-runs-cutover.mjs +15 -1
  30. package/server/session-index-service.mjs +57 -391
  31. package/server/session-persistence-lock.mjs +17 -6
  32. package/server/session-state-backup.mjs +60 -7
  33. package/server/session-state-import.mjs +157 -0
  34. package/server/session-state-maintenance.mjs +126 -0
  35. package/server/session-state-service.mjs +133 -194
  36. package/server/share-cutover.mjs +21 -4
  37. package/server/sqlite/database.mjs +142 -7
  38. package/server/sqlite/lan-access-repository.mjs +6 -4
  39. package/server/sqlite/migrations.mjs +109 -0
  40. package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
  41. package/server/sqlite/session-index-repository.mjs +59 -208
  42. package/server/sqlite/session-state-repository.mjs +359 -289
  43. package/server/sqlite/share-repository.mjs +56 -45
  44. package/server/startup-state.mjs +107 -0
  45. package/server/storage.mjs +240 -448
  46. package/server/utils/logger.mjs +15 -4
  47. package/server/utils/process-tree.mjs +14 -2
  48. package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
  49. package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
  50. package/dist/assets/index-D-J_8Smf.css +0 -3
  51. package/dist/assets/index-DFASc15l.js +0 -66
  52. package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
  53. package/server/session-state-cutover.mjs +0 -370
@@ -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,93 +248,25 @@ 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
- }))
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) {
265
+ return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
295
266
  }
296
267
 
297
268
  function verificationDigest(rows) {
298
- const values = rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)).sort()
299
- return createHash('sha256').update(values.join('\n')).digest('hex')
269
+ return digestFromLines(rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)))
300
270
  }
301
271
 
302
272
  export function sessionStateSnapshotDigest(records) {
@@ -313,46 +283,128 @@ export function sessionStateSnapshotDigest(records) {
313
283
  }
314
284
 
315
285
  function actualRevision(database, record) {
316
- 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 = ?')
317
287
  .get(record.scope, record.projectId, record.sessionId)
318
- if (active) return { revision: Number(active.revision), stateVersion: Number(active.state_version), createdAt: active.created_at, active: true }
319
- 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 = ?')
320
295
  .get(record.scope, record.projectId, record.sessionId)
321
- 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 }
322
297
  }
323
298
 
324
299
  function assertNoCrossBucketDuplicate(database, record) {
325
- const other = database.prepare(`SELECT scope, project_id FROM session_states
300
+ const other = cachedStatement(database, `SELECT scope, project_id FROM sessions
326
301
  WHERE session_id = ? AND NOT (scope = ? AND project_id = ?) LIMIT 1`)
327
302
  .get(record.sessionId, record.scope, record.projectId)
328
303
  if (other) throw duplicate(record.sessionId)
329
304
  }
330
305
 
331
- 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) {
332
367
  if (record.messages === undefined) return
333
368
  if (mode !== 'replace' && mode !== 'append') throw new TypeError('messagesMode must be replace or append')
334
369
  const params = [record.scope, record.projectId, record.sessionId]
335
370
  if (mode === 'replace') {
336
- database.prepare('DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').run(...params)
337
- const insert = database.prepare(`
338
- INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
339
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
340
- `)
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)
341
373
  record.messages.forEach((message, seq) => {
342
- 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)
343
375
  })
344
376
  return
345
377
  }
346
- 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)
347
- 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))
348
- const insert = database.prepare(`
349
- INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
350
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
351
- `)
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)
352
404
  let seq = maxSeq + 1
353
405
  for (const message of record.messages) {
354
406
  if (message.messageId !== null && existingIds.has(message.messageId)) continue
355
- 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)
356
408
  if (message.messageId !== null) existingIds.add(message.messageId)
357
409
  seq += 1
358
410
  }
@@ -364,27 +416,26 @@ function saveInTransaction(database, record, expectedRevision, expectedStateVers
364
416
  if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
365
417
  if (expectedStateVersion !== null && current.stateVersion !== expectedStateVersion) throw stateVersionConflict(record.sessionId, expectedStateVersion, current.stateVersion)
366
418
  const revision = current.revision + 1
367
- 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 = ?')
368
423
  .run(record.scope, record.projectId, record.sessionId)
369
- if (record.messages !== undefined) writeMessages(database, record, messagesMode ?? 'replace', record.now)
370
- database.prepare(`
371
- INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
372
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
373
- revision = excluded.revision,
374
- state_version = excluded.state_version,
375
- state_json = excluded.state_json,
376
- state_digest = excluded.state_digest,
377
- metadata_json = excluded.metadata_json,
378
- metadata_digest = excluded.metadata_digest,
379
- updated_at = excluded.updated_at
380
- `).run(
381
- record.scope, record.projectId, record.sessionId, revision, record.stateVersion,
382
- record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest,
383
- current.createdAt || record.now, record.now,
384
- )
385
- upsertIndex(database, record)
386
- enqueueMirror(database, record, 'upsert', revision)
387
- 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 }
388
439
  }
389
440
 
390
441
  function deleteInTransaction(database, input, expectedRevision, timestamp) {
@@ -393,32 +444,28 @@ function deleteInTransaction(database, input, expectedRevision, timestamp) {
393
444
  const current = actualRevision(database, record)
394
445
  if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
395
446
  if (!current.active) return false
396
- const deletionRevision = current.revision + 1
397
- database.prepare('DELETE FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
398
- .run(record.scope, record.projectId, record.sessionId)
399
- 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 = ?')
400
449
  .run(record.scope, record.projectId, record.sessionId)
401
- database.prepare('DELETE FROM session_index WHERE scope = ? AND project_id = ? AND session_id = ?')
402
- .run(record.scope, record.projectId, record.sessionId)
403
- database.prepare(`INSERT INTO session_state_tombstones (scope, project_id, session_id, revision, deleted_at)
404
- VALUES (?, ?, ?, ?, ?)
405
- ON CONFLICT(scope, project_id, session_id) DO UPDATE SET revision = excluded.revision, deleted_at = excluded.deleted_at`)
406
- .run(record.scope, record.projectId, record.sessionId, deletionRevision, timestamp)
407
- 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))
408
454
  return true
409
455
  }
410
456
 
411
- function updateStorageState(database, values) {
412
- if (!values) return
413
- database.prepare(`UPDATE session_storage_state SET phase = ?, state_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
414
- WHERE singleton = 1`).run(
415
- values.phase,
416
- values.stateCount ?? null,
417
- values.digest ?? null,
418
- values.backupFile ?? null,
419
- values.diagnostic ? JSON.stringify(values.diagnostic) : null,
420
- values.updatedAt,
421
- )
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
+ }
422
469
  }
423
470
 
424
471
  export function createSessionStateRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
@@ -430,13 +477,13 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
430
477
  function get(scope, projectId, sessionId) {
431
478
  const normalizedBucket = bucket(scope, projectId)
432
479
  nonEmptyString(sessionId, 'sessionId')
433
- 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 = ?`)
434
481
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId))
435
482
  }
436
483
 
437
484
  function findBySessionId(sessionId) {
438
485
  nonEmptyString(sessionId, 'sessionId')
439
- 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)
440
487
  if (rows.length > 1) throw duplicate(sessionId)
441
488
  return mapRow(rows[0])
442
489
  }
@@ -468,13 +515,6 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
468
515
  const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
469
516
  const record = normalizeRecord({ ...input, messages }, now())
470
517
  return storage.transaction((database) => {
471
- const current = actualRevision(database, record)
472
- if (current.active) {
473
- const stored = database.prepare('SELECT state_json FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
474
- .get(record.scope, record.projectId, record.sessionId)
475
- const parsed = JSON.parse(stored.state_json)
476
- if (Array.isArray(parsed.messages)) throw new TypeError('Session is not split; use replaceMessages to split it first')
477
- }
478
518
  const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'append' })
479
519
  beforeCommit?.(database)
480
520
  return result
@@ -496,10 +536,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
496
536
  } else if (offset > 0) {
497
537
  limitParams = [limit, offset]
498
538
  }
499
- 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
500
540
  WHERE ${where.join(' AND ')} ORDER BY seq ASC LIMIT ?${offset > 0 && afterSeq === null ? ' OFFSET ?' : ''}`)
501
541
  .all(...params, ...limitParams)
502
- 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)
503
543
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
504
544
  const lastSeq = rows.length > 0 ? Number(rows[rows.length - 1].seq) : null
505
545
  return {
@@ -516,18 +556,43 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
516
556
  function messageCount({ scope, projectId, sessionId }) {
517
557
  const normalizedBucket = bucket(scope, projectId)
518
558
  nonEmptyString(sessionId, 'sessionId')
519
- 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)
520
560
  .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
521
561
  }
522
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
+
523
586
  function deleteRecord({ scope, projectId, sessionId, expectedRevision = null, beforeCommit } = {}) {
524
587
  const expected = normalizeRevision(expectedRevision, { optional: true })
525
588
  const timestamp = now()
526
- return storage.transaction((database) => {
589
+ const deleted = storage.transaction((database) => {
527
590
  const result = deleteInTransaction(database, { scope, projectId, sessionId }, expected, timestamp)
528
591
  beforeCommit?.(database)
529
592
  return result
530
593
  }, { mode: 'immediate' })
594
+ if (deleted) vacuumAfterDelete(storage)
595
+ return deleted
531
596
  }
532
597
 
533
598
  function deleteBySessionId(sessionId, options = {}) {
@@ -544,8 +609,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
544
609
  const timestamp = now()
545
610
  const normalizedUpserts = upserts.map((entry) => {
546
611
  const input = entry.record ?? entry
547
- const messages = entry.messages !== undefined ? entry.messages : input.messages
548
- const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
612
+ const record = normalizeRecord(entry.messages !== undefined ? { ...input, messages: entry.messages } : input, timestamp)
549
613
  return {
550
614
  record,
551
615
  expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
@@ -563,12 +627,14 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
563
627
  if (keys.has(key)) throw new TypeError(`Duplicate session batch key: ${entry.sessionId}`)
564
628
  keys.add(key)
565
629
  }
566
- return storage.transaction((database) => {
630
+ const result = storage.transaction((database) => {
567
631
  const saved = normalizedUpserts.map(({ record, expectedRevision, expectedStateVersion, messagesMode }) => saveInTransaction(database, record, expectedRevision, expectedStateVersion, { messagesMode }))
568
632
  const deleted = normalizedDeletes.map(({ input, expectedRevision }) => deleteInTransaction(database, input, expectedRevision, timestamp))
569
633
  beforeCommit?.(database)
570
634
  return { saved, deleted }
571
635
  }, { mode: 'immediate' })
636
+ if (result.deleted.some(Boolean)) vacuumAfterDelete(storage)
637
+ return result
572
638
  }
573
639
 
574
640
  function saveMany(inputs, options = {}) {
@@ -582,42 +648,44 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
582
648
  }, options).saved
583
649
  }
584
650
 
585
- function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest } = {}) {
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 } = {}) {
586
657
  if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
587
658
  const timestamp = now()
588
- const records = inputs.map((input) => {
589
- let messages = input.messages
590
- if (messages === undefined && input.state?.messageStorage === 'split') {
591
- messages = Array.isArray(input.state.messages) ? input.state.messages : []
592
- }
593
- return normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
594
- })
659
+ const records = inputs.map((input) => normalizeRecord(input, timestamp))
595
660
  const ids = new Set()
596
661
  for (const record of records) {
597
662
  if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
598
663
  ids.add(record.sessionId)
599
664
  }
600
- return storage.transaction((database) => {
601
- 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)
602
668
  for (const record of records) {
603
- database.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
604
- .run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
605
- if (record.messages !== undefined) writeMessages(database, record, 'replace', timestamp)
606
- upsertIndex(database, record)
607
- enqueueMirror(database, record, 'upsert', 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')
608
675
  }
609
676
  const digest = verificationDigest(digestRows(database))
610
677
  if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
611
678
  if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
612
- updateStorageState(database, storageState ? { ...storageState, stateCount: records.length, digest, updatedAt: timestamp } : null)
613
679
  beforeCommit?.(database)
614
680
  return records.length
615
681
  }, { mode: 'immediate' })
682
+ vacuumAfterDelete(storage)
683
+ return count
616
684
  }
617
685
 
618
686
  function exportSnapshot() {
619
687
  return storage.transaction((database) => {
620
- 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()
621
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()
622
690
  const groups = new Map()
623
691
  for (const row of messageRows) {
@@ -628,7 +696,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
628
696
  const records = rows.map((row) => {
629
697
  const group = groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []
630
698
  const record = mapRow(row)
631
- 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))
632
703
  record.messagesDigest = messagesDigest(group)
633
704
  return record
634
705
  })
@@ -637,36 +708,79 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
637
708
  }
638
709
 
639
710
  function count() {
640
- 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)
641
712
  }
642
713
 
643
714
  function digest() {
644
715
  return verificationDigest(digestRows(storage))
645
716
  }
646
717
 
647
- function verifyIntegrity({ quickCheck = false } = {}) {
718
+ // SQL-only integrity counts shared by both verification modes (no row
719
+ // bodies are loaded; joins and aggregates run inside SQLite).
720
+ function sqlIntegrityCounts() {
721
+ return {
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),
727
+ orphanMessages: Number(storage.prepare(`
728
+ SELECT COUNT(*) AS count FROM session_messages m
729
+ LEFT JOIN sessions s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
730
+ WHERE s.session_id IS NULL
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),
739
+ }
740
+ }
741
+
742
+ function verifyIntegrity({ quickCheck = false, forceQuickCheck = false } = {}) {
743
+ const counts = sqlIntegrityCounts()
744
+ const structuralOk = counts.duplicateIds === 0 && counts.activeTombstones === 0 && counts.staleTombstones === 0
745
+ && counts.orphanMessages === 0 && counts.messageCountMismatches === 0
648
746
  if (quickCheck) {
649
- const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
650
- if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
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).
755
+ return {
756
+ ok: structuralOk,
757
+ count: Number(storage.prepare('SELECT COUNT(*) AS count FROM sessions').get().count),
758
+ digest: null,
759
+ ...counts,
760
+ invalidRecords: 0,
761
+ invalidMessageDigests: 0,
762
+ invalidMessageRepresentations: 0,
763
+ lightweight: true,
764
+ }
651
765
  }
652
- const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states`).all()
766
+ const rows = storage.prepare(`SELECT ${SESSION_ROW_COLUMNS} FROM sessions`).all()
653
767
  let invalidRecords = 0
654
- let invalidDigests = 0
655
768
  let invalidMessageRepresentations = 0
656
769
  for (const row of rows) {
657
770
  try {
658
- const stateEncoded = jsonAndDigest(JSON.parse(row.state_json), 'state')
659
- const metadataEncoded = jsonAndDigest(JSON.parse(row.metadata_json), 'metadata')
660
- 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.
661
777
  const record = normalizeRecord(mapRow(row), row.updated_at)
662
- if (record.stateJson !== row.state_json || record.metadataJson !== row.metadata_json) invalidRecords += 1
663
- const parsedState = JSON.parse(row.state_json)
664
- if (parsedState.messageStorage === 'split' && Array.isArray(parsedState.messages)) invalidMessageRepresentations += 1
778
+ if (record.stateJson !== row.body_json || record.metadataJson !== row.meta_json) invalidRecords += 1
665
779
  } catch {
666
780
  invalidRecords += 1
667
781
  }
668
782
  }
669
- const messageRows = storage.prepare('SELECT * FROM session_messages').all()
783
+ const messageRows = storage.prepare('SELECT message_json, message_digest FROM session_messages').all()
670
784
  let invalidMessageDigests = 0
671
785
  for (const row of messageRows) {
672
786
  try {
@@ -675,90 +789,24 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
675
789
  invalidMessageDigests += 1
676
790
  }
677
791
  }
678
- const orphanMessages = Number(storage.prepare(`
679
- SELECT COUNT(*) AS count FROM session_messages m
680
- LEFT JOIN session_states s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
681
- WHERE s.session_id IS NULL
682
- `).get().count)
683
- const indexRows = storage.prepare('SELECT metadata_json, metadata_digest FROM session_index').all()
684
- let invalidIndexDigests = 0
685
- for (const row of indexRows) {
686
- try {
687
- if (jsonAndDigest(JSON.parse(row.metadata_json), 'metadata').digest !== row.metadata_digest) invalidIndexDigests += 1
688
- } catch {
689
- invalidIndexDigests += 1
690
- }
691
- }
692
- const missingIndex = Number(storage.prepare(`
693
- SELECT COUNT(*) AS count FROM session_states s
694
- LEFT JOIN session_index i ON i.scope = s.scope AND i.project_id = s.project_id AND i.session_id = s.session_id
695
- WHERE i.session_id IS NULL OR i.metadata_digest <> s.metadata_digest OR i.state_version <> s.state_version
696
- `).get().count)
697
- const orphanIndex = Number(storage.prepare(`
698
- SELECT COUNT(*) AS count FROM session_index i
699
- LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
700
- WHERE s.session_id IS NULL
701
- `).get().count)
702
- const duplicateIds = Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count)
703
- const activeTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
704
- 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)
705
- const staleTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
706
- 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)
707
792
  return {
708
- ok: missingIndex === 0 && orphanIndex === 0 && duplicateIds === 0 && activeTombstones === 0 && staleTombstones === 0
709
- && invalidRecords === 0 && invalidDigests === 0 && invalidIndexDigests === 0
710
- && invalidMessageDigests === 0 && orphanMessages === 0 && invalidMessageRepresentations === 0,
793
+ ok: structuralOk && invalidRecords === 0 && invalidMessageDigests === 0 && invalidMessageRepresentations === 0,
711
794
  count: rows.length,
712
795
  digest: verificationDigest(digestRows(storage)),
713
- missingIndex,
714
- orphanIndex,
715
- duplicateIds,
716
- activeTombstones,
717
- staleTombstones,
796
+ ...counts,
718
797
  invalidRecords,
719
- invalidDigests,
720
- invalidIndexDigests,
721
798
  invalidMessageDigests,
722
- orphanMessages,
723
799
  invalidMessageRepresentations,
724
800
  }
725
801
  }
726
802
 
727
- function rebuildIndex() {
728
- return storage.transaction((database) => {
729
- database.exec('DELETE FROM session_index')
730
- const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
731
- for (const row of rows) upsertIndex(database, normalizeRecord(mapRow(row), row.updated_at))
732
- const indexCount = Number(database.prepare('SELECT COUNT(*) AS count FROM session_index').get().count)
733
- if (indexCount !== rows.length) throw new Error('Session index rebuild verification failed')
734
- return rows.length
735
- }, { mode: 'immediate' })
736
- }
737
-
738
- function listMirrorQueue() {
739
- return storage.prepare('SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id').all().map((row) => ({
740
- scope: row.scope,
741
- projectId: row.scope === 'project' ? row.project_id : null,
742
- sessionId: row.session_id,
743
- operation: row.operation,
744
- revision: Number(row.revision),
745
- state: row.state_json ? JSON.parse(row.state_json) : null,
746
- metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null,
747
- attempts: Number(row.attempts),
748
- lastError: row.last_error,
749
- updatedAt: row.updated_at,
750
- }))
751
- }
752
-
753
- function acknowledgeMirror(entry) {
754
- return Number(storage.prepare('DELETE FROM session_json_mirror_queue WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?')
755
- .run(entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision).changes) === 1
756
- }
757
-
758
- function failMirror(entry, error) {
759
- storage.prepare(`UPDATE session_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ?
760
- WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?`)
761
- .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()
762
810
  }
763
811
 
764
812
  return Object.freeze({
@@ -769,6 +817,8 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
769
817
  appendMessages,
770
818
  readMessagesPage,
771
819
  messageCount,
820
+ readLastMessage,
821
+ readMessageDigestAt,
772
822
  saveMany,
773
823
  applyBatch,
774
824
  delete: deleteRecord,
@@ -778,9 +828,29 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
778
828
  verifyIntegrity,
779
829
  count,
780
830
  digest,
781
- rebuildIndex,
782
- listMirrorQueue,
783
- acknowledgeMirror,
784
- failMirror,
831
+ checkpointWal,
785
832
  })
786
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
+ }