@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,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,91 +248,20 @@ 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 Number.isInteger(value) && value >= 0 ? value : null
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function upsertIndex(database, record) {
|
|
231
|
-
const metadata = record.metadata
|
|
232
|
-
database.prepare(`
|
|
233
|
-
INSERT INTO session_index (
|
|
234
|
-
scope, project_id, session_id, created_at, last_modified, message_count,
|
|
235
|
-
pinned_at, archived_at, is_pinned, is_archived, state_version,
|
|
236
|
-
metadata_json, metadata_digest, indexed_at
|
|
237
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
238
|
-
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
|
|
239
|
-
created_at = excluded.created_at,
|
|
240
|
-
last_modified = excluded.last_modified,
|
|
241
|
-
message_count = excluded.message_count,
|
|
242
|
-
pinned_at = excluded.pinned_at,
|
|
243
|
-
archived_at = excluded.archived_at,
|
|
244
|
-
is_pinned = excluded.is_pinned,
|
|
245
|
-
is_archived = excluded.is_archived,
|
|
246
|
-
state_version = excluded.state_version,
|
|
247
|
-
metadata_json = excluded.metadata_json,
|
|
248
|
-
metadata_digest = excluded.metadata_digest,
|
|
249
|
-
indexed_at = excluded.indexed_at
|
|
250
|
-
`).run(
|
|
251
|
-
record.scope, record.projectId, record.sessionId,
|
|
252
|
-
nullableString(metadata.createdAt), nullableString(metadata.lastModified), nullableInteger(metadata.messageCount),
|
|
253
|
-
nullableString(metadata.pinnedAt), nullableString(metadata.archivedAt), metadata.pinnedAt ? 1 : 0, metadata.archivedAt ? 1 : 0,
|
|
254
|
-
record.stateVersion, record.metadataJson, record.metadataDigest, record.now,
|
|
255
|
-
)
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function enqueueMirror(database, record, operation, revision) {
|
|
259
|
-
database.prepare(`
|
|
260
|
-
INSERT INTO session_json_mirror_queue (
|
|
261
|
-
scope, project_id, session_id, operation, revision, state_json, metadata_json, attempts, last_error, updated_at
|
|
262
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)
|
|
263
|
-
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
|
|
264
|
-
operation = excluded.operation,
|
|
265
|
-
revision = excluded.revision,
|
|
266
|
-
state_json = excluded.state_json,
|
|
267
|
-
metadata_json = excluded.metadata_json,
|
|
268
|
-
attempts = 0,
|
|
269
|
-
last_error = NULL,
|
|
270
|
-
updated_at = excluded.updated_at
|
|
271
|
-
`).run(
|
|
272
|
-
record.scope, record.projectId, record.sessionId, operation, revision,
|
|
273
|
-
operation === 'upsert' ? record.stateJson : null,
|
|
274
|
-
operation === 'upsert' ? record.metadataJson : null,
|
|
275
|
-
record.now,
|
|
276
|
-
)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function digestRows(database) {
|
|
280
|
-
const rows = database.prepare('SELECT scope, project_id, session_id, state_digest, metadata_digest FROM session_states').all()
|
|
281
|
-
const groups = new Map()
|
|
282
|
-
for (const row of database.prepare('SELECT scope, project_id, session_id, seq, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()) {
|
|
283
|
-
const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
|
|
284
|
-
if (!groups.has(key)) groups.set(key, [])
|
|
285
|
-
groups.get(key).push(row)
|
|
286
|
-
}
|
|
287
|
-
return rows.map((row) => ({
|
|
288
|
-
scope: row.scope,
|
|
289
|
-
project_id: row.project_id,
|
|
290
|
-
session_id: row.session_id,
|
|
291
|
-
state_digest: row.state_digest,
|
|
292
|
-
metadata_digest: row.metadata_digest,
|
|
293
|
-
messages_digest: messagesDigest(groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []),
|
|
294
|
-
}))
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function digestFromLines(lines) {
|
|
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) {
|
|
298
265
|
return createHash('sha256').update([...lines].sort().join('\n')).digest('hex')
|
|
299
266
|
}
|
|
300
267
|
|
|
@@ -316,46 +283,128 @@ export function sessionStateSnapshotDigest(records) {
|
|
|
316
283
|
}
|
|
317
284
|
|
|
318
285
|
function actualRevision(database, record) {
|
|
319
|
-
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 = ?')
|
|
320
287
|
.get(record.scope, record.projectId, record.sessionId)
|
|
321
|
-
if (active)
|
|
322
|
-
|
|
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 = ?')
|
|
323
295
|
.get(record.scope, record.projectId, record.sessionId)
|
|
324
|
-
return { revision:
|
|
296
|
+
return { revision: 0, stateVersion: null, createdAt: null, messageCount: 0, tombstoneAt: tombstone ? Number(tombstone.deleted_at) : null, active: false }
|
|
325
297
|
}
|
|
326
298
|
|
|
327
299
|
function assertNoCrossBucketDuplicate(database, record) {
|
|
328
|
-
const other = database
|
|
300
|
+
const other = cachedStatement(database, `SELECT scope, project_id FROM sessions
|
|
329
301
|
WHERE session_id = ? AND NOT (scope = ? AND project_id = ?) LIMIT 1`)
|
|
330
302
|
.get(record.sessionId, record.scope, record.projectId)
|
|
331
303
|
if (other) throw duplicate(record.sessionId)
|
|
332
304
|
}
|
|
333
305
|
|
|
334
|
-
|
|
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) {
|
|
335
367
|
if (record.messages === undefined) return
|
|
336
368
|
if (mode !== 'replace' && mode !== 'append') throw new TypeError('messagesMode must be replace or append')
|
|
337
369
|
const params = [record.scope, record.projectId, record.sessionId]
|
|
338
370
|
if (mode === 'replace') {
|
|
339
|
-
database
|
|
340
|
-
const insert = database
|
|
341
|
-
INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
|
|
342
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
343
|
-
`)
|
|
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)
|
|
344
373
|
record.messages.forEach((message, seq) => {
|
|
345
|
-
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)
|
|
346
375
|
})
|
|
347
376
|
return
|
|
348
377
|
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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)
|
|
355
404
|
let seq = maxSeq + 1
|
|
356
405
|
for (const message of record.messages) {
|
|
357
406
|
if (message.messageId !== null && existingIds.has(message.messageId)) continue
|
|
358
|
-
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)
|
|
359
408
|
if (message.messageId !== null) existingIds.add(message.messageId)
|
|
360
409
|
seq += 1
|
|
361
410
|
}
|
|
@@ -367,27 +416,26 @@ function saveInTransaction(database, record, expectedRevision, expectedStateVers
|
|
|
367
416
|
if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
|
|
368
417
|
if (expectedStateVersion !== null && current.stateVersion !== expectedStateVersion) throw stateVersionConflict(record.sessionId, expectedStateVersion, current.stateVersion)
|
|
369
418
|
const revision = current.revision + 1
|
|
370
|
-
|
|
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 = ?')
|
|
371
423
|
.run(record.scope, record.projectId, record.sessionId)
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
record.scope, record.projectId, record.sessionId
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
)
|
|
388
|
-
upsertIndex(database, record)
|
|
389
|
-
enqueueMirror(database, record, 'upsert', revision)
|
|
390
|
-
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 }
|
|
391
439
|
}
|
|
392
440
|
|
|
393
441
|
function deleteInTransaction(database, input, expectedRevision, timestamp) {
|
|
@@ -396,32 +444,28 @@ function deleteInTransaction(database, input, expectedRevision, timestamp) {
|
|
|
396
444
|
const current = actualRevision(database, record)
|
|
397
445
|
if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
|
|
398
446
|
if (!current.active) return false
|
|
399
|
-
|
|
400
|
-
database
|
|
401
|
-
.run(record.scope, record.projectId, record.sessionId)
|
|
402
|
-
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 = ?')
|
|
403
449
|
.run(record.scope, record.projectId, record.sessionId)
|
|
404
|
-
database
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET revision = excluded.revision, deleted_at = excluded.deleted_at`)
|
|
409
|
-
.run(record.scope, record.projectId, record.sessionId, deletionRevision, timestamp)
|
|
410
|
-
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))
|
|
411
454
|
return true
|
|
412
455
|
}
|
|
413
456
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
+
}
|
|
425
469
|
}
|
|
426
470
|
|
|
427
471
|
export function createSessionStateRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
|
|
@@ -433,13 +477,13 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
433
477
|
function get(scope, projectId, sessionId) {
|
|
434
478
|
const normalizedBucket = bucket(scope, projectId)
|
|
435
479
|
nonEmptyString(sessionId, 'sessionId')
|
|
436
|
-
return mapRow(storage
|
|
480
|
+
return mapRow(cachedStatement(storage, `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE scope = ? AND project_id = ? AND session_id = ?`)
|
|
437
481
|
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId))
|
|
438
482
|
}
|
|
439
483
|
|
|
440
484
|
function findBySessionId(sessionId) {
|
|
441
485
|
nonEmptyString(sessionId, 'sessionId')
|
|
442
|
-
const rows = storage
|
|
486
|
+
const rows = cachedStatement(storage, `SELECT ${SESSION_ROW_COLUMNS} FROM sessions WHERE session_id = ? ORDER BY scope, project_id`).all(sessionId)
|
|
443
487
|
if (rows.length > 1) throw duplicate(sessionId)
|
|
444
488
|
return mapRow(rows[0])
|
|
445
489
|
}
|
|
@@ -471,13 +515,6 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
471
515
|
const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
|
|
472
516
|
const record = normalizeRecord({ ...input, messages }, now())
|
|
473
517
|
return storage.transaction((database) => {
|
|
474
|
-
const current = actualRevision(database, record)
|
|
475
|
-
if (current.active) {
|
|
476
|
-
const stored = database.prepare('SELECT state_json FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
477
|
-
.get(record.scope, record.projectId, record.sessionId)
|
|
478
|
-
const parsed = JSON.parse(stored.state_json)
|
|
479
|
-
if (Array.isArray(parsed.messages)) throw new TypeError('Session is not split; use replaceMessages to split it first')
|
|
480
|
-
}
|
|
481
518
|
const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'append' })
|
|
482
519
|
beforeCommit?.(database)
|
|
483
520
|
return result
|
|
@@ -499,10 +536,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
499
536
|
} else if (offset > 0) {
|
|
500
537
|
limitParams = [limit, offset]
|
|
501
538
|
}
|
|
502
|
-
const rows = storage
|
|
539
|
+
const rows = cachedStatement(storage, `SELECT seq, message_json, message_digest FROM session_messages
|
|
503
540
|
WHERE ${where.join(' AND ')} ORDER BY seq ASC LIMIT ?${offset > 0 && afterSeq === null ? ' OFFSET ?' : ''}`)
|
|
504
541
|
.all(...params, ...limitParams)
|
|
505
|
-
const total = Number(storage
|
|
542
|
+
const total = Number(cachedStatement(storage, MESSAGE_COUNT_SQL)
|
|
506
543
|
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
|
|
507
544
|
const lastSeq = rows.length > 0 ? Number(rows[rows.length - 1].seq) : null
|
|
508
545
|
return {
|
|
@@ -519,18 +556,43 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
519
556
|
function messageCount({ scope, projectId, sessionId }) {
|
|
520
557
|
const normalizedBucket = bucket(scope, projectId)
|
|
521
558
|
nonEmptyString(sessionId, 'sessionId')
|
|
522
|
-
return Number(storage
|
|
559
|
+
return Number(cachedStatement(storage, MESSAGE_COUNT_SQL)
|
|
523
560
|
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
|
|
524
561
|
}
|
|
525
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
|
+
|
|
526
586
|
function deleteRecord({ scope, projectId, sessionId, expectedRevision = null, beforeCommit } = {}) {
|
|
527
587
|
const expected = normalizeRevision(expectedRevision, { optional: true })
|
|
528
588
|
const timestamp = now()
|
|
529
|
-
|
|
589
|
+
const deleted = storage.transaction((database) => {
|
|
530
590
|
const result = deleteInTransaction(database, { scope, projectId, sessionId }, expected, timestamp)
|
|
531
591
|
beforeCommit?.(database)
|
|
532
592
|
return result
|
|
533
593
|
}, { mode: 'immediate' })
|
|
594
|
+
if (deleted) vacuumAfterDelete(storage)
|
|
595
|
+
return deleted
|
|
534
596
|
}
|
|
535
597
|
|
|
536
598
|
function deleteBySessionId(sessionId, options = {}) {
|
|
@@ -547,8 +609,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
547
609
|
const timestamp = now()
|
|
548
610
|
const normalizedUpserts = upserts.map((entry) => {
|
|
549
611
|
const input = entry.record ?? entry
|
|
550
|
-
const
|
|
551
|
-
const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
|
|
612
|
+
const record = normalizeRecord(entry.messages !== undefined ? { ...input, messages: entry.messages } : input, timestamp)
|
|
552
613
|
return {
|
|
553
614
|
record,
|
|
554
615
|
expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
|
|
@@ -566,12 +627,14 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
566
627
|
if (keys.has(key)) throw new TypeError(`Duplicate session batch key: ${entry.sessionId}`)
|
|
567
628
|
keys.add(key)
|
|
568
629
|
}
|
|
569
|
-
|
|
630
|
+
const result = storage.transaction((database) => {
|
|
570
631
|
const saved = normalizedUpserts.map(({ record, expectedRevision, expectedStateVersion, messagesMode }) => saveInTransaction(database, record, expectedRevision, expectedStateVersion, { messagesMode }))
|
|
571
632
|
const deleted = normalizedDeletes.map(({ input, expectedRevision }) => deleteInTransaction(database, input, expectedRevision, timestamp))
|
|
572
633
|
beforeCommit?.(database)
|
|
573
634
|
return { saved, deleted }
|
|
574
635
|
}, { mode: 'immediate' })
|
|
636
|
+
if (result.deleted.some(Boolean)) vacuumAfterDelete(storage)
|
|
637
|
+
return result
|
|
575
638
|
}
|
|
576
639
|
|
|
577
640
|
function saveMany(inputs, options = {}) {
|
|
@@ -585,122 +648,44 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
585
648
|
}, options).saved
|
|
586
649
|
}
|
|
587
650
|
|
|
588
|
-
|
|
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 } = {}) {
|
|
589
657
|
if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
|
|
590
|
-
if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
|
|
591
658
|
const timestamp = now()
|
|
592
|
-
const records = inputs.map((input) =>
|
|
593
|
-
let messages = input.messages
|
|
594
|
-
if (messages === undefined && input.state?.messageStorage === 'split') {
|
|
595
|
-
messages = Array.isArray(input.state.messages) ? input.state.messages : []
|
|
596
|
-
}
|
|
597
|
-
return normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
|
|
598
|
-
})
|
|
659
|
+
const records = inputs.map((input) => normalizeRecord(input, timestamp))
|
|
599
660
|
const ids = new Set()
|
|
600
661
|
for (const record of records) {
|
|
601
662
|
if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
|
|
602
663
|
ids.add(record.sessionId)
|
|
603
664
|
}
|
|
604
|
-
const
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
if (recordKeys.has(JSON.stringify([entry.scope, entry.projectId, entry.sessionId]))) {
|
|
608
|
-
throw new TypeError(`mirrorDeletes entry duplicates a record key: ${entry.sessionId}`)
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
return storage.transaction((database) => {
|
|
612
|
-
database.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
|
|
665
|
+
const count = storage.transaction((database) => {
|
|
666
|
+
database.exec('DELETE FROM sessions; DELETE FROM session_messages; DELETE FROM session_tombstones;')
|
|
667
|
+
const insert = database.prepare(UPSERT_SESSION_SQL)
|
|
613
668
|
for (const record of records) {
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
// Orphan deletes intentionally have no session_states/session_index rows
|
|
621
|
-
// (they were dropped as stale residue); enqueue mirror deletes so the
|
|
622
|
-
// drain physically clears leftover JSON files (idempotent when absent).
|
|
623
|
-
for (const entry of deletes) {
|
|
624
|
-
enqueueMirror(database, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
|
|
669
|
+
insert.run(...sessionRowValues(record, {
|
|
670
|
+
revision: 1,
|
|
671
|
+
createdAt: nullableString(record.metadata.createdAt) || timestamp,
|
|
672
|
+
messageCount: record.messages !== undefined ? record.messages.length : 0,
|
|
673
|
+
}))
|
|
674
|
+
if (record.messages !== undefined) writeMessages(database, record, 'replace')
|
|
625
675
|
}
|
|
626
676
|
const digest = verificationDigest(digestRows(database))
|
|
627
677
|
if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
|
|
628
678
|
if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
|
|
629
|
-
updateStorageState(database, storageState ? { ...storageState, stateCount: records.length, digest, updatedAt: timestamp } : null)
|
|
630
679
|
beforeCommit?.(database)
|
|
631
680
|
return records.length
|
|
632
681
|
}, { mode: 'immediate' })
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
// Streaming equivalent of replaceAll for sources that cannot be materialized
|
|
636
|
-
// in memory (large JSON cutover imports). Consumes a sync or async iterable
|
|
637
|
-
// record-by-record inside one immediate transaction; only the per-record
|
|
638
|
-
// digest lines accumulate. `storage.transaction` requires a synchronous
|
|
639
|
-
// callback, so the transaction is managed manually: any error — including
|
|
640
|
-
// one thrown by the iterator mid-stream — rolls everything back, preserving
|
|
641
|
-
// replaceAll's all-or-nothing semantics. Callers must hold the session
|
|
642
|
-
// state maintenance lock (a concurrent writer hitting the open transaction
|
|
643
|
-
// fails loudly instead of interleaving).
|
|
644
|
-
async function replaceAllStream(recordIterable, { beforeCommit, storageState, expectedCount, expectedDigest, mirrorDeletes = [] } = {}) {
|
|
645
|
-
if (!recordIterable || (typeof recordIterable[Symbol.asyncIterator] !== 'function' && typeof recordIterable[Symbol.iterator] !== 'function')) {
|
|
646
|
-
throw new TypeError('records must be a sync or async iterable')
|
|
647
|
-
}
|
|
648
|
-
if (!Array.isArray(mirrorDeletes)) throw new TypeError('mirrorDeletes must be an array')
|
|
649
|
-
const timestamp = now()
|
|
650
|
-
const deletes = mirrorDeletes.map((input) => ({ ...bucket(input.scope, input.projectId), sessionId: nonEmptyString(input.sessionId, 'sessionId') }))
|
|
651
|
-
// Record keys arrive incrementally while streaming, so the mirrorDeletes
|
|
652
|
-
// conflict check runs per record against the precomputed delete keys (the
|
|
653
|
-
// mirror image of replaceAll's post-hoc set comparison).
|
|
654
|
-
const deleteKeys = new Set(deletes.map((entry) => JSON.stringify([entry.scope, entry.projectId, entry.sessionId])))
|
|
655
|
-
storage.exec('BEGIN IMMEDIATE')
|
|
656
|
-
let count = 0
|
|
657
|
-
const digestLines = []
|
|
658
|
-
try {
|
|
659
|
-
storage.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
|
|
660
|
-
const insert = storage.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
|
|
661
|
-
const ids = new Set()
|
|
662
|
-
for await (const input of recordIterable) {
|
|
663
|
-
let messages = input.messages
|
|
664
|
-
if (messages === undefined && input.state?.messageStorage === 'split') {
|
|
665
|
-
messages = Array.isArray(input.state.messages) ? input.state.messages : []
|
|
666
|
-
}
|
|
667
|
-
const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
|
|
668
|
-
if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
|
|
669
|
-
ids.add(record.sessionId)
|
|
670
|
-
if (deleteKeys.has(JSON.stringify([record.scope, record.projectId, record.sessionId]))) {
|
|
671
|
-
throw new TypeError(`mirrorDeletes entry duplicates a record key: ${record.sessionId}`)
|
|
672
|
-
}
|
|
673
|
-
insert.run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
|
|
674
|
-
if (record.messages !== undefined) writeMessages(storage, record, 'replace', timestamp)
|
|
675
|
-
upsertIndex(storage, record)
|
|
676
|
-
enqueueMirror(storage, record, 'upsert', 1)
|
|
677
|
-
// Streamed imports are non-split (messages inline, none in
|
|
678
|
-
// session_messages), so the empty messages digest matches what
|
|
679
|
-
// digestRows computes for freshly imported non-split rows.
|
|
680
|
-
digestLines.push(snapshotDigestLine(record.scope, record.projectId, record.sessionId, record.stateDigest, record.metadataDigest, ''))
|
|
681
|
-
count += 1
|
|
682
|
-
}
|
|
683
|
-
// Orphan deletes intentionally have no session_states/session_index rows;
|
|
684
|
-
// see replaceAll for the mirror-drain rationale.
|
|
685
|
-
for (const entry of deletes) {
|
|
686
|
-
enqueueMirror(storage, { ...entry, stateJson: null, metadataJson: null, now: timestamp }, 'delete', 1)
|
|
687
|
-
}
|
|
688
|
-
const digest = digestFromLines(digestLines)
|
|
689
|
-
if (expectedCount !== undefined && count !== expectedCount) throw new Error('Session state replace count verification failed')
|
|
690
|
-
if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
|
|
691
|
-
updateStorageState(storage, storageState ? { ...storageState, stateCount: count, digest, updatedAt: timestamp } : null)
|
|
692
|
-
beforeCommit?.(storage)
|
|
693
|
-
storage.exec('COMMIT')
|
|
694
|
-
return count
|
|
695
|
-
} catch (error) {
|
|
696
|
-
try { storage.exec('ROLLBACK') } catch { /* transaction already closed */ }
|
|
697
|
-
throw error
|
|
698
|
-
}
|
|
682
|
+
vacuumAfterDelete(storage)
|
|
683
|
+
return count
|
|
699
684
|
}
|
|
700
685
|
|
|
701
686
|
function exportSnapshot() {
|
|
702
687
|
return storage.transaction((database) => {
|
|
703
|
-
const rows = database.prepare(`SELECT ${
|
|
688
|
+
const rows = database.prepare(`SELECT ${SESSION_ROW_COLUMNS} FROM sessions ORDER BY scope, project_id, session_id`).all()
|
|
704
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()
|
|
705
690
|
const groups = new Map()
|
|
706
691
|
for (const row of messageRows) {
|
|
@@ -711,7 +696,10 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
711
696
|
const records = rows.map((row) => {
|
|
712
697
|
const group = groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []
|
|
713
698
|
const record = mapRow(row)
|
|
714
|
-
|
|
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))
|
|
715
703
|
record.messagesDigest = messagesDigest(group)
|
|
716
704
|
return record
|
|
717
705
|
})
|
|
@@ -720,7 +708,7 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
720
708
|
}
|
|
721
709
|
|
|
722
710
|
function count() {
|
|
723
|
-
return Number(storage.prepare('SELECT COUNT(*) AS count FROM
|
|
711
|
+
return Number(storage.prepare('SELECT COUNT(*) AS count FROM sessions').get().count)
|
|
724
712
|
}
|
|
725
713
|
|
|
726
714
|
function digest() {
|
|
@@ -731,70 +719,68 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
731
719
|
// bodies are loaded; joins and aggregates run inside SQLite).
|
|
732
720
|
function sqlIntegrityCounts() {
|
|
733
721
|
return {
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
orphanIndex: Number(storage.prepare(`
|
|
740
|
-
SELECT COUNT(*) AS count FROM session_index i
|
|
741
|
-
LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
|
|
742
|
-
WHERE s.session_id IS NULL
|
|
743
|
-
`).get().count),
|
|
744
|
-
duplicateIds: Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count),
|
|
745
|
-
activeTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
|
|
746
|
-
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),
|
|
747
|
-
staleTombstones: Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
|
|
748
|
-
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),
|
|
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),
|
|
749
727
|
orphanMessages: Number(storage.prepare(`
|
|
750
728
|
SELECT COUNT(*) AS count FROM session_messages m
|
|
751
|
-
LEFT JOIN
|
|
729
|
+
LEFT JOIN sessions s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
|
|
752
730
|
WHERE s.session_id IS NULL
|
|
753
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),
|
|
754
739
|
}
|
|
755
740
|
}
|
|
756
741
|
|
|
757
|
-
function verifyIntegrity({ quickCheck = false } = {}) {
|
|
742
|
+
function verifyIntegrity({ quickCheck = false, forceQuickCheck = false } = {}) {
|
|
758
743
|
const counts = sqlIntegrityCounts()
|
|
744
|
+
const structuralOk = counts.duplicateIds === 0 && counts.activeTombstones === 0 && counts.staleTombstones === 0
|
|
745
|
+
&& counts.orphanMessages === 0 && counts.messageCountMismatches === 0
|
|
759
746
|
if (quickCheck) {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
//
|
|
765
|
-
//
|
|
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).
|
|
766
755
|
return {
|
|
767
|
-
ok:
|
|
768
|
-
|
|
769
|
-
count: Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count),
|
|
756
|
+
ok: structuralOk,
|
|
757
|
+
count: Number(storage.prepare('SELECT COUNT(*) AS count FROM sessions').get().count),
|
|
770
758
|
digest: null,
|
|
771
759
|
...counts,
|
|
772
760
|
invalidRecords: 0,
|
|
773
|
-
invalidDigests: 0,
|
|
774
|
-
invalidIndexDigests: 0,
|
|
775
761
|
invalidMessageDigests: 0,
|
|
776
762
|
invalidMessageRepresentations: 0,
|
|
777
763
|
lightweight: true,
|
|
778
764
|
}
|
|
779
765
|
}
|
|
780
|
-
const rows = storage.prepare(`SELECT ${
|
|
766
|
+
const rows = storage.prepare(`SELECT ${SESSION_ROW_COLUMNS} FROM sessions`).all()
|
|
781
767
|
let invalidRecords = 0
|
|
782
|
-
let invalidDigests = 0
|
|
783
768
|
let invalidMessageRepresentations = 0
|
|
784
769
|
for (const row of rows) {
|
|
785
770
|
try {
|
|
786
|
-
const
|
|
787
|
-
|
|
788
|
-
|
|
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.
|
|
789
777
|
const record = normalizeRecord(mapRow(row), row.updated_at)
|
|
790
|
-
if (record.stateJson !== row.
|
|
791
|
-
const parsedState = JSON.parse(row.state_json)
|
|
792
|
-
if (parsedState.messageStorage === 'split' && Array.isArray(parsedState.messages)) invalidMessageRepresentations += 1
|
|
778
|
+
if (record.stateJson !== row.body_json || record.metadataJson !== row.meta_json) invalidRecords += 1
|
|
793
779
|
} catch {
|
|
794
780
|
invalidRecords += 1
|
|
795
781
|
}
|
|
796
782
|
}
|
|
797
|
-
const messageRows = storage.prepare('SELECT
|
|
783
|
+
const messageRows = storage.prepare('SELECT message_json, message_digest FROM session_messages').all()
|
|
798
784
|
let invalidMessageDigests = 0
|
|
799
785
|
for (const row of messageRows) {
|
|
800
786
|
try {
|
|
@@ -803,80 +789,24 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
803
789
|
invalidMessageDigests += 1
|
|
804
790
|
}
|
|
805
791
|
}
|
|
806
|
-
const indexRows = storage.prepare('SELECT metadata_json, metadata_digest FROM session_index').all()
|
|
807
|
-
let invalidIndexDigests = 0
|
|
808
|
-
for (const row of indexRows) {
|
|
809
|
-
try {
|
|
810
|
-
if (jsonAndDigest(JSON.parse(row.metadata_json), 'metadata').digest !== row.metadata_digest) invalidIndexDigests += 1
|
|
811
|
-
} catch {
|
|
812
|
-
invalidIndexDigests += 1
|
|
813
|
-
}
|
|
814
|
-
}
|
|
815
|
-
const { missingIndex, orphanIndex, duplicateIds, activeTombstones, staleTombstones, orphanMessages } = counts
|
|
816
792
|
return {
|
|
817
|
-
ok:
|
|
818
|
-
&& invalidRecords === 0 && invalidDigests === 0 && invalidIndexDigests === 0
|
|
819
|
-
&& invalidMessageDigests === 0 && orphanMessages === 0 && invalidMessageRepresentations === 0,
|
|
793
|
+
ok: structuralOk && invalidRecords === 0 && invalidMessageDigests === 0 && invalidMessageRepresentations === 0,
|
|
820
794
|
count: rows.length,
|
|
821
795
|
digest: verificationDigest(digestRows(storage)),
|
|
822
|
-
|
|
823
|
-
orphanIndex,
|
|
824
|
-
duplicateIds,
|
|
825
|
-
activeTombstones,
|
|
826
|
-
staleTombstones,
|
|
796
|
+
...counts,
|
|
827
797
|
invalidRecords,
|
|
828
|
-
invalidDigests,
|
|
829
|
-
invalidIndexDigests,
|
|
830
798
|
invalidMessageDigests,
|
|
831
|
-
orphanMessages,
|
|
832
799
|
invalidMessageRepresentations,
|
|
833
800
|
}
|
|
834
801
|
}
|
|
835
802
|
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
return rows.length
|
|
844
|
-
}, { mode: 'immediate' })
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
function listMirrorQueue({ limit } = {}) {
|
|
848
|
-
if (limit !== undefined && (!Number.isInteger(limit) || limit < 1)) throw new TypeError('limit must be a positive integer')
|
|
849
|
-
// The queue rows carry full state_json payloads, so callers draining large
|
|
850
|
-
// queues page through with `limit` instead of loading every row at once.
|
|
851
|
-
const rows = storage.prepare(`SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id${limit !== undefined ? ' LIMIT ?' : ''}`)
|
|
852
|
-
.all(...(limit !== undefined ? [limit] : []))
|
|
853
|
-
return rows.map((row) => ({
|
|
854
|
-
scope: row.scope,
|
|
855
|
-
projectId: row.scope === 'project' ? row.project_id : null,
|
|
856
|
-
sessionId: row.session_id,
|
|
857
|
-
operation: row.operation,
|
|
858
|
-
revision: Number(row.revision),
|
|
859
|
-
state: row.state_json ? JSON.parse(row.state_json) : null,
|
|
860
|
-
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null,
|
|
861
|
-
attempts: Number(row.attempts),
|
|
862
|
-
lastError: row.last_error,
|
|
863
|
-
updatedAt: row.updated_at,
|
|
864
|
-
}))
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function countMirrorQueue() {
|
|
868
|
-
return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_json_mirror_queue').get().count)
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
function acknowledgeMirror(entry) {
|
|
872
|
-
return Number(storage.prepare('DELETE FROM session_json_mirror_queue WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?')
|
|
873
|
-
.run(entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision).changes) === 1
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function failMirror(entry, error) {
|
|
877
|
-
storage.prepare(`UPDATE session_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
878
|
-
WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?`)
|
|
879
|
-
.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()
|
|
880
810
|
}
|
|
881
811
|
|
|
882
812
|
return Object.freeze({
|
|
@@ -887,20 +817,40 @@ export function createSessionStateRepository(storageHandle, { now = () => new Da
|
|
|
887
817
|
appendMessages,
|
|
888
818
|
readMessagesPage,
|
|
889
819
|
messageCount,
|
|
820
|
+
readLastMessage,
|
|
821
|
+
readMessageDigestAt,
|
|
890
822
|
saveMany,
|
|
891
823
|
applyBatch,
|
|
892
824
|
delete: deleteRecord,
|
|
893
825
|
deleteBySessionId,
|
|
894
826
|
replaceAll,
|
|
895
|
-
replaceAllStream,
|
|
896
827
|
exportSnapshot,
|
|
897
828
|
verifyIntegrity,
|
|
898
829
|
count,
|
|
899
830
|
digest,
|
|
900
|
-
|
|
901
|
-
listMirrorQueue,
|
|
902
|
-
countMirrorQueue,
|
|
903
|
-
acknowledgeMirror,
|
|
904
|
-
failMirror,
|
|
831
|
+
checkpointWal,
|
|
905
832
|
})
|
|
906
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
|
+
}
|