@shawnstack/quickforge 1.7.6 → 1.7.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
  2. package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
  3. package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
  4. package/dist/assets/{PluginsPage-BDKYfLna.js → PluginsPage-mJkTmVVV.js} +1 -1
  5. package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
  6. package/dist/assets/{SettingsWorkspacePage-DSy66Tuw.js → SettingsWorkspacePage-7lQ0Wl63.js} +391 -391
  7. package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
  8. package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
  9. package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
  10. package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
  11. package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
  12. package/dist/assets/index-D-J_8Smf.css +3 -0
  13. package/dist/assets/index-DFASc15l.js +66 -0
  14. package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
  15. package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
  16. package/dist/assets/{mcp-servers-dialog-BEV4_TXC.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
  17. package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
  18. package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
  19. package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
  20. package/dist/assets/{skills-dialog-BTJ7mzk_.js → skills-dialog-DIzZvQqW.js} +3 -3
  21. package/dist/index.html +8 -8
  22. package/package.json +1 -1
  23. package/server/acp/server.mjs +17 -1
  24. package/server/agent-manager.mjs +251 -31
  25. package/server/ai-http-logger.mjs +162 -19
  26. package/server/ai-provider-options.mjs +4 -1
  27. package/server/auto-archive.mjs +42 -29
  28. package/server/auto-compaction.mjs +43 -1
  29. package/server/cloud/auto-approval.mjs +155 -0
  30. package/server/cloud/client.mjs +5 -0
  31. package/server/cloud/config.mjs +8 -16
  32. package/server/cloud/qf-agent-process.mjs +163 -3
  33. package/server/cloud/service-config.mjs +1 -1
  34. package/server/context-usage.mjs +11 -4
  35. package/server/index.mjs +69 -13
  36. package/server/lan-access-backup.mjs +256 -0
  37. package/server/lan-access-cutover.mjs +306 -0
  38. package/server/lan-access-json-file.mjs +75 -0
  39. package/server/lan-access-service.mjs +155 -0
  40. package/server/lan-access-store.mjs +110 -9
  41. package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
  42. package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
  43. package/server/maintenance/downgrade-share-v1.mjs +101 -0
  44. package/server/maintenance/export-lan-access-v1.mjs +80 -0
  45. package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
  46. package/server/maintenance/export-session-state-v1.mjs +67 -0
  47. package/server/maintenance/export-share-v1.mjs +77 -0
  48. package/server/routes/agent.mjs +37 -4
  49. package/server/routes/backup.mjs +221 -39
  50. package/server/routes/cloud.mjs +28 -1
  51. package/server/routes/scheduled-tasks.mjs +347 -134
  52. package/server/routes/shares.mjs +4 -2
  53. package/server/routes/storage.mjs +138 -8
  54. package/server/routes/workspace.mjs +265 -4
  55. package/server/scheduled-runs-backup.mjs +235 -0
  56. package/server/scheduled-runs-cutover.mjs +497 -0
  57. package/server/scheduled-task-runs-service.mjs +279 -0
  58. package/server/session-index-service.mjs +495 -0
  59. package/server/session-state-backup.mjs +277 -0
  60. package/server/session-state-cutover.mjs +370 -0
  61. package/server/session-state-service.mjs +709 -0
  62. package/server/share-backup.mjs +255 -0
  63. package/server/share-cutover.mjs +296 -0
  64. package/server/share-json-file.mjs +60 -0
  65. package/server/share-service.mjs +163 -0
  66. package/server/share-store.mjs +196 -19
  67. package/server/skills.mjs +0 -1
  68. package/server/sqlite/database.mjs +233 -0
  69. package/server/sqlite/lan-access-repository.mjs +652 -0
  70. package/server/sqlite/migrations.mjs +577 -0
  71. package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
  72. package/server/sqlite/session-index-repository.mjs +297 -0
  73. package/server/sqlite/session-state-repository.mjs +786 -0
  74. package/server/sqlite/share-repository.mjs +854 -0
  75. package/server/storage.mjs +317 -1
  76. package/dist/assets/AgentProfilesPage-Dl5Peqov.js +0 -1
  77. package/dist/assets/ChatPanelHost-D3987TUJ.js +0 -307
  78. package/dist/assets/CloudAccountSettingsPage-BG5Nn3bq.js +0 -1
  79. package/dist/assets/ScheduledTasksPage--GAki3lB.js +0 -2
  80. package/dist/assets/ShareLinksSettingsPage-BQQMsvJo.js +0 -1
  81. package/dist/assets/SharedConversationPage-B9BUVCri.js +0 -1
  82. package/dist/assets/TerminalDock-CT1nYo7P.js +0 -2
  83. package/dist/assets/WorkspaceInspector-BnfDLf9a.js +0 -36
  84. package/dist/assets/index-1ei6nhs-.css +0 -3
  85. package/dist/assets/index-BivLuZvX.js +0 -65
  86. package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
  87. /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
@@ -0,0 +1,786 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { getSqliteStorage } from './database.mjs'
3
+
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
7
+ `
8
+
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
+ export const MESSAGES_SPLIT_VALUE = 'split'
12
+ export const MESSAGES_PAGE_LIMIT_MAX = 5000
13
+
14
+ function isPlainObject(value) {
15
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
16
+ }
17
+
18
+ function canonicalize(value) {
19
+ if (Array.isArray(value)) return value.map(canonicalize)
20
+ if (!isPlainObject(value)) return value
21
+ return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
22
+ }
23
+
24
+ function jsonAndDigest(value, field) {
25
+ if (!isPlainObject(value)) throw new TypeError(`${field} must be a plain object`)
26
+ let parsed
27
+ try {
28
+ parsed = JSON.parse(JSON.stringify(value))
29
+ } catch {
30
+ throw new TypeError(`${field} must be JSON-serializable`)
31
+ }
32
+ if (!isPlainObject(parsed)) throw new TypeError(`${field} must be a JSON object`)
33
+ const json = JSON.stringify(canonicalize(parsed))
34
+ return { value: parsed, json, digest: createHash('sha256').update(json).digest('hex') }
35
+ }
36
+
37
+ // Canonical SHA-256 digest of a single message object (identical algorithm to
38
+ // the row-level `message_digest` written by the repository).
39
+ export function messageDigest(message) {
40
+ return jsonAndDigest(message, 'message').digest
41
+ }
42
+
43
+ // Digest over an ordered message array (seq = array index). Mirrors the
44
+ // per-session aggregate computed from stored rows, so assembled plan/backup
45
+ // values hash identically to their stored (split) representation.
46
+ export function messagesDigestFromValues(messages) {
47
+ if (!Array.isArray(messages) || messages.length === 0) return ''
48
+ const rows = messages.map((message, seq) => ({ seq, message_digest: messageDigest(message) }))
49
+ return messagesDigest(rows)
50
+ }
51
+
52
+ // sha256 over `${seq}\0${message_digest}` lines (rows must be ordered by seq).
53
+ function messagesDigest(rows) {
54
+ if (!rows || rows.length === 0) return ''
55
+ return createHash('sha256').update(rows.map((row) => `${row.seq}\0${row.message_digest}`).join('\n')).digest('hex')
56
+ }
57
+
58
+ // Canonical digest line shared by the repository snapshot digest, the JSON
59
+ // cutover snapshot, restore-plan digest and offline tools. A session with no
60
+ // separate message rows contributes an empty `messagesDigest`.
61
+ export function snapshotDigestLine(scope, projectId, sessionId, stateDigest, metadataDigest, messagesDigestValue = '') {
62
+ return `${scope}\0${projectId || ''}\0${sessionId}\0${stateDigest}\0${metadataDigest}\0${messagesDigestValue || ''}`
63
+ }
64
+
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.
69
+ export function splitStateForStorage(state) {
70
+ const copy = structuredClone(state)
71
+ 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 : []
74
+ delete copy.messages
75
+ return { storedState: copy, messages }
76
+ }
77
+ return { storedState: copy, messages: undefined }
78
+ }
79
+
80
+ function encodeMessage(message, timestamp) {
81
+ if (!isPlainObject(message)) throw new TypeError('message must be a plain object')
82
+ let parsed
83
+ try {
84
+ parsed = JSON.parse(JSON.stringify(message))
85
+ } catch {
86
+ throw new TypeError('message must be JSON-serializable')
87
+ }
88
+ if (!isPlainObject(parsed)) throw new TypeError('message must be a JSON object')
89
+ const json = JSON.stringify(canonicalize(parsed))
90
+ const messageId = typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id : null
91
+ return {
92
+ messageId,
93
+ messageJson: json,
94
+ messageDigest: createHash('sha256').update(json).digest('hex'),
95
+ created: timestamp,
96
+ updated: timestamp,
97
+ }
98
+ }
99
+
100
+ function encodeMessages(messages, timestamp) {
101
+ if (!Array.isArray(messages)) throw new TypeError('messages must be an array')
102
+ return messages.map((message) => encodeMessage(message, timestamp))
103
+ }
104
+
105
+ function nonEmptyString(value, field) {
106
+ if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${field} must be a non-empty string`)
107
+ return value
108
+ }
109
+
110
+ function bucket(scope, projectId) {
111
+ if (scope === 'global') return { scope, projectId: '' }
112
+ if (scope !== 'project') throw new TypeError('scope must be global or project')
113
+ return { scope, projectId: nonEmptyString(projectId, 'projectId') }
114
+ }
115
+
116
+ function normalizeRevision(value, { optional = false } = {}) {
117
+ if (optional && (value === undefined || value === null)) return null
118
+ if (!Number.isInteger(value) || value < 0) throw new TypeError('expectedRevision must be a non-negative integer or null')
119
+ return value
120
+ }
121
+
122
+ function normalizeExpectedStateVersion(value) {
123
+ if (value === undefined || value === null) return null
124
+ if (!Number.isInteger(value) || value < 0) throw new TypeError('expectedStateVersion must be a non-negative integer or null')
125
+ return value
126
+ }
127
+
128
+ function normalizeRecord(input, timestamp) {
129
+ if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('session state record must be an object')
130
+ const state = structuredClone(input.state)
131
+ const metadata = structuredClone(input.metadata)
132
+ if (!isPlainObject(state) || !isPlainObject(metadata)) throw new TypeError('state and metadata must be plain objects')
133
+ const sessionId = nonEmptyString(input.sessionId ?? state.id ?? metadata.id, 'sessionId')
134
+ const normalizedBucket = bucket(input.scope ?? state.scope ?? metadata.scope, input.projectId ?? state.projectId ?? metadata.projectId)
135
+ const stateVersion = input.stateVersion ?? state.stateVersion ?? metadata.stateVersion ?? 0
136
+ if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError('stateVersion must be a non-negative integer')
137
+
138
+ for (const [value, field] of [[state, 'state'], [metadata, 'metadata']]) {
139
+ if (value.id !== undefined && value.id !== sessionId) throw new TypeError(`${field}.id must match sessionId`)
140
+ if (value.scope !== undefined && value.scope !== normalizedBucket.scope) throw new TypeError(`${field}.scope must match scope`)
141
+ if (normalizedBucket.scope === 'project') {
142
+ if (value.projectId !== undefined && value.projectId !== normalizedBucket.projectId) throw new TypeError(`${field}.projectId must match projectId`)
143
+ } else if (value.projectId !== undefined && value.projectId !== null) {
144
+ throw new TypeError(`${field}.projectId is not allowed for global sessions`)
145
+ }
146
+ if (value.stateVersion !== undefined && value.stateVersion !== stateVersion) throw new TypeError(`${field}.stateVersion must match stateVersion`)
147
+ value.id = sessionId
148
+ value.scope = normalizedBucket.scope
149
+ value.stateVersion = stateVersion
150
+ if (normalizedBucket.scope === 'project') value.projectId = normalizedBucket.projectId
151
+ else delete value.projectId
152
+ }
153
+
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
158
+ if (messages !== undefined) {
159
+ delete state.messages
160
+ state.messageStorage = MESSAGES_SPLIT_VALUE
161
+ }
162
+
163
+ const stateEncoded = jsonAndDigest(state, 'state')
164
+ const metadataEncoded = jsonAndDigest(metadata, 'metadata')
165
+ return {
166
+ ...normalizedBucket,
167
+ sessionId,
168
+ stateVersion,
169
+ state: stateEncoded.value,
170
+ metadata: metadataEncoded.value,
171
+ stateJson: stateEncoded.json,
172
+ stateDigest: stateEncoded.digest,
173
+ metadataJson: metadataEncoded.json,
174
+ metadataDigest: metadataEncoded.digest,
175
+ messages,
176
+ now: timestamp,
177
+ }
178
+ }
179
+
180
+ function conflict(sessionId, expectedRevision, actualRevision) {
181
+ const error = new Error(`Session state conflict for ${sessionId}`)
182
+ error.statusCode = 409
183
+ error.errorCode = 'SESSION_STATE_CONFLICT'
184
+ error.expectedRevision = expectedRevision
185
+ error.actualRevision = actualRevision
186
+ return error
187
+ }
188
+
189
+ function stateVersionConflict(sessionId, expectedStateVersion, actualStateVersion) {
190
+ const error = new Error(`Session state version conflict for ${sessionId}`)
191
+ error.statusCode = 409
192
+ error.errorCode = 'SESSION_STATE_CONFLICT'
193
+ error.expectedStateVersion = expectedStateVersion
194
+ error.actualStateVersion = actualStateVersion
195
+ return error
196
+ }
197
+
198
+ function duplicate(sessionId) {
199
+ const error = new Error(`Duplicate authoritative session id: ${sessionId}`)
200
+ error.statusCode = 409
201
+ error.errorCode = 'SESSION_STATE_DUPLICATE_ID'
202
+ return error
203
+ }
204
+
205
+ function mapRow(row) {
206
+ if (!row) return null
207
+ return {
208
+ scope: row.scope,
209
+ projectId: row.scope === 'project' ? row.project_id : null,
210
+ sessionId: row.session_id,
211
+ revision: Number(row.revision),
212
+ 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,
217
+ createdAt: row.created_at,
218
+ updatedAt: row.updated_at,
219
+ }
220
+ }
221
+
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 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')
300
+ }
301
+
302
+ export function sessionStateSnapshotDigest(records) {
303
+ return verificationDigest(records.map((record) => ({
304
+ scope: record.scope,
305
+ project_id: record.projectId || '',
306
+ session_id: record.sessionId,
307
+ state_digest: record.stateDigest,
308
+ metadata_digest: record.metadataDigest,
309
+ messages_digest: typeof record.messagesDigest === 'string'
310
+ ? record.messagesDigest
311
+ : (Array.isArray(record.messages) ? messagesDigestFromValues(record.messages) : ''),
312
+ })))
313
+ }
314
+
315
+ 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 = ?')
317
+ .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 = ?')
320
+ .get(record.scope, record.projectId, record.sessionId)
321
+ return { revision: Number(tombstone?.revision || 0), stateVersion: null, createdAt: null, active: false }
322
+ }
323
+
324
+ function assertNoCrossBucketDuplicate(database, record) {
325
+ const other = database.prepare(`SELECT scope, project_id FROM session_states
326
+ WHERE session_id = ? AND NOT (scope = ? AND project_id = ?) LIMIT 1`)
327
+ .get(record.sessionId, record.scope, record.projectId)
328
+ if (other) throw duplicate(record.sessionId)
329
+ }
330
+
331
+ function writeMessages(database, record, mode, timestamp) {
332
+ if (record.messages === undefined) return
333
+ if (mode !== 'replace' && mode !== 'append') throw new TypeError('messagesMode must be replace or append')
334
+ const params = [record.scope, record.projectId, record.sessionId]
335
+ 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
+ `)
341
+ record.messages.forEach((message, seq) => {
342
+ insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest, timestamp, timestamp)
343
+ })
344
+ return
345
+ }
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
+ `)
352
+ let seq = maxSeq + 1
353
+ for (const message of record.messages) {
354
+ 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)
356
+ if (message.messageId !== null) existingIds.add(message.messageId)
357
+ seq += 1
358
+ }
359
+ }
360
+
361
+ function saveInTransaction(database, record, expectedRevision, expectedStateVersion = null, { messagesMode } = {}) {
362
+ assertNoCrossBucketDuplicate(database, record)
363
+ const current = actualRevision(database, record)
364
+ if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
365
+ if (expectedStateVersion !== null && current.stateVersion !== expectedStateVersion) throw stateVersionConflict(record.sessionId, expectedStateVersion, current.stateVersion)
366
+ const revision = current.revision + 1
367
+ database.prepare('DELETE FROM session_state_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
368
+ .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 }
388
+ }
389
+
390
+ function deleteInTransaction(database, input, expectedRevision, timestamp) {
391
+ const normalizedBucket = bucket(input.scope, input.projectId)
392
+ const record = { ...normalizedBucket, sessionId: nonEmptyString(input.sessionId, 'sessionId') }
393
+ const current = actualRevision(database, record)
394
+ if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
395
+ 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 = ?')
400
+ .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)
408
+ return true
409
+ }
410
+
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
+ )
422
+ }
423
+
424
+ export function createSessionStateRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
425
+ const storage = storageHandle ?? getSqliteStorage()
426
+ if (!storage || typeof storage.prepare !== 'function' || typeof storage.transaction !== 'function') {
427
+ throw new TypeError('Session state repository requires a SQLite storage handle')
428
+ }
429
+
430
+ function get(scope, projectId, sessionId) {
431
+ const normalizedBucket = bucket(scope, projectId)
432
+ nonEmptyString(sessionId, 'sessionId')
433
+ return mapRow(storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?`)
434
+ .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId))
435
+ }
436
+
437
+ function findBySessionId(sessionId) {
438
+ nonEmptyString(sessionId, 'sessionId')
439
+ const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE session_id = ? ORDER BY scope, project_id`).all(sessionId)
440
+ if (rows.length > 1) throw duplicate(sessionId)
441
+ return mapRow(rows[0])
442
+ }
443
+
444
+ function save(input, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
445
+ const expected = normalizeRevision(expectedRevision, { optional: true })
446
+ const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
447
+ const record = normalizeRecord(input, now())
448
+ return storage.transaction((database) => {
449
+ const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: record.messages !== undefined ? 'replace' : undefined })
450
+ beforeCommit?.(database)
451
+ return result
452
+ }, { mode: 'immediate' })
453
+ }
454
+
455
+ function replaceMessages(input, messages, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
456
+ const expected = normalizeRevision(expectedRevision, { optional: true })
457
+ const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
458
+ const record = normalizeRecord({ ...input, messages }, now())
459
+ return storage.transaction((database) => {
460
+ const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'replace' })
461
+ beforeCommit?.(database)
462
+ return result
463
+ }, { mode: 'immediate' })
464
+ }
465
+
466
+ function appendMessages(input, messages, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
467
+ const expected = normalizeRevision(expectedRevision, { optional: true })
468
+ const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
469
+ const record = normalizeRecord({ ...input, messages }, now())
470
+ 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
+ const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'append' })
479
+ beforeCommit?.(database)
480
+ return result
481
+ }, { mode: 'immediate' })
482
+ }
483
+
484
+ function readMessagesPage({ scope, projectId, sessionId, limit = 100, offset = 0, afterSeq = null } = {}) {
485
+ const normalizedBucket = bucket(scope, projectId)
486
+ nonEmptyString(sessionId, 'sessionId')
487
+ if (!Number.isInteger(limit) || limit < 1 || limit > MESSAGES_PAGE_LIMIT_MAX) throw new TypeError('limit must be an integer between 1 and 5000')
488
+ if (!Number.isInteger(offset) || offset < 0) throw new TypeError('offset must be a non-negative integer')
489
+ if (afterSeq !== null && (!Number.isInteger(afterSeq) || afterSeq < 0)) throw new TypeError('afterSeq must be a non-negative integer or null')
490
+ const where = ['scope = ?', 'project_id = ?', 'session_id = ?']
491
+ const params = [normalizedBucket.scope, normalizedBucket.projectId, sessionId]
492
+ let limitParams = [limit]
493
+ if (afterSeq !== null) {
494
+ where.push('seq > ?')
495
+ params.push(afterSeq)
496
+ } else if (offset > 0) {
497
+ limitParams = [limit, offset]
498
+ }
499
+ const rows = storage.prepare(`SELECT seq, message_id, message_json, message_digest FROM session_messages
500
+ WHERE ${where.join(' AND ')} ORDER BY seq ASC LIMIT ?${offset > 0 && afterSeq === null ? ' OFFSET ?' : ''}`)
501
+ .all(...params, ...limitParams)
502
+ const total = Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
503
+ .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
504
+ const lastSeq = rows.length > 0 ? Number(rows[rows.length - 1].seq) : null
505
+ return {
506
+ messages: rows.map((row) => ({ seq: Number(row.seq), message: JSON.parse(row.message_json), digest: row.message_digest })),
507
+ total,
508
+ limit,
509
+ offset,
510
+ hasMore: afterSeq !== null ? rows.length === limit : offset + rows.length < total,
511
+ nextOffset: offset + rows.length,
512
+ lastSeq,
513
+ }
514
+ }
515
+
516
+ function messageCount({ scope, projectId, sessionId }) {
517
+ const normalizedBucket = bucket(scope, projectId)
518
+ nonEmptyString(sessionId, 'sessionId')
519
+ return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
520
+ .get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
521
+ }
522
+
523
+ function deleteRecord({ scope, projectId, sessionId, expectedRevision = null, beforeCommit } = {}) {
524
+ const expected = normalizeRevision(expectedRevision, { optional: true })
525
+ const timestamp = now()
526
+ return storage.transaction((database) => {
527
+ const result = deleteInTransaction(database, { scope, projectId, sessionId }, expected, timestamp)
528
+ beforeCommit?.(database)
529
+ return result
530
+ }, { mode: 'immediate' })
531
+ }
532
+
533
+ function deleteBySessionId(sessionId, options = {}) {
534
+ const existing = findBySessionId(sessionId)
535
+ if (!existing) {
536
+ if (options.expectedRevision !== undefined && options.expectedRevision !== null) throw conflict(sessionId, options.expectedRevision, 0)
537
+ return false
538
+ }
539
+ return deleteRecord({ ...existing, ...options })
540
+ }
541
+
542
+ function applyBatch({ upserts = [], deletes = [] } = {}, { beforeCommit } = {}) {
543
+ if (!Array.isArray(upserts) || !Array.isArray(deletes) || upserts.length + deletes.length === 0) throw new TypeError('Session batch changes are required')
544
+ const timestamp = now()
545
+ const normalizedUpserts = upserts.map((entry) => {
546
+ 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)
549
+ return {
550
+ record,
551
+ expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
552
+ expectedStateVersion: normalizeExpectedStateVersion(entry.expectedStateVersion),
553
+ messagesMode: entry.messagesMode ?? (record.messages !== undefined ? 'replace' : undefined),
554
+ }
555
+ })
556
+ const normalizedDeletes = deletes.map((entry) => ({
557
+ input: entry,
558
+ expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
559
+ }))
560
+ const keys = new Set()
561
+ for (const entry of [...normalizedUpserts.map(({ record }) => record), ...normalizedDeletes.map(({ input }) => ({ ...bucket(input.scope, input.projectId), sessionId: input.sessionId }))]) {
562
+ const key = JSON.stringify([entry.scope, entry.projectId || '', entry.sessionId])
563
+ if (keys.has(key)) throw new TypeError(`Duplicate session batch key: ${entry.sessionId}`)
564
+ keys.add(key)
565
+ }
566
+ return storage.transaction((database) => {
567
+ const saved = normalizedUpserts.map(({ record, expectedRevision, expectedStateVersion, messagesMode }) => saveInTransaction(database, record, expectedRevision, expectedStateVersion, { messagesMode }))
568
+ const deleted = normalizedDeletes.map(({ input, expectedRevision }) => deleteInTransaction(database, input, expectedRevision, timestamp))
569
+ beforeCommit?.(database)
570
+ return { saved, deleted }
571
+ }, { mode: 'immediate' })
572
+ }
573
+
574
+ function saveMany(inputs, options = {}) {
575
+ if (!Array.isArray(inputs) || inputs.length === 0) throw new TypeError('records must be a non-empty array')
576
+ return applyBatch({
577
+ upserts: inputs.map((input) => ({
578
+ record: input.record ?? input,
579
+ expectedRevision: input.expectedRevision,
580
+ expectedStateVersion: input.expectedStateVersion,
581
+ })),
582
+ }, options).saved
583
+ }
584
+
585
+ function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest } = {}) {
586
+ if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
587
+ 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
+ })
595
+ const ids = new Set()
596
+ for (const record of records) {
597
+ if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
598
+ ids.add(record.sessionId)
599
+ }
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;')
602
+ 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)
608
+ }
609
+ const digest = verificationDigest(digestRows(database))
610
+ if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
611
+ 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
+ beforeCommit?.(database)
614
+ return records.length
615
+ }, { mode: 'immediate' })
616
+ }
617
+
618
+ function exportSnapshot() {
619
+ return storage.transaction((database) => {
620
+ const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
621
+ 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
+ const groups = new Map()
623
+ for (const row of messageRows) {
624
+ const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
625
+ if (!groups.has(key)) groups.set(key, [])
626
+ groups.get(key).push(row)
627
+ }
628
+ const records = rows.map((row) => {
629
+ const group = groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []
630
+ const record = mapRow(row)
631
+ if (group.length > 0) record.messages = group.map((messageRow) => JSON.parse(messageRow.message_json))
632
+ record.messagesDigest = messagesDigest(group)
633
+ return record
634
+ })
635
+ return { records, count: records.length, digest: verificationDigest(digestRows(database)) }
636
+ }, { mode: 'deferred' })
637
+ }
638
+
639
+ function count() {
640
+ return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count)
641
+ }
642
+
643
+ function digest() {
644
+ return verificationDigest(digestRows(storage))
645
+ }
646
+
647
+ function verifyIntegrity({ quickCheck = false } = {}) {
648
+ 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(', ')}`)
651
+ }
652
+ const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states`).all()
653
+ let invalidRecords = 0
654
+ let invalidDigests = 0
655
+ let invalidMessageRepresentations = 0
656
+ for (const row of rows) {
657
+ 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
661
+ 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
665
+ } catch {
666
+ invalidRecords += 1
667
+ }
668
+ }
669
+ const messageRows = storage.prepare('SELECT * FROM session_messages').all()
670
+ let invalidMessageDigests = 0
671
+ for (const row of messageRows) {
672
+ try {
673
+ if (jsonAndDigest(JSON.parse(row.message_json), 'message').digest !== row.message_digest) invalidMessageDigests += 1
674
+ } catch {
675
+ invalidMessageDigests += 1
676
+ }
677
+ }
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
+ 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,
711
+ count: rows.length,
712
+ digest: verificationDigest(digestRows(storage)),
713
+ missingIndex,
714
+ orphanIndex,
715
+ duplicateIds,
716
+ activeTombstones,
717
+ staleTombstones,
718
+ invalidRecords,
719
+ invalidDigests,
720
+ invalidIndexDigests,
721
+ invalidMessageDigests,
722
+ orphanMessages,
723
+ invalidMessageRepresentations,
724
+ }
725
+ }
726
+
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)
762
+ }
763
+
764
+ return Object.freeze({
765
+ get,
766
+ findBySessionId,
767
+ save,
768
+ replaceMessages,
769
+ appendMessages,
770
+ readMessagesPage,
771
+ messageCount,
772
+ saveMany,
773
+ applyBatch,
774
+ delete: deleteRecord,
775
+ deleteBySessionId,
776
+ replaceAll,
777
+ exportSnapshot,
778
+ verifyIntegrity,
779
+ count,
780
+ digest,
781
+ rebuildIndex,
782
+ listMirrorQueue,
783
+ acknowledgeMirror,
784
+ failMirror,
785
+ })
786
+ }