@shawnstack/quickforge 1.7.10 → 1.7.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{AgentProfilesPage-Dw9orPNt.js → AgentProfilesPage-CApCpsDd.js} +1 -1
- package/dist/assets/ChatPanelHost-COTO7swS.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-Be5r3dtv.js +1 -0
- package/dist/assets/{PluginsPage-Dj1bmsEf.js → PluginsPage-BIbIDfWY.js} +1 -1
- package/dist/assets/ScheduledTasksPage-BOqmBNCx.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-CFuvSATi.js → SettingsWorkspacePage-BCRtycRN.js} +304 -304
- package/dist/assets/ShareLinksSettingsPage-BrxQacJn.js +1 -0
- package/dist/assets/{SharedConversationPage-DLo05j7c.js → SharedConversationPage-DJa0M0El.js} +1 -1
- package/dist/assets/TerminalDock-Cqz_9mTC.js +2 -0
- package/dist/assets/WorkspaceInspector-C-GOK81D.js +36 -0
- package/dist/assets/{abnfDiagram-VRR7QNED-CMhVoYUh.js → abnfDiagram-VRR7QNED-DGklo3PO.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-C52qL6Hg.js → architectureDiagram-ZJ3FMSHR-CioWLVTp.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-CM3XbLol.js → blockDiagram-677ZJIJ3-BJWBq5e9.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-D7Bh1ui1.js → c4Diagram-LMCZKHZV-BacRvqFK.js} +1 -1
- package/dist/assets/channel-CeXY-wZo.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-xgrtbT8e.js → chunk-32BRIVSS-B53BNE2C.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-CDbaHw80.js → chunk-52WLFC77-CYq4hT4f.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-ZMLUvfLy.js → chunk-C7G6YPKG-KFaGpP_Z.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-BcQhaqt7.js → chunk-EX3LRPZG-DqVd_ihG.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-DrnrdrSu.js → chunk-FWX5IMBZ-BxRjZkOP.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-tdc-Z9eW.js → chunk-HOUHSVGY-C9QO8omv.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-Cd7igVGt.js → chunk-ICXQ74PX-ClbGPgLV.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CLTjV5ov.js → chunk-MOJQB5TN-Ba4wlF1J.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-TgvkXkFX.js → chunk-OGEWGWER-DczhQmcx.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-C5GMVYup.js → chunk-PUDLZKDR-CPWpcKUF.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-fo5-HsqY.js → chunk-Q4XR5HBZ-CiF6T--X.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-CEDazV6w.js → chunk-V7JOEXUC-Dxv0zwmw.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-CWYjg8zE.js → chunk-VAUOI2AC-D3J5bw1P.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-C_-eEj-9.js → chunk-VR4S4FIN-BXQSwwII.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-NF-MsJJR.js → chunk-WYO6CB5R-C0azl-fk.js} +2 -2
- package/dist/assets/{chunk-ZGVPDNZ5-CRrAYi4g.js → chunk-ZGVPDNZ5-D_8F4Akx.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-B8M6sf49.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-BY7fzBDH.js +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-D4sZ7LYI.js → cynefinDiagram-TSTJHNR4-BFlsWz0B.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-BlsnSh-Q.js → dagre-VKFMJZFB-DXhpjlEs.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-DW1yQiRO.js → diagram-FQU43EPY-DRVVOD9k.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-BTUAsFjb.js → diagram-G47NLZAW-BV9Gw1tL.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-BcaomqSd.js → diagram-NH7WQ7WH-CrUkXfRY.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-DDLX7Xwr.js → diagram-OA4YK3LP-CuILjn7v.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-CIave0An.js → diagram-WEI45ONY-nlNN3tWB.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-Cr1Y6lPu.js → ebnfDiagram-CCIWWBDH-BuDgZt9n.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-D3EgiVVH.js → erDiagram-Q63AITRT-MEwxjmtP.js} +1 -1
- package/dist/assets/flowDiagram-23GEKE2U-BiUP-Scq.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-B0oyDIqK.js → ganttDiagram-NO4QXBWP-DNw5qghE.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-uQtr6IqK.js → gitGraphDiagram-IHSO6WYX-CaTcakAt.js} +1 -1
- package/dist/assets/index-BWf6y3Kh.css +3 -0
- package/dist/assets/index-D3GWkXJR.js +66 -0
- package/dist/assets/{infoDiagram-FWYZ7A6U-B_GzlLIM.js → infoDiagram-FWYZ7A6U-DwfyP7dU.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-CKQIsSdU.js → ishikawaDiagram-FXEZZL3T-CmrzbOCV.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-DrrvzZL1.js → journeyDiagram-5HDEW3XC-CYqe3sgr.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-CJQRTdXz.js → kanban-definition-HUTT4EX6-BMDO_mv0.js} +1 -1
- package/dist/assets/{line-B4DxX7tC.js → line-SbxJR8ZT.js} +1 -1
- package/dist/assets/lit-vendor-CinchGmI.js +2 -0
- package/dist/assets/local-tools-DY0yEU3T.js +350 -0
- package/dist/assets/{mcp-servers-dialog-CwHp0K4q.js → mcp-servers-dialog-7Xwu2RVW.js} +2 -2
- package/dist/assets/{mermaid.core-DWpShXCs.js → mermaid.core-CwXTzYjs.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-CEVwZD78.js → mindmap-definition-LN4V7U3C-CLyJ78mO.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-CBLkBdnQ.js → pegDiagram-2B236MQR-4qFznrSw.js} +1 -1
- package/dist/assets/{pi-web-ui-DV5jKjSg.js → pi-web-ui-BNuvqKDT.js} +289 -289
- package/dist/assets/{pieDiagram-ENE6RG2P-8tCnwtWS.js → pieDiagram-ENE6RG2P-141sGxVe.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-CWd28cHF.js → quadrantDiagram-ABIIQ3AL-SUI44pAX.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-CI4ouOBs.js → railroadDiagram-RFXS5EU6-1oiZuYBB.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-DtFz7sw4.js → requirementDiagram-TGXJPOKE-CfVishLi.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB--VeOwwk9.js → sankeyDiagram-HTMAVEWB-DVu7J_Z9.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-B8AHk5Oe.js → sequenceDiagram-DBY2YBRQ-D-Ip2yiS.js} +1 -1
- package/dist/assets/{skills-dialog-k9ycekIU.js → skills-dialog-C_JvWbAa.js} +3 -3
- package/dist/assets/{stateDiagram-2N3HPSRC-etXnJ8X8.js → stateDiagram-2N3HPSRC-Cg0hWwrp.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DhmzNQd9.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-pabIKAmn.js → swimlanes-5IMT3BWC-Co5D-cGU.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-BEvomQNa.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-C55MKm3L.js → timeline-definition-FHXFAJF6-CBk4c_Ua.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-CgNf1Q9W.js → vennDiagram-L72KCM5P-BII7wdRx.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-Drt5ytec.js → wardleyDiagram-EHGQE667-CkyJJIXP.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-hhoEo_Ar.js → xychartDiagram-FW5EYKEG-B1A66Ks0.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +1 -1
- package/server/acp/server.mjs +3 -6
- package/server/agent-manager.mjs +192 -48
- package/server/approval-store.mjs +1 -0
- package/server/ask-store.mjs +78 -0
- package/server/auto-archive.mjs +55 -8
- package/server/index.mjs +172 -51
- package/server/lan-access-cutover.mjs +21 -4
- package/server/maintenance/downgrade-session-state-v1.mjs +46 -85
- package/server/maintenance/export-session-state-v1.mjs +1 -11
- package/server/routes/agent.mjs +9 -0
- package/server/routes/backup.mjs +1 -1
- package/server/routes/storage.mjs +63 -49
- package/server/scheduled-runs-cutover.mjs +15 -1
- package/server/session-index-service.mjs +57 -391
- package/server/session-state-backup.mjs +56 -5
- package/server/session-state-import.mjs +174 -0
- package/server/session-state-maintenance.mjs +126 -0
- package/server/session-state-service.mjs +128 -210
- 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 +337 -387
- package/server/sqlite/share-repository.mjs +56 -45
- package/server/startup-state.mjs +107 -0
- package/server/storage.mjs +218 -454
- package/server/tools/definitions.mjs +18 -0
- package/server/tools/index.mjs +14 -2
- package/server/utils/logger.mjs +15 -4
- package/dist/assets/ChatPanelHost-CW3-MQyL.js +0 -48
- package/dist/assets/CloudAccountSettingsPage-CDkKN1Sz.js +0 -1
- package/dist/assets/ScheduledTasksPage-C9lOTi75.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-CH_t33A3.js +0 -1
- package/dist/assets/TerminalDock-CSOVub-m.js +0 -2
- package/dist/assets/WorkspaceInspector-DPXgLIum.js +0 -36
- package/dist/assets/channel-Duu1CuH9.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-Cc63fLWP.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-CoXU0qT6.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-BS7KXcqn.js +0 -1
- package/dist/assets/index-DQBOm9gt.js +0 -66
- package/dist/assets/index-ps85guTs.css +0 -3
- package/dist/assets/lit-vendor-DvaQX_UQ.js +0 -2
- package/dist/assets/local-tools-BxfQeIUQ.js +0 -270
- package/dist/assets/stateDiagram-v2-6OUMAXLB-Dms3pICj.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-B14aWjv6.js +0 -8
- package/server/session-state-cutover.mjs +0 -563
|
@@ -1,563 +0,0 @@
|
|
|
1
|
-
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
-
import { createHash, randomUUID } from 'node:crypto'
|
|
3
|
-
import { createReadStream, createWriteStream, promises as fs } from 'node:fs'
|
|
4
|
-
import path from 'node:path'
|
|
5
|
-
import { getSqliteStorage } from './sqlite/database.mjs'
|
|
6
|
-
import { createSessionStateRepository, snapshotDigestLine } from './sqlite/session-state-repository.mjs'
|
|
7
|
-
import {
|
|
8
|
-
SESSION_STORAGE_PHASES,
|
|
9
|
-
configureSessionStateService,
|
|
10
|
-
drainSessionJsonMirror,
|
|
11
|
-
readSessionStorageState,
|
|
12
|
-
setSessionStoragePhase,
|
|
13
|
-
} from './session-state-service.mjs'
|
|
14
|
-
import {
|
|
15
|
-
createPhysicalSessionStateFsAdapter,
|
|
16
|
-
materializeSessionJsonMirrorEntry,
|
|
17
|
-
storageDir,
|
|
18
|
-
} from './storage.mjs'
|
|
19
|
-
|
|
20
|
-
const DEFAULT_LOCK_TTL_MS = 60_000
|
|
21
|
-
const DEFAULT_WAIT_TIMEOUT_MS = 65_000
|
|
22
|
-
const maintenanceContext = new AsyncLocalStorage()
|
|
23
|
-
let maintenanceCount = 0
|
|
24
|
-
|
|
25
|
-
function isPlainObject(value) {
|
|
26
|
-
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function canonicalize(value) {
|
|
30
|
-
if (Array.isArray(value)) return value.map(canonicalize)
|
|
31
|
-
if (!isPlainObject(value)) return value
|
|
32
|
-
return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function digestJson(value) {
|
|
36
|
-
return createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex')
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function normalizeBucket(raw) {
|
|
40
|
-
if (raw.scope === 'global') {
|
|
41
|
-
if (raw.projectId !== undefined && raw.projectId !== null && raw.projectId !== '') throw new TypeError('Global session bucket cannot have projectId')
|
|
42
|
-
return { scope: 'global', projectId: null }
|
|
43
|
-
}
|
|
44
|
-
if (raw.scope !== 'project' || typeof raw.projectId !== 'string' || !raw.projectId.trim() || raw.projectId === '.' || raw.projectId === '..' || /[\\/]/.test(raw.projectId)) {
|
|
45
|
-
throw new TypeError('Invalid session bucket')
|
|
46
|
-
}
|
|
47
|
-
return { scope: 'project', projectId: raw.projectId }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function deriveMetadata(sessionId, state, bucket) {
|
|
51
|
-
const createdAt = typeof state.createdAt === 'string'
|
|
52
|
-
? state.createdAt
|
|
53
|
-
: (typeof state.lastModified === 'string' ? state.lastModified : '1970-01-01T00:00:00.000Z')
|
|
54
|
-
return {
|
|
55
|
-
id: sessionId,
|
|
56
|
-
title: typeof state.title === 'string' ? state.title : 'New chat',
|
|
57
|
-
titleSource: state.titleSource,
|
|
58
|
-
createdAt,
|
|
59
|
-
lastModified: typeof state.lastModified === 'string' ? state.lastModified : createdAt,
|
|
60
|
-
messageCount: state.messages.length,
|
|
61
|
-
stateVersion: Number.isInteger(state.stateVersion) && state.stateVersion >= 0 ? state.stateVersion : 0,
|
|
62
|
-
thinkingLevel: typeof state.thinkingLevel === 'string' ? state.thinkingLevel : 'off',
|
|
63
|
-
scope: bucket.scope,
|
|
64
|
-
...(bucket.scope === 'project' ? { projectId: bucket.projectId } : {}),
|
|
65
|
-
taskStatus: state.taskStatus || 'idle',
|
|
66
|
-
taskStartedAt: state.taskStartedAt ?? null,
|
|
67
|
-
taskFinishedAt: state.taskFinishedAt ?? null,
|
|
68
|
-
}
|
|
69
|
-
}
|
|
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
|
-
|
|
118
|
-
export function buildSessionJsonSnapshot(buckets) {
|
|
119
|
-
if (!Array.isArray(buckets)) throw new TypeError('Session buckets must be an array')
|
|
120
|
-
const records = []
|
|
121
|
-
const diagnostics = { bodyOnly: [], metadataOnly: [], orphanDeletes: [], duplicateSessionIds: [] }
|
|
122
|
-
const seen = new Set()
|
|
123
|
-
for (const rawBucket of buckets) {
|
|
124
|
-
const bucket = normalizeBucket(rawBucket)
|
|
125
|
-
if (!isPlainObject(rawBucket.sessions) || !isPlainObject(rawBucket.metadata)) throw new TypeError('Session bucket stores must be objects')
|
|
126
|
-
const ids = new Set([...Object.keys(rawBucket.sessions), ...Object.keys(rawBucket.metadata)])
|
|
127
|
-
for (const sessionId of ids) {
|
|
128
|
-
if (seen.has(sessionId)) diagnostics.duplicateSessionIds.push(sessionId)
|
|
129
|
-
seen.add(sessionId)
|
|
130
|
-
const record = normalizeSessionEntry(bucket, sessionId, rawBucket.sessions[sessionId], rawBucket.metadata[sessionId], diagnostics)
|
|
131
|
-
if (record) records.push(record)
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
if (diagnostics.duplicateSessionIds.length > 0) throw new TypeError(`Duplicate session ids across buckets: ${[...new Set(diagnostics.duplicateSessionIds)].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.
|
|
143
|
-
records.sort((left, right) => left.sessionId.localeCompare(right.sessionId))
|
|
144
|
-
// F9 v7: the canonical snapshot digest line now includes a `messagesDigest`
|
|
145
|
-
// slot. JSON imports are always non-split (messages inline in the body), so
|
|
146
|
-
// they contribute an empty messages digest — matching the repository digest
|
|
147
|
-
// of freshly imported (non-split) SQLite rows.
|
|
148
|
-
const digest = createHash('sha256').update(records.map((record) => snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, '')).join('\n')).digest('hex')
|
|
149
|
-
return { records, count: records.length, digest, diagnostics }
|
|
150
|
-
}
|
|
151
|
-
|
|
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')
|
|
252
|
-
await fs.mkdir(directory, { recursive: true })
|
|
253
|
-
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
254
|
-
const finalPath = path.join(directory, `quickforge-session-state-cutover-${stamp}.json`)
|
|
255
|
-
const temporaryPath = `${finalPath}.${process.pid}.${randomUUID()}.tmp`
|
|
256
|
-
const openWriteStream = options.createWriteStream || createWriteStream
|
|
257
|
-
try {
|
|
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
|
|
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
|
-
})
|
|
277
|
-
}
|
|
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)
|
|
294
|
-
}
|
|
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) {
|
|
322
|
-
throw new Error('Session cutover backup verification failed')
|
|
323
|
-
}
|
|
324
|
-
await fs.rename(temporaryPath, finalPath)
|
|
325
|
-
return finalPath
|
|
326
|
-
} catch (error) {
|
|
327
|
-
await fs.rm(temporaryPath, { force: true }).catch(() => {})
|
|
328
|
-
throw error
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
function ownerPid(row) {
|
|
333
|
-
const value = Number(row?.owner_pid)
|
|
334
|
-
return Number.isInteger(value) && value > 0 ? value : null
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
function pidAlive(pid) {
|
|
338
|
-
try { process.kill(pid, 0); return true } catch (error) { return error?.code === 'EPERM' }
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
export function acquireSessionStateMaintenanceLock(storage, options = {}) {
|
|
342
|
-
const nowMs = Number(options.now?.() ?? Date.now())
|
|
343
|
-
const acquiredAt = new Date(nowMs).toISOString()
|
|
344
|
-
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
345
|
-
const identity = isPlainObject(options.owner)
|
|
346
|
-
? { id: options.owner.id || `${options.owner.pid || process.pid}:${randomUUID()}`, pid: options.owner.pid || process.pid }
|
|
347
|
-
: { id: options.owner || `${process.pid}:${randomUUID()}`, pid: process.pid }
|
|
348
|
-
const isPidAlive = options.pidAlive || pidAlive
|
|
349
|
-
return storage.transaction((database) => {
|
|
350
|
-
const current = database.prepare('SELECT * FROM session_state_maintenance_lock WHERE singleton = 1').get()
|
|
351
|
-
if (current && current.owner === identity.id) {
|
|
352
|
-
database.prepare(`UPDATE session_state_maintenance_lock SET owner_pid = ?, operation = ?, heartbeat_at = ?, expires_at = ?
|
|
353
|
-
WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
354
|
-
.run(identity.pid, options.operation || current.operation, acquiredAt, expiresAt, identity.id, current.fencing)
|
|
355
|
-
return { owner: identity.id, ownerPid: identity.pid, fencing: Number(current.fencing), expiresAt }
|
|
356
|
-
}
|
|
357
|
-
if (current) {
|
|
358
|
-
const expired = Date.parse(current.expires_at) <= nowMs
|
|
359
|
-
const pid = ownerPid(current)
|
|
360
|
-
if (!expired || pid === null || isPidAlive(pid)) return null
|
|
361
|
-
}
|
|
362
|
-
const fencing = Number(current?.fencing || 0) + 1
|
|
363
|
-
database.prepare(`INSERT INTO session_state_maintenance_lock
|
|
364
|
-
(singleton, owner, owner_pid, fencing, operation, acquired_at, heartbeat_at, expires_at)
|
|
365
|
-
VALUES (1, ?, ?, ?, ?, ?, ?, ?)
|
|
366
|
-
ON CONFLICT(singleton) DO UPDATE SET owner=excluded.owner, owner_pid=excluded.owner_pid, fencing=excluded.fencing,
|
|
367
|
-
operation=excluded.operation, acquired_at=excluded.acquired_at, heartbeat_at=excluded.heartbeat_at, expires_at=excluded.expires_at`)
|
|
368
|
-
.run(identity.id, identity.pid, fencing, options.operation || 'session-state-maintenance', acquiredAt, acquiredAt, expiresAt)
|
|
369
|
-
return { owner: identity.id, ownerPid: identity.pid, fencing, expiresAt }
|
|
370
|
-
}, { mode: 'immediate' })
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
export function renewSessionStateMaintenanceLock(storage, lease, options = {}) {
|
|
374
|
-
const nowMs = Number(options.now?.() ?? Date.now())
|
|
375
|
-
const heartbeatAt = new Date(nowMs).toISOString()
|
|
376
|
-
const expiresAt = new Date(nowMs + (options.ttlMs ?? DEFAULT_LOCK_TTL_MS)).toISOString()
|
|
377
|
-
const result = storage.prepare(`UPDATE session_state_maintenance_lock SET heartbeat_at = ?, expires_at = ? WHERE singleton = 1 AND owner = ? AND fencing = ?`)
|
|
378
|
-
.run(heartbeatAt, expiresAt, lease.owner, lease.fencing)
|
|
379
|
-
lease.expiresAt = expiresAt
|
|
380
|
-
return Number(result.changes) === 1
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
export function releaseSessionStateMaintenanceLock(storage, lease) {
|
|
384
|
-
return Number(storage.prepare('DELETE FROM session_state_maintenance_lock WHERE singleton = 1 AND owner = ? AND fencing = ?').run(lease.owner, lease.fencing).changes) === 1
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
async function waitForLock(storage, options) {
|
|
388
|
-
const deadline = Date.now() + (options.waitTimeoutMs ?? DEFAULT_WAIT_TIMEOUT_MS)
|
|
389
|
-
while (Date.now() < deadline) {
|
|
390
|
-
const lease = acquireSessionStateMaintenanceLock(storage, options)
|
|
391
|
-
if (lease) return lease
|
|
392
|
-
await new Promise((resolve) => setTimeout(resolve, options.pollIntervalMs ?? 100))
|
|
393
|
-
}
|
|
394
|
-
throw new Error('Timed out waiting for session state maintenance lock')
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export async function runSessionStateMaintenance(operation, options = {}) {
|
|
398
|
-
const existing = maintenanceContext.getStore()
|
|
399
|
-
if (existing) return operation(existing)
|
|
400
|
-
const storage = options.storage || getSqliteStorage()
|
|
401
|
-
const lease = await waitForLock(storage, options)
|
|
402
|
-
const interval = options.heartbeatIntervalMs ?? Math.max(10, Math.floor((options.ttlMs ?? DEFAULT_LOCK_TTL_MS) / 3))
|
|
403
|
-
const timer = setInterval(() => {
|
|
404
|
-
try {
|
|
405
|
-
if (!renewSessionStateMaintenanceLock(storage, lease, options)) throw new Error('Session state maintenance lock fencing was lost')
|
|
406
|
-
} catch (error) {
|
|
407
|
-
heartbeatError = error
|
|
408
|
-
rejectHeartbeat(error)
|
|
409
|
-
}
|
|
410
|
-
}, interval)
|
|
411
|
-
timer.unref?.()
|
|
412
|
-
let rejectHeartbeat
|
|
413
|
-
let heartbeatError = null
|
|
414
|
-
const heartbeatFailure = new Promise((_, reject) => { rejectHeartbeat = reject })
|
|
415
|
-
maintenanceCount += 1
|
|
416
|
-
try {
|
|
417
|
-
const context = { storage, lease }
|
|
418
|
-
const result = await Promise.race([maintenanceContext.run(context, () => Promise.resolve().then(() => operation(context))), heartbeatFailure])
|
|
419
|
-
if (heartbeatError) throw heartbeatError
|
|
420
|
-
return result
|
|
421
|
-
} finally {
|
|
422
|
-
clearInterval(timer)
|
|
423
|
-
releaseSessionStateMaintenanceLock(storage, lease)
|
|
424
|
-
maintenanceCount = Math.max(0, maintenanceCount - 1)
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export function isSessionStateMaintenanceActive(storage) {
|
|
429
|
-
if (!storage) {
|
|
430
|
-
try { storage = getSqliteStorage() } catch { return false }
|
|
431
|
-
}
|
|
432
|
-
return maintenanceCount > 0 || Boolean(storage.prepare('SELECT 1 AS active FROM session_state_maintenance_lock WHERE singleton = 1').get())
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export function currentSessionStateMaintenanceContext() {
|
|
436
|
-
return maintenanceContext.getStore() || null
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
function mirrorAdapter() {
|
|
440
|
-
async function materialize(entry) {
|
|
441
|
-
await materializeSessionJsonMirrorEntry(entry)
|
|
442
|
-
}
|
|
443
|
-
return { upsert: materialize, delete: materialize }
|
|
444
|
-
}
|
|
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
|
-
|
|
469
|
-
export async function initializeSessionStateCutover(options = {}) {
|
|
470
|
-
const storage = options.storage || getSqliteStorage()
|
|
471
|
-
const repository = options.repository || createSessionStateRepository(storage)
|
|
472
|
-
configureSessionStateService({ repository, mirror: options.mirror || mirrorAdapter() })
|
|
473
|
-
return runSessionStateMaintenance(async () => {
|
|
474
|
-
const current = readSessionStorageState()
|
|
475
|
-
if (current.phase === SESSION_STORAGE_PHASES.JSON_PENDING) {
|
|
476
|
-
const integrity = verifyIntegrityWithIndexSelfHeal(repository, 'pending')
|
|
477
|
-
const drained = await drainSessionJsonMirror()
|
|
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 })
|
|
482
|
-
return readSessionStorageState()
|
|
483
|
-
}
|
|
484
|
-
if (current.phase === SESSION_STORAGE_PHASES.AUTHORITATIVE) {
|
|
485
|
-
verifyIntegrityWithIndexSelfHeal(repository, 'authoritative')
|
|
486
|
-
await drainSessionJsonMirror()
|
|
487
|
-
return readSessionStorageState()
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
if (current.phase === SESSION_STORAGE_PHASES.CUTOVER_RUNNING && current.backupFile) {
|
|
491
|
-
setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
492
|
-
backupFile: current.backupFile,
|
|
493
|
-
diagnostic: { operation: 'cutover_recovery', recoveredFrom: SESSION_STORAGE_PHASES.CUTOVER_RUNNING },
|
|
494
|
-
})
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
let backupFile = current.backupFile
|
|
498
|
-
try {
|
|
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
|
-
}
|
|
523
|
-
setSessionStoragePhase(SESSION_STORAGE_PHASES.CUTOVER_RUNNING, {
|
|
524
|
-
stateCount: first.count,
|
|
525
|
-
digest: first.digest,
|
|
526
|
-
backupFile,
|
|
527
|
-
diagnostic: first.diagnostics,
|
|
528
|
-
})
|
|
529
|
-
const pendingValues = {
|
|
530
|
-
phase: SESSION_STORAGE_PHASES.JSON_PENDING,
|
|
531
|
-
backupFile,
|
|
532
|
-
diagnostic: first.diagnostics,
|
|
533
|
-
}
|
|
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(), {
|
|
537
|
-
expectedCount: first.count,
|
|
538
|
-
expectedDigest: first.digest,
|
|
539
|
-
storageState: pendingValues,
|
|
540
|
-
mirrorDeletes: first.diagnostics.orphanDeletes,
|
|
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).
|
|
545
|
-
const integrity = repository.verifyIntegrity({ quickCheck: true })
|
|
546
|
-
if (!integrity.ok || integrity.count !== first.count) throw new Error('Session SQLite replace verification failed')
|
|
547
|
-
readSessionStorageState()
|
|
548
|
-
const drained = await drainSessionJsonMirror()
|
|
549
|
-
if (drained.pending === 0) setSessionStoragePhase(SESSION_STORAGE_PHASES.AUTHORITATIVE, { stateCount: integrity.count, digest: first.digest, backupFile, diagnostic: first.diagnostics })
|
|
550
|
-
return readSessionStorageState()
|
|
551
|
-
} catch (error) {
|
|
552
|
-
const state = readSessionStorageState()
|
|
553
|
-
if (![SESSION_STORAGE_PHASES.JSON_PENDING, SESSION_STORAGE_PHASES.AUTHORITATIVE].includes(state.phase)) {
|
|
554
|
-
setSessionStoragePhase(SESSION_STORAGE_PHASES.JSON_AUTHORITATIVE, {
|
|
555
|
-
backupFile,
|
|
556
|
-
diagnostic: { operation: 'cutover', errorName: error?.name || 'Error', error: error?.message || String(error) },
|
|
557
|
-
})
|
|
558
|
-
return readSessionStorageState()
|
|
559
|
-
}
|
|
560
|
-
throw error
|
|
561
|
-
}
|
|
562
|
-
}, { ...options, storage, operation: 'session-state-cutover' })
|
|
563
|
-
}
|