@shawnstack/quickforge 1.7.9 → 1.7.10

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 (33) hide show
  1. package/dist/assets/{AgentProfilesPage-BSM5w3bn.js → AgentProfilesPage-Dw9orPNt.js} +1 -1
  2. package/dist/assets/{ChatPanelHost-cd80S4EX.js → ChatPanelHost-CW3-MQyL.js} +2 -2
  3. package/dist/assets/{CloudAccountSettingsPage-BJMrfgy9.js → CloudAccountSettingsPage-CDkKN1Sz.js} +1 -1
  4. package/dist/assets/{PluginsPage-mJkTmVVV.js → PluginsPage-Dj1bmsEf.js} +1 -1
  5. package/dist/assets/{ScheduledTasksPage-DJj_NLm9.js → ScheduledTasksPage-C9lOTi75.js} +1 -1
  6. package/dist/assets/{SettingsWorkspacePage-7lQ0Wl63.js → SettingsWorkspacePage-CFuvSATi.js} +3 -3
  7. package/dist/assets/{ShareLinksSettingsPage-DjFq_Lnb.js → ShareLinksSettingsPage-CH_t33A3.js} +1 -1
  8. package/dist/assets/{SharedConversationPage-BbuNpcpP.js → SharedConversationPage-DLo05j7c.js} +1 -1
  9. package/dist/assets/{TerminalDock-CczcCJJE.js → TerminalDock-CSOVub-m.js} +1 -1
  10. package/dist/assets/{WorkspaceInspector-0i6x-diB.js → WorkspaceInspector-DPXgLIum.js} +1 -1
  11. package/dist/assets/index-DQBOm9gt.js +66 -0
  12. package/dist/assets/index-ps85guTs.css +3 -0
  13. package/dist/assets/{local-tools-Dn1Y9aPe.js → local-tools-BxfQeIUQ.js} +1 -1
  14. package/dist/assets/{mcp-servers-dialog-BG4b67ea.js → mcp-servers-dialog-CwHp0K4q.js} +1 -1
  15. package/dist/assets/{skills-dialog-DIzZvQqW.js → skills-dialog-k9ycekIU.js} +1 -1
  16. package/dist/index.html +4 -4
  17. package/package.json +1 -1
  18. package/server/agent-manager.mjs +68 -2
  19. package/server/auto-compaction.mjs +0 -2
  20. package/server/context-usage.mjs +9 -7
  21. package/server/maintenance/downgrade-session-state-v1.mjs +4 -2
  22. package/server/maintenance/export-session-state-v1.mjs +4 -2
  23. package/server/routes/backup.mjs +7 -0
  24. package/server/routes/storage.mjs +23 -0
  25. package/server/session-persistence-lock.mjs +17 -6
  26. package/server/session-state-backup.mjs +4 -2
  27. package/server/session-state-cutover.mjs +284 -91
  28. package/server/session-state-service.mjs +38 -17
  29. package/server/sqlite/session-state-repository.mjs +145 -25
  30. package/server/storage.mjs +28 -0
  31. package/server/utils/process-tree.mjs +14 -2
  32. package/dist/assets/index-D-J_8Smf.css +0 -3
  33. package/dist/assets/index-DFASc15l.js +0 -66
@@ -1,6 +1,6 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks'
2
2
  import { createHash, randomUUID } from 'node:crypto'
3
- import { promises as fs } from 'node:fs'
3
+ import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import { getSqliteStorage } from './sqlite/database.mjs'
6
6
  import { createSessionStateRepository, snapshotDigestLine } from './sqlite/session-state-repository.mjs'
@@ -12,8 +12,8 @@ import {
12
12
  setSessionStoragePhase,
13
13
  } from './session-state-service.mjs'
14
14
  import {
15
+ createPhysicalSessionStateFsAdapter,
15
16
  materializeSessionJsonMirrorEntry,
16
- readPhysicalSessionStateBuckets,
17
17
  storageDir,
18
18
  } from './storage.mjs'
19
19
 
@@ -68,10 +68,57 @@ function deriveMetadata(sessionId, state, bucket) {
68
68
  }
69
69
  }
70
70
 
71
+ // Per-session normalization shared verbatim by the materialized snapshot
72
+ // (buildSessionJsonSnapshot) and the streaming cutover source below —
73
+ // validation, shaping and diagnostics MUST stay identical between the two
74
+ // paths; the snapshot tests are the parity anchor. Returns null for
75
+ // metadata-only orphans (diagnostics already recorded; orphans are excluded
76
+ // from records, backups and the SQLite import).
77
+ function normalizeSessionEntry(bucket, sessionId, rawState, rawMetadata, diagnostics) {
78
+ if (!rawState && rawMetadata) {
79
+ diagnostics.metadataOnly.push(sessionId)
80
+ diagnostics.orphanDeletes.push({ scope: bucket.scope, projectId: bucket.projectId, sessionId })
81
+ return null
82
+ }
83
+ if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
84
+ if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
85
+ if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
86
+ if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
87
+ if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
88
+ if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
89
+ let metadata
90
+ if (rawMetadata === undefined) {
91
+ diagnostics.bodyOnly.push(sessionId)
92
+ metadata = deriveMetadata(sessionId, rawState, bucket)
93
+ } else {
94
+ if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
95
+ if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
96
+ if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
97
+ if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
98
+ if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
99
+ metadata = structuredClone(rawMetadata)
100
+ }
101
+ const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
102
+ if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
103
+ const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
104
+ metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
105
+ if (bucket.scope === 'project') {
106
+ state.projectId = bucket.projectId
107
+ metadata.projectId = bucket.projectId
108
+ } else {
109
+ delete state.projectId
110
+ delete metadata.projectId
111
+ }
112
+ for (const field of ['pinnedAt', 'archivedAt']) {
113
+ if (metadata[field] !== undefined) state[field] = metadata[field]
114
+ }
115
+ return { ...bucket, sessionId, stateVersion, state, metadata, stateDigest: digestJson(state), metadataDigest: digestJson(metadata) }
116
+ }
117
+
71
118
  export function buildSessionJsonSnapshot(buckets) {
72
119
  if (!Array.isArray(buckets)) throw new TypeError('Session buckets must be an array')
73
120
  const records = []
74
- const diagnostics = { bodyOnly: [], metadataOnly: [], duplicateSessionIds: [] }
121
+ const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
75
122
  const seen = new Set()
76
123
  for (const rawBucket of buckets) {
77
124
  const bucket = normalizeBucket(rawBucket)
@@ -80,49 +127,19 @@ export function buildSessionJsonSnapshot(buckets) {
80
127
  for (const sessionId of ids) {
81
128
  if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
82
129
  seen.add(sessionId)
83
- const rawState = rawBucket.sessions[sessionId]
84
- const rawMetadata = rawBucket.metadata[sessionId]
85
- if (!rawState && rawMetadata) {
86
- diagnostics.metadataOnly.push(sessionId)
87
- continue
88
- }
89
- if (!isPlainObject(rawState)) throw new TypeError(`Invalid session state: ${sessionId}`)
90
- if (!Array.isArray(rawState.messages)) throw new TypeError(`Session messages must be an array: ${sessionId}`)
91
- if (rawState.id !== undefined && rawState.id !== sessionId) throw new TypeError(`Session body id mismatch: ${sessionId}`)
92
- if (rawState.scope !== undefined && rawState.scope !== bucket.scope) throw new TypeError(`Session body scope mismatch: ${sessionId}`)
93
- if (bucket.scope === 'project' && rawState.projectId !== undefined && rawState.projectId !== bucket.projectId) throw new TypeError(`Session body project mismatch: ${sessionId}`)
94
- if (bucket.scope === 'global' && rawState.projectId !== undefined && rawState.projectId !== null) throw new TypeError(`Global session body project mismatch: ${sessionId}`)
95
- let metadata
96
- if (rawMetadata === undefined) {
97
- diagnostics.bodyOnly.push(sessionId)
98
- metadata = deriveMetadata(sessionId, rawState, bucket)
99
- } else {
100
- if (!isPlainObject(rawMetadata)) throw new TypeError(`Invalid session metadata: ${sessionId}`)
101
- if (rawMetadata.id !== undefined && rawMetadata.id !== sessionId) throw new TypeError(`Session metadata id mismatch: ${sessionId}`)
102
- if (rawMetadata.scope !== undefined && rawMetadata.scope !== bucket.scope) throw new TypeError(`Session metadata scope mismatch: ${sessionId}`)
103
- if (bucket.scope === 'project' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== bucket.projectId) throw new TypeError(`Session metadata project mismatch: ${sessionId}`)
104
- if (bucket.scope === 'global' && rawMetadata.projectId !== undefined && rawMetadata.projectId !== null) throw new TypeError(`Global session metadata project mismatch: ${sessionId}`)
105
- metadata = structuredClone(rawMetadata)
106
- }
107
- const stateVersion = rawState.stateVersion ?? metadata.stateVersion ?? 0
108
- if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError(`Invalid session stateVersion: ${sessionId}`)
109
- const state = { ...structuredClone(rawState), id: sessionId, scope: bucket.scope, stateVersion }
110
- metadata = { ...metadata, id: sessionId, scope: bucket.scope, stateVersion }
111
- if (bucket.scope === 'project') {
112
- state.projectId = bucket.projectId
113
- metadata.projectId = bucket.projectId
114
- } else {
115
- delete state.projectId
116
- delete metadata.projectId
117
- }
118
- for (const field of ['pinnedAt', 'archivedAt']) {
119
- if (metadata[field] !== undefined) state[field] = metadata[field]
120
- }
121
- records.push({ ...bucket, sessionId, stateVersion, state, metadata, stateDigest: digestJson(state), metadataDigest: digestJson(metadata) })
130
+ const record = normalizeSessionEntry(bucket, sessionId, rawBucket.sessions[sessionId], rawBucket.metadata[sessionId], diagnostics)
131
+ if (record) records.push(record)
122
132
  }
123
133
  }
124
134
  if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
125
- if (diagnostics.metadataOnly.length > 0) throw new TypeError(`Metadata-only session orphans: ${diagnostics.metadataOnly.join(', ')}`)
135
+ // Startup cutover holds the maintenance lock with no concurrent writes, so
136
+ // metadata-only orphans are stale residue of already-deleted sessions: they
137
+ // are dropped above (excluded from records) and recorded in diagnostics for
138
+ // audit instead of blocking the migration. Orphans ride the mirror delete
139
+ // queue (`orphanDeletes` passed to replaceAll as `mirrorDeletes`) so the
140
+ // drain physically clears their leftover JSON metadata; otherwise
141
+ // initializeSessionIndex could re-import that residue on a later boot and
142
+ // the orphaned index rows would fail the next startup integrity check.
126
143
  records.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
127
144
  // F9 v7: the canonical snapshot digest line now includes a `messagesDigest`
128
145
  // slot. JSON imports are always non-split (messages inline in the body), so
@@ -132,48 +149,176 @@ export function buildSessionJsonSnapshot(buckets) {
132
149
  return { records, count: records.length, digest, diagnostics }
133
150
  }
134
151
 
135
- async function writeCutoverBackup(snapshot, directory = path.join(storageDir, 'backups')) {
152
+ // Streaming cutover source: each pass consumes the fsAdapter lazily — one
153
+ // bucket listing, one metadata bucket parse and one session file parse at a
154
+ // time — so a multi-GB library never needs to be fully resident. Only the
155
+ // small per-pass summary (digest lines + diagnostics) survives a full
156
+ // iteration. Records are normalized by the shared normalizeSessionEntry, and
157
+ // the summary digest is computed from digest lines sorted by sessionId, which
158
+ // matches buildSessionJsonSnapshot's snapshot digest exactly. Bucket-local
159
+ // iteration order is sorted session ids; duplicate/orphan detection is
160
+ // order-independent. Returns a factory: each call produces one independent
161
+ // { iterate, getSummary } pass over the source.
162
+ export function createStreamingSessionSource(fsAdapter) {
163
+ return () => {
164
+ const digestEntries = []
165
+ const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
166
+ const seen = new Set()
167
+ let count = 0
168
+ let completed = false
169
+ const iterate = async function* streamSessionRecords() {
170
+ for await (const rawBucket of fsAdapter.listBuckets()) {
171
+ const bucket = normalizeBucket(rawBucket)
172
+ const metadata = await fsAdapter.readMetadataBucket(rawBucket)
173
+ if (!isPlainObject(metadata)) throw new TypeError('Session bucket stores must be objects')
174
+ const fileIds = new Set()
175
+ for await (const sessionId of fsAdapter.listSessionFiles(rawBucket)) fileIds.add(sessionId)
176
+ const ids = [...new Set([...fileIds, ...Object.keys(metadata)])].sort((left, right) => left.localeCompare(right))
177
+ for (const sessionId of ids) {
178
+ if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
179
+ seen.add(sessionId)
180
+ const rawState = fileIds.has(sessionId) ? await fsAdapter.readSessionState(rawBucket, sessionId) : undefined
181
+ const record = normalizeSessionEntry(bucket, sessionId, rawState, metadata[sessionId], diagnostics)
182
+ if (!record) continue
183
+ count += 1
184
+ digestEntries.push({ sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
185
+ yield record
186
+ }
187
+ }
188
+ completed = true
189
+ }
190
+ return {
191
+ iterate,
192
+ getSummary() {
193
+ if (!completed) throw new Error('Session source summary is not ready before the iteration completes')
194
+ if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].join(', ')}`)
195
+ digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
196
+ const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
197
+ return { count, digest, diagnostics }
198
+ },
199
+ }
200
+ }
201
+ }
202
+
203
+ // readBuckets keeps the historical injection path for tests and small data
204
+ // sets: each pass calls readBuckets() exactly once, materializes one full
205
+ // snapshot (buildSessionJsonSnapshot) and replays its records. The summary is
206
+ // structurally identical to the streaming source, so the cutover flow below
207
+ // is agnostic to which source factory it was given.
208
+ function createReadBucketsSessionSource(readBuckets) {
209
+ return () => {
210
+ let snapshot = null
211
+ const iterate = async function* snapshotSessionRecords() {
212
+ snapshot = buildSessionJsonSnapshot(await readBuckets())
213
+ yield* snapshot.records
214
+ }
215
+ return {
216
+ iterate,
217
+ getSummary() {
218
+ if (!snapshot) throw new Error('Session source summary is not ready before the iteration completes')
219
+ return { count: snapshot.count, digest: snapshot.digest, diagnostics: snapshot.diagnostics }
220
+ },
221
+ }
222
+ }
223
+ }
224
+
225
+ async function summarizeSessionSource(source) {
226
+ let consumed = 0
227
+ for await (const _record of source.iterate()) consumed += 1
228
+ const summary = source.getSummary()
229
+ if (summary.count !== consumed) throw new Error('Session source summary count mismatch')
230
+ return summary
231
+ }
232
+
233
+ function sameSessionSourceSummary(left, right) {
234
+ return left.count === right.count && left.digest === right.digest
235
+ && JSON.stringify(left.diagnostics) === JSON.stringify(right.diagnostics)
236
+ }
237
+
238
+ // Streaming v1 backup writer: the output JSON shape matches the old
239
+ // materialized writer exactly ({app, version, exportedAt, scope,
240
+ // includeSecrets, sessionState, data:{sessions, sessionsMetadata}}), but
241
+ // states are streamed record-by-record so the library never lives in memory
242
+ // as a whole. The sessions section is written while iterating; metadata JSON
243
+ // strings (small per-session summaries) are buffered for the second section —
244
+ // states themselves are never buffered. Verification is byte-level: the
245
+ // write-side sha256/byte counter is re-checked against a chunked
246
+ // createReadStream re-read plus a first/last byte sanity check, replacing the
247
+ // old "read whole file + re-parse + re-snapshot" verification. The streamed
248
+ // records' digest is also re-accumulated and compared against `summary`, so a
249
+ // source that changed since the double read fails closed here.
250
+ async function writeCutoverBackupStream(createIteration, summary, options = {}) {
251
+ const directory = options.directory || path.join(storageDir, 'backups')
136
252
  await fs.mkdir(directory, { recursive: true })
137
253
  const stamp = new Date().toISOString().replace(/[:.]/g, '-')
138
254
  const finalPath = path.join(directory, `quickforge-session-state-cutover-${stamp}.json`)
139
255
  const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
140
- const backup = {
141
- app: 'quickforge',
142
- version: 1,
143
- exportedAt: new Date().toISOString(),
144
- scope: 'sessions',
145
- includeSecrets: false,
146
- sessionState: { count: snapshot.count, digest: snapshot.digest },
147
- data: {
148
- sessions: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.state])),
149
- sessionsMetadata: Object.fromEntries(snapshot.records.map((record) => [record.sessionId, record.metadata])),
150
- },
151
- }
256
+ const openWriteStream = options.createWriteStream || createWriteStream
152
257
  try {
153
- await fs.writeFile(temporaryPath, `${JSON.stringify(backup, null, 2)}\n`, 'utf8')
154
- const verified = JSON.parse(await fs.readFile(temporaryPath, 'utf8'))
155
- const grouped = new Map()
156
- for (const [sessionId, state] of Object.entries(verified.data.sessions)) {
157
- const scope = state?.scope === 'project' ? 'project' : 'global'
158
- const projectId = scope === 'project' ? state?.projectId : null
159
- const key = scope === 'project' ? `project:${projectId}` : 'global'
160
- if (!grouped.has(key)) grouped.set(key, { scope, projectId, sessions: {}, metadata: {} })
161
- grouped.get(key).sessions[sessionId] = state
162
- if (Object.prototype.hasOwnProperty.call(verified.data.sessionsMetadata, sessionId)) {
163
- grouped.get(key).metadata[sessionId] = verified.data.sessionsMetadata[sessionId]
258
+ const stream = openWriteStream(temporaryPath, { encoding: 'utf8' })
259
+ const failures = []
260
+ stream.on('error', (error) => { failures.push(error) })
261
+ const written = createHash('sha256')
262
+ let bytes = 0
263
+ const write = async (chunk) => {
264
+ written.update(chunk)
265
+ bytes += Buffer.byteLength(chunk, 'utf8')
266
+ if (stream.write(chunk, 'utf8')) {
267
+ if (failures.length > 0) throw failures[0]
268
+ return
164
269
  }
270
+ await new Promise((resolve, reject) => {
271
+ const onDrain = () => { cleanup(); resolve() }
272
+ const onError = (error) => { cleanup(); reject(error) }
273
+ const cleanup = () => { stream.off('drain', onDrain); stream.off('error', onError) }
274
+ stream.once('drain', onDrain)
275
+ stream.once('error', onError)
276
+ })
165
277
  }
166
- for (const [sessionId, metadata] of Object.entries(verified.data.sessionsMetadata)) {
167
- if (!Object.prototype.hasOwnProperty.call(verified.data.sessions, sessionId)) {
168
- const scope = metadata?.scope === 'project' ? 'project' : 'global'
169
- const projectId = scope === 'project' ? metadata?.projectId : null
170
- const key = scope === 'project' ? `project:${projectId}` : 'global'
171
- if (!grouped.has(key)) grouped.set(key, { scope, projectId, sessions: {}, metadata: {} })
172
- grouped.get(key).metadata[sessionId] = metadata
173
- }
278
+ await write(`{\n "app": "quickforge",\n "version": 1,\n "exportedAt": ${JSON.stringify(new Date().toISOString())},\n "scope": "sessions",\n "includeSecrets": false,\n "sessionState": {\n "count": ${summary.count},\n "digest": ${JSON.stringify(summary.digest)}\n },\n "data": {\n "sessions": {`)
279
+ const digestEntries = []
280
+ let count = 0
281
+ let firstSession = true
282
+ const metadataChunks = []
283
+ for await (const record of createIteration()) {
284
+ digestEntries.push({ sessionId: record.sessionId, line: snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '') })
285
+ count += 1
286
+ const prefix = firstSession ? '' : ','
287
+ await write(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.state)}`)
288
+ metadataChunks.push(`${prefix}\n ${JSON.stringify(record.sessionId)}: ${JSON.stringify(record.metadata)}`)
289
+ firstSession = false
290
+ }
291
+ await write(`\n },\n "sessionsMetadata": {`)
292
+ for (const metadataChunk of metadataChunks) {
293
+ await write(metadataChunk)
174
294
  }
175
- const verifiedSnapshot = buildSessionJsonSnapshot([...grouped.values()])
176
- if (verified.sessionState?.count !== snapshot.count || verified.sessionState?.digest !== snapshot.digest || verifiedSnapshot.count !== snapshot.count || verifiedSnapshot.digest !== snapshot.digest) {
295
+ await write(`\n }\n }\n}`)
296
+ await new Promise((resolve, reject) => {
297
+ const onError = (error) => { cleanup(); reject(error) }
298
+ const cleanup = () => { stream.off('error', onError) }
299
+ stream.once('error', onError)
300
+ stream.end(() => { cleanup(); resolve() })
301
+ })
302
+ if (count !== summary.count) throw new Error('Session JSON source changed before cutover commit')
303
+ digestEntries.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
304
+ const digest = createHash('sha256').update(digestEntries.map((entry) => entry.line).join('\n')).digest('hex')
305
+ if (digest !== summary.digest) throw new Error('Session JSON source changed before cutover commit')
306
+ const reread = createHash('sha256')
307
+ let rereadBytes = 0
308
+ let firstByte = -1
309
+ let lastByte = -1
310
+ await new Promise((resolve, reject) => {
311
+ const reader = createReadStream(temporaryPath)
312
+ reader.on('data', (chunk) => {
313
+ if (firstByte === -1) firstByte = chunk[0]
314
+ lastByte = chunk[chunk.length - 1]
315
+ reread.update(chunk)
316
+ rereadBytes += chunk.length
317
+ })
318
+ reader.once('error', reject)
319
+ reader.once('end', () => resolve())
320
+ })
321
+ if (reread.digest('hex') !== written.digest('hex') || rereadBytes !== bytes || firstByte !== 0x7b || lastByte !== 0x7d) {
177
322
  throw new Error('Session cutover backup verification failed')
178
323
  }
179
324
  await fs.rename(temporaryPath, finalPath)
@@ -298,6 +443,29 @@ function mirrorAdapter() {
298
443
  return { upsert: materialize, delete: materialize }
299
444
  }
300
445
 
446
+ function integrityFailureSummary(integrity) {
447
+ const summary = Object.entries(integrity)
448
+ .filter(([key, value]) => !['ok', 'count', 'digest'].includes(key) && Number(value) > 0)
449
+ .map(([key, value]) => `${key}=${value}`)
450
+ .join(', ')
451
+ return summary || 'unknown'
452
+ }
453
+
454
+ // session_index is a pure projection of session_states, but
455
+ // initializeSessionIndex rebuilds it from the JSON source, which can drift
456
+ // from authoritative rows (e.g. orphan metadata residue re-imported after a
457
+ // failed mirror drain). Rebuilding the projection from the authoritative
458
+ // states is lossless self-healing: on integrity failure, rebuild the index
459
+ // and re-verify, and only fail closed if the re-check still fails.
460
+ function verifyIntegrityWithIndexSelfHeal(repository, phase) {
461
+ const first = repository.verifyIntegrity({ quickCheck: true })
462
+ if (first.ok) return first
463
+ repository.rebuildIndex()
464
+ const second = repository.verifyIntegrity({ quickCheck: true })
465
+ if (!second.ok) throw new Error(`Session state ${phase} integrity verification failed (${integrityFailureSummary(first)})`)
466
+ return second
467
+ }
468
+
301
469
  export async function initializeSessionStateCutover(options = {}) {
302
470
  const storage = options.storage || getSqliteStorage()
303
471
  const repository = options.repository || createSessionStateRepository(storage)
@@ -305,15 +473,16 @@ export async function initializeSessionStateCutover(options = {}) {
305
473
  return runSessionStateMaintenance(async () => {
306
474
  const current = readSessionStorageState()
307
475
  if (current.phase === SESSION_STORAGE_PHASES.JSON_PENDING) {
308
- const integrity = repository.verifyIntegrity({ quickCheck: true })
309
- if (!integrity.ok) throw new Error('Session state pending integrity verification failed')
476
+ const integrity = verifyIntegrityWithIndexSelfHeal(repository, 'pending')
310
477
  const drained = await drainSessionJsonMirror()
311
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: integrity.digest, backupFile: current.backupFile })
478
+ // integrity is the lightweight (SQL-level) check its digest is null.
479
+ // The verified digest persisted by replaceAll's storageState stays
480
+ // authoritative when promoting to authoritative.
481
+ if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: current.digest, backupFile: current.backupFile })
312
482
  return readSessionStorageState()
313
483
  }
314
484
  if (current.phase === SESSION_STORAGE_PHASES.AUTHORITATIVE) {
315
- const integrity = repository.verifyIntegrity({ quickCheck: true })
316
- if (!integrity.ok) throw new Error('Session state authoritative integrity verification failed')
485
+ verifyIntegrityWithIndexSelfHeal(repository, 'authoritative')
317
486
  await drainSessionJsonMirror()
318
487
  return readSessionStorageState()
319
488
  }
@@ -327,12 +496,30 @@ export async function initializeSessionStateCutover(options = {}) {
327
496
 
328
497
  let backupFile = current.backupFile
329
498
  try {
330
- const first = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
331
- const second = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
332
- if (first.count !== second.count || first.digest !== second.digest) throw new Error('Session JSON source changed during cutover double read')
333
- if (!backupFile) backupFile = await writeCutoverBackup(first, options.backupDirectory)
334
- const third = buildSessionJsonSnapshot(await (options.readBuckets || readPhysicalSessionStateBuckets)())
335
- if (first.count !== third.count || first.digest !== third.digest) throw new Error('Session JSON source changed before cutover commit')
499
+ // Streaming cutover (step 2): the json_authoritative migration path runs
500
+ // FOUR independent full iterations of the JSON source — (1) summary,
501
+ // (2) summary re-read, (3) streaming backup write (or summary-only when
502
+ // a backup already exists) and (4) replaceAllStream import. Only the
503
+ // small summaries (digest lines + diagnostics) survive between passes,
504
+ // so peak memory is bounded by the largest single session instead of
505
+ // the whole library. options.readBuckets keeps the materialized
506
+ // injection path for tests; the production default streams the physical
507
+ // layout one file at a time via the fsAdapter.
508
+ const createSource = options.readBuckets
509
+ ? createReadBucketsSessionSource(options.readBuckets)
510
+ : createStreamingSessionSource(options.fsAdapter || createPhysicalSessionStateFsAdapter())
511
+ const first = await summarizeSessionSource(createSource())
512
+ const second = await summarizeSessionSource(createSource())
513
+ if (!sameSessionSourceSummary(first, second)) throw new Error('Session JSON source changed during cutover double read')
514
+ if (!backupFile) {
515
+ backupFile = await writeCutoverBackupStream(() => createSource().iterate(), first, {
516
+ directory: options.backupDirectory,
517
+ createWriteStream: options.createBackupWriteStream,
518
+ })
519
+ } else {
520
+ const third = await summarizeSessionSource(createSource())
521
+ if (!sameSessionSourceSummary(first, third)) throw new Error('Session JSON source changed before cutover commit')
522
+ }
336
523
  setSessionStoragePhase(SESSION_STORAGE_PHASES.CUTOVER_RUNNING, {
337
524
  stateCount: first.count,
338
525
  digest: first.digest,
@@ -344,16 +531,22 @@ export async function initializeSessionStateCutover(options = {}) {
344
531
  backupFile,
345
532
  diagnostic: first.diagnostics,
346
533
  }
347
- repository.replaceAll(first.records, {
534
+ // replaceAllStream consumes the 4th source pass record-by-record inside
535
+ // its own immediate transaction and verifies expectedDigest there.
536
+ await repository.replaceAllStream(createSource().iterate(), {
348
537
  expectedCount: first.count,
349
538
  expectedDigest: first.digest,
350
539
  storageState: pendingValues,
540
+ mirrorDeletes: first.diagnostics.orphanDeletes,
351
541
  })
542
+ // replaceAll already verified expectedDigest inside its transaction, so
543
+ // the post-replace check re-asserts SQL-level integrity and the count
544
+ // only (the lightweight verification digest is null by design).
352
545
  const integrity = repository.verifyIntegrity({ quickCheck: true })
353
- if (!integrity.ok || integrity.count !== first.count || integrity.digest !== first.digest) throw new Error('Session SQLite replace verification failed')
546
+ if (!integrity.ok || integrity.count !== first.count) throw new Error('Session SQLite replace verification failed')
354
547
  readSessionStorageState()
355
548
  const drained = await drainSessionJsonMirror()
356
- if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: integrity.digest, backupFile, diagnostic: first.diagnostics })
549
+ if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: first.digest, backupFile, diagnostic: first.diagnostics })
357
550
  return readSessionStorageState()
358
551
  } catch (error) {
359
552
  const state = readSessionStorageState()
@@ -13,6 +13,11 @@ export const SESSION_STORAGE_PHASES = Object.freeze({
13
13
  // marker). Sessions below the threshold stay inline for backward compatibility.
14
14
  export const MESSAGES_SPLIT_THRESHOLD = 200
15
15
 
16
+ // Mirror drain page size: the queue rows carry full state_json payloads, so
17
+ // the drain pulls a bounded batch at a time instead of loading the whole
18
+ // outbox (large cutover imports can enqueue thousands of entries).
19
+ const MIRROR_DRAIN_BATCH_LIMIT = 8
20
+
16
21
  let repositoryInstance = null
17
22
  let cachedPhase = SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE
18
23
  let jsonAdapter = null
@@ -667,25 +672,37 @@ export function replaceSessionStateSnapshot({ sessions, sessionsMetadata }, { me
667
672
  export async function drainSessionJsonMirror() {
668
673
  if (drainPromise) return drainPromise
669
674
  drainPromise = (async () => {
670
- if (!mirrorAdapter) return { drained: 0, pending: repository().listMirrorQueue().length }
675
+ if (!mirrorAdapter) return { drained: 0, pending: repository().countMirrorQueue() }
671
676
  let drained = 0
672
- for (const entry of repository().listMirrorQueue()) {
673
- try {
674
- if (entry.operation === 'upsert') {
675
- const state = entry.state?.messageStorage === MESSAGES_SPLIT_VALUE
676
- ? { ...entry.state, messages: allMessages(entry) }
677
- : entry.state
678
- await mirrorAdapter.upsert({ ...entry, state })
679
- } else {
680
- await mirrorAdapter.delete(entry)
677
+ // Page through the outbox in small batches. A failed entry gets its
678
+ // updated_at bumped by failMirror, so it sorts behind the remaining
679
+ // entries and the next page drains others first; entries that keep
680
+ // failing are retried by the scheduled drain. A batch with zero
681
+ // acknowledgements stops the loop (no head-of-line livelock).
682
+ for (;;) {
683
+ const batch = repository().listMirrorQueue({ limit: MIRROR_DRAIN_BATCH_LIMIT })
684
+ if (batch.length === 0) break
685
+ let acknowledged = 0
686
+ for (const entry of batch) {
687
+ try {
688
+ if (entry.operation === 'upsert') {
689
+ const state = entry.state?.messageStorage === MESSAGES_SPLIT_VALUE
690
+ ? { ...entry.state, messages: allMessages(entry) }
691
+ : entry.state
692
+ await mirrorAdapter.upsert({ ...entry, state })
693
+ } else {
694
+ await mirrorAdapter.delete(entry)
695
+ }
696
+ repository().acknowledgeMirror(entry)
697
+ drained += 1
698
+ acknowledged += 1
699
+ } catch (error) {
700
+ repository().failMirror(entry, error)
681
701
  }
682
- repository().acknowledgeMirror(entry)
683
- drained += 1
684
- } catch (error) {
685
- repository().failMirror(entry, error)
686
702
  }
703
+ if (acknowledged === 0) break
687
704
  }
688
- return { drained, pending: repository().listMirrorQueue().length }
705
+ return { drained, pending: repository().countMirrorQueue() }
689
706
  })().finally(() => { drainPromise = null })
690
707
  const result = await drainPromise
691
708
  if (result.pending > 0) scheduleSessionJsonMirrorDrain()
@@ -700,8 +717,12 @@ export function getSessionStateDiagnostics() {
700
717
  let integrity
701
718
  let mirrorPending = null
702
719
  try {
703
- integrity = repository().verifyIntegrity()
704
- mirrorPending = repository().listMirrorQueue().length
720
+ // Lightweight (SQL-level) integrity: startup diagnostics on large stores
721
+ // must not re-parse every stored body. The result carries
722
+ // `lightweight: true`; full per-row verification stays on maintenance
723
+ // entry points (backup export/restore, downgrade tooling).
724
+ integrity = repository().verifyIntegrity({ quickCheck: true })
725
+ mirrorPending = repository().countMirrorQueue()
705
726
  } catch (error) {
706
727
  integrity = { ok: false, error: error?.message || String(error) }
707
728
  }