@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.
- package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Cvb1KTz5.js} +1 -1
- package/dist/assets/ChatPanelHost-C7zV-xE_.js +48 -0
- package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-mAs_nbvw.js} +1 -1
- package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-mLvwvTmL.js} +1 -1
- package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-CuUW_9lq.js} +1 -1
- package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-0OINfEqK.js} +390 -390
- package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-DqeyJYoD.js} +1 -1
- package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-C7CDEoX9.js} +1 -1
- package/dist/assets/TerminalDock-CZXcuMpq.js +2 -0
- package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-CNSBOEvP.js} +3 -3
- package/dist/assets/index-BgMvQW1b.js +66 -0
- package/dist/assets/index-CB1g2D0f.css +3 -0
- package/dist/assets/local-tools-Dopjg9xq.js +270 -0
- package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-B2lALtTE.js} +1 -1
- package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-DYp35JM3.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +125 -43
- package/server/auto-archive.mjs +55 -8
- package/server/auto-compaction.mjs +0 -2
- package/server/context-usage.mjs +9 -7
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +50 -87
- package/server/maintenance/export-session-state-v1.mjs +5 -13
- package/server/routes/backup.mjs +8 -1
- package/server/routes/storage.mjs +85 -48
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-persistence-lock.mjs +17 -6
- package/server/session-state-backup.mjs +60 -7
- package/server/session-state-import.mjs +157 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +133 -194
- package/server/share-cutover.mjs +21 -4
- package/server/sqlite/database.mjs +142 -7
- package/server/sqlite/lan-access-repository.mjs +6 -4
- package/server/sqlite/migrations.mjs +109 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +6 -3
- package/server/sqlite/session-index-repository.mjs +59 -208
- package/server/sqlite/session-state-repository.mjs +359 -289
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +240 -448
- package/server/utils/logger.mjs +15 -4
- package/server/utils/process-tree.mjs +14 -2
- package/dist/assets/ChatPanelHost-cd80S4EX.js +0 -48
- package/dist/assets/TerminalDock-CczcCJJE.js +0 -2
- package/dist/assets/index-D-J_8Smf.css +0 -3
- package/dist/assets/index-DFASc15l.js +0 -66
- package/dist/assets/local-tools-Dn1Y9aPe.js +0 -270
- package/server/session-state-cutover.mjs +0 -370
|
@@ -1,16 +1,39 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import {
|
|
2
|
+
import { logger } from '../utils/logger.mjs'
|
|
3
|
+
import { getSqliteStorage, runSharedSqliteQuickCheck } from './database.mjs'
|
|
3
4
|
|
|
4
|
-
const
|
|
5
|
-
scope, project_id, session_id, revision, state_version,
|
|
6
|
-
|
|
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
|
-
//
|
|
10
|
-
// `session_messages
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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.
|
|
73
|
-
const 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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
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.
|
|
214
|
-
stateDigest: row.
|
|
215
|
-
metadata: JSON.parse(row.
|
|
216
|
-
metadataDigest: row.
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
function
|
|
227
|
-
return
|
|
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
|
-
|
|
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
|
|
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)
|
|
319
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
337
|
-
const insert = database
|
|
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
|
|
374
|
+
insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest)
|
|
343
375
|
})
|
|
344
376
|
return
|
|
345
377
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
record.scope, record.projectId, record.sessionId
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
397
|
-
database
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
601
|
-
database.exec('DELETE FROM
|
|
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
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
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 ${
|
|
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
|
|
659
|
-
|
|
660
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
|
|
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
|
+
}
|