@shawnstack/quickforge 1.7.7 → 1.7.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/AgentProfilesPage-BSM5w3bn.js +1 -0
- package/dist/assets/ChatPanelHost-cd80S4EX.js +48 -0
- package/dist/assets/CloudAccountSettingsPage-BJMrfgy9.js +1 -0
- package/dist/assets/{PluginsPage-CPE5tbbf.js → PluginsPage-mJkTmVVV.js} +1 -1
- package/dist/assets/ScheduledTasksPage-DJj_NLm9.js +2 -0
- package/dist/assets/{SettingsWorkspacePage-BE2T4d3P.js → SettingsWorkspacePage-7lQ0Wl63.js} +389 -389
- package/dist/assets/ShareLinksSettingsPage-DjFq_Lnb.js +1 -0
- package/dist/assets/SharedConversationPage-BbuNpcpP.js +1 -0
- package/dist/assets/TerminalDock-CczcCJJE.js +2 -0
- package/dist/assets/WorkspaceInspector-0i6x-diB.js +36 -0
- package/dist/assets/{icons-2D18YbSI.js → icons-DM_qJ7VJ.js} +1 -1
- package/dist/assets/{index-BiCTgUlY.css → index-D-J_8Smf.css} +1 -1
- package/dist/assets/index-DFASc15l.js +66 -0
- package/dist/assets/local-tools-Dn1Y9aPe.js +270 -0
- package/dist/assets/{model-display-label-C6-0eDxF.js → logger-8REuLPGK.js} +1 -1
- package/dist/assets/{mcp-servers-dialog-GY7sdfIr.js → mcp-servers-dialog-BG4b67ea.js} +2 -2
- package/dist/assets/{model-reference-o41fJ6jQ.js → model-display-label-CURwH0xM.js} +1 -1
- package/dist/assets/{monaco-DvXErxp2.js → monaco-bDr860xx.js} +1 -1
- package/dist/assets/{react-vendor-3UaA-C9W.js → react-vendor-BpWW_Nvp.js} +1 -1
- package/dist/assets/{skills-dialog-C1fJ1Jl7.js → skills-dialog-DIzZvQqW.js} +3 -3
- package/dist/index.html +8 -8
- package/package.json +1 -1
- package/server/acp/server.mjs +17 -1
- package/server/agent-manager.mjs +251 -31
- package/server/ai-http-logger.mjs +162 -19
- package/server/ai-provider-options.mjs +4 -1
- package/server/auto-archive.mjs +42 -29
- package/server/cloud/auto-approval.mjs +155 -0
- package/server/cloud/client.mjs +5 -0
- package/server/cloud/qf-agent-process.mjs +163 -3
- package/server/cloud/service-config.mjs +1 -1
- package/server/index.mjs +69 -13
- package/server/lan-access-backup.mjs +256 -0
- package/server/lan-access-cutover.mjs +306 -0
- package/server/lan-access-json-file.mjs +75 -0
- package/server/lan-access-service.mjs +155 -0
- package/server/lan-access-store.mjs +110 -9
- package/server/maintenance/downgrade-lan-access-v1.mjs +101 -0
- package/server/maintenance/downgrade-session-state-v1.mjs +130 -0
- package/server/maintenance/downgrade-share-v1.mjs +101 -0
- package/server/maintenance/export-lan-access-v1.mjs +80 -0
- package/server/maintenance/export-scheduled-runs-v1.mjs +45 -0
- package/server/maintenance/export-session-state-v1.mjs +67 -0
- package/server/maintenance/export-share-v1.mjs +77 -0
- package/server/routes/agent.mjs +37 -4
- package/server/routes/backup.mjs +221 -39
- package/server/routes/cloud.mjs +28 -1
- package/server/routes/scheduled-tasks.mjs +347 -134
- package/server/routes/shares.mjs +4 -2
- package/server/routes/storage.mjs +138 -8
- package/server/routes/workspace.mjs +265 -4
- package/server/scheduled-runs-backup.mjs +235 -0
- package/server/scheduled-runs-cutover.mjs +497 -0
- package/server/scheduled-task-runs-service.mjs +279 -0
- package/server/session-index-service.mjs +495 -0
- package/server/session-state-backup.mjs +277 -0
- package/server/session-state-cutover.mjs +370 -0
- package/server/session-state-service.mjs +709 -0
- package/server/share-backup.mjs +255 -0
- package/server/share-cutover.mjs +296 -0
- package/server/share-json-file.mjs +60 -0
- package/server/share-service.mjs +163 -0
- package/server/share-store.mjs +196 -19
- package/server/skills.mjs +0 -1
- package/server/sqlite/database.mjs +233 -0
- package/server/sqlite/lan-access-repository.mjs +652 -0
- package/server/sqlite/migrations.mjs +577 -0
- package/server/sqlite/scheduled-task-runs-repository.mjs +464 -0
- package/server/sqlite/session-index-repository.mjs +297 -0
- package/server/sqlite/session-state-repository.mjs +786 -0
- package/server/sqlite/share-repository.mjs +854 -0
- package/server/storage.mjs +317 -1
- package/dist/assets/AgentProfilesPage-BfA_sQ3A.js +0 -1
- package/dist/assets/ChatPanelHost-Dul1ahKj.js +0 -307
- package/dist/assets/CloudAccountSettingsPage-qZh0lTUN.js +0 -1
- package/dist/assets/ScheduledTasksPage-PTxL_PPg.js +0 -2
- package/dist/assets/ShareLinksSettingsPage-Bl8fP62X.js +0 -1
- package/dist/assets/SharedConversationPage-BK9XJnEe.js +0 -1
- package/dist/assets/TerminalDock-CL7HU9DI.js +0 -2
- package/dist/assets/WorkspaceInspector-MNBLIKMn.js +0 -36
- package/dist/assets/index-BTPzyq7d.js +0 -65
- package/dist/assets/mermaid-renderer-jEbqFaTe.js +0 -2
- /package/dist/assets/{plugin-api-Uu2xAB4d.js → plugin-api-BrdQeM4B.js} +0 -0
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { getSqliteStorage } from './database.mjs'
|
|
3
|
+
|
|
4
|
+
const STATE_COLUMNS = `
|
|
5
|
+
scope, project_id, session_id, revision, state_version, state_json, state_digest,
|
|
6
|
+
metadata_json, metadata_digest, created_at, updated_at
|
|
7
|
+
`
|
|
8
|
+
|
|
9
|
+
// F9 split-message representation: a session whose messages live in
|
|
10
|
+
// `session_messages` stores a body without the `messages` key plus this marker.
|
|
11
|
+
export const MESSAGES_SPLIT_VALUE = 'split'
|
|
12
|
+
export const MESSAGES_PAGE_LIMIT_MAX = 5000
|
|
13
|
+
|
|
14
|
+
function isPlainObject(value) {
|
|
15
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function canonicalize(value) {
|
|
19
|
+
if (Array.isArray(value)) return value.map(canonicalize)
|
|
20
|
+
if (!isPlainObject(value)) return value
|
|
21
|
+
return Object.fromEntries(Object.keys(value).sort().filter((key) => value[key] !== undefined).map((key) => [key, canonicalize(value[key])]))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function jsonAndDigest(value, field) {
|
|
25
|
+
if (!isPlainObject(value)) throw new TypeError(`${field} must be a plain object`)
|
|
26
|
+
let parsed
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(JSON.stringify(value))
|
|
29
|
+
} catch {
|
|
30
|
+
throw new TypeError(`${field} must be JSON-serializable`)
|
|
31
|
+
}
|
|
32
|
+
if (!isPlainObject(parsed)) throw new TypeError(`${field} must be a JSON object`)
|
|
33
|
+
const json = JSON.stringify(canonicalize(parsed))
|
|
34
|
+
return { value: parsed, json, digest: createHash('sha256').update(json).digest('hex') }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Canonical SHA-256 digest of a single message object (identical algorithm to
|
|
38
|
+
// the row-level `message_digest` written by the repository).
|
|
39
|
+
export function messageDigest(message) {
|
|
40
|
+
return jsonAndDigest(message, 'message').digest
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Digest over an ordered message array (seq = array index). Mirrors the
|
|
44
|
+
// per-session aggregate computed from stored rows, so assembled plan/backup
|
|
45
|
+
// values hash identically to their stored (split) representation.
|
|
46
|
+
export function messagesDigestFromValues(messages) {
|
|
47
|
+
if (!Array.isArray(messages) || messages.length === 0) return ''
|
|
48
|
+
const rows = messages.map((message, seq) => ({ seq, message_digest: messageDigest(message) }))
|
|
49
|
+
return messagesDigest(rows)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// sha256 over `${seq}\0${message_digest}` lines (rows must be ordered by seq).
|
|
53
|
+
function messagesDigest(rows) {
|
|
54
|
+
if (!rows || rows.length === 0) return ''
|
|
55
|
+
return createHash('sha256').update(rows.map((row) => `${row.seq}\0${row.message_digest}`).join('\n')).digest('hex')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Canonical digest line shared by the repository snapshot digest, the JSON
|
|
59
|
+
// cutover snapshot, restore-plan digest and offline tools. A session with no
|
|
60
|
+
// separate message rows contributes an empty `messagesDigest`.
|
|
61
|
+
export function snapshotDigestLine(scope, projectId, sessionId, stateDigest, metadataDigest, messagesDigestValue = '') {
|
|
62
|
+
return `${scope}\0${projectId || ''}\0${sessionId}\0${stateDigest}\0${metadataDigest}\0${messagesDigestValue || ''}`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Split a (possibly assembled) state into its stored body and message array.
|
|
66
|
+
// - `messageStorage: 'split'` bodies: messages are stripped out and returned.
|
|
67
|
+
// - inline `messages` bodies: kept whole (non-split) and return no messages.
|
|
68
|
+
// Never mutates the input and never forces a marker onto legacy bodies.
|
|
69
|
+
export function splitStateForStorage(state) {
|
|
70
|
+
const copy = structuredClone(state)
|
|
71
|
+
if (!isPlainObject(copy)) throw new TypeError('state must be a plain object')
|
|
72
|
+
if (copy.messageStorage === MESSAGES_SPLIT_VALUE) {
|
|
73
|
+
const messages = Array.isArray(copy.messages) ? copy.messages : []
|
|
74
|
+
delete copy.messages
|
|
75
|
+
return { storedState: copy, messages }
|
|
76
|
+
}
|
|
77
|
+
return { storedState: copy, messages: undefined }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function encodeMessage(message, timestamp) {
|
|
81
|
+
if (!isPlainObject(message)) throw new TypeError('message must be a plain object')
|
|
82
|
+
let parsed
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(JSON.stringify(message))
|
|
85
|
+
} catch {
|
|
86
|
+
throw new TypeError('message must be JSON-serializable')
|
|
87
|
+
}
|
|
88
|
+
if (!isPlainObject(parsed)) throw new TypeError('message must be a JSON object')
|
|
89
|
+
const json = JSON.stringify(canonicalize(parsed))
|
|
90
|
+
const messageId = typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id : null
|
|
91
|
+
return {
|
|
92
|
+
messageId,
|
|
93
|
+
messageJson: json,
|
|
94
|
+
messageDigest: createHash('sha256').update(json).digest('hex'),
|
|
95
|
+
created: timestamp,
|
|
96
|
+
updated: timestamp,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function encodeMessages(messages, timestamp) {
|
|
101
|
+
if (!Array.isArray(messages)) throw new TypeError('messages must be an array')
|
|
102
|
+
return messages.map((message) => encodeMessage(message, timestamp))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function nonEmptyString(value, field) {
|
|
106
|
+
if (typeof value !== 'string' || !value.trim()) throw new TypeError(`${field} must be a non-empty string`)
|
|
107
|
+
return value
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function bucket(scope, projectId) {
|
|
111
|
+
if (scope === 'global') return { scope, projectId: '' }
|
|
112
|
+
if (scope !== 'project') throw new TypeError('scope must be global or project')
|
|
113
|
+
return { scope, projectId: nonEmptyString(projectId, 'projectId') }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizeRevision(value, { optional = false } = {}) {
|
|
117
|
+
if (optional && (value === undefined || value === null)) return null
|
|
118
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError('expectedRevision must be a non-negative integer or null')
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeExpectedStateVersion(value) {
|
|
123
|
+
if (value === undefined || value === null) return null
|
|
124
|
+
if (!Number.isInteger(value) || value < 0) throw new TypeError('expectedStateVersion must be a non-negative integer or null')
|
|
125
|
+
return value
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeRecord(input, timestamp) {
|
|
129
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('session state record must be an object')
|
|
130
|
+
const state = structuredClone(input.state)
|
|
131
|
+
const metadata = structuredClone(input.metadata)
|
|
132
|
+
if (!isPlainObject(state) || !isPlainObject(metadata)) throw new TypeError('state and metadata must be plain objects')
|
|
133
|
+
const sessionId = nonEmptyString(input.sessionId ?? state.id ?? metadata.id, 'sessionId')
|
|
134
|
+
const normalizedBucket = bucket(input.scope ?? state.scope ?? metadata.scope, input.projectId ?? state.projectId ?? metadata.projectId)
|
|
135
|
+
const stateVersion = input.stateVersion ?? state.stateVersion ?? metadata.stateVersion ?? 0
|
|
136
|
+
if (!Number.isInteger(stateVersion) || stateVersion < 0) throw new TypeError('stateVersion must be a non-negative integer')
|
|
137
|
+
|
|
138
|
+
for (const [value, field] of [[state, 'state'], [metadata, 'metadata']]) {
|
|
139
|
+
if (value.id !== undefined && value.id !== sessionId) throw new TypeError(`${field}.id must match sessionId`)
|
|
140
|
+
if (value.scope !== undefined && value.scope !== normalizedBucket.scope) throw new TypeError(`${field}.scope must match scope`)
|
|
141
|
+
if (normalizedBucket.scope === 'project') {
|
|
142
|
+
if (value.projectId !== undefined && value.projectId !== normalizedBucket.projectId) throw new TypeError(`${field}.projectId must match projectId`)
|
|
143
|
+
} else if (value.projectId !== undefined && value.projectId !== null) {
|
|
144
|
+
throw new TypeError(`${field}.projectId is not allowed for global sessions`)
|
|
145
|
+
}
|
|
146
|
+
if (value.stateVersion !== undefined && value.stateVersion !== stateVersion) throw new TypeError(`${field}.stateVersion must match stateVersion`)
|
|
147
|
+
value.id = sessionId
|
|
148
|
+
value.scope = normalizedBucket.scope
|
|
149
|
+
value.stateVersion = stateVersion
|
|
150
|
+
if (normalizedBucket.scope === 'project') value.projectId = normalizedBucket.projectId
|
|
151
|
+
else delete value.projectId
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Optional split-message payload: when `input.messages` is provided the body
|
|
155
|
+
// is stored without `messages` and carries the split marker; the message rows
|
|
156
|
+
// are written in the same transaction by the caller-selected mode.
|
|
157
|
+
const messages = input.messages !== undefined ? encodeMessages(input.messages, timestamp) : undefined
|
|
158
|
+
if (messages !== undefined) {
|
|
159
|
+
delete state.messages
|
|
160
|
+
state.messageStorage = MESSAGES_SPLIT_VALUE
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const stateEncoded = jsonAndDigest(state, 'state')
|
|
164
|
+
const metadataEncoded = jsonAndDigest(metadata, 'metadata')
|
|
165
|
+
return {
|
|
166
|
+
...normalizedBucket,
|
|
167
|
+
sessionId,
|
|
168
|
+
stateVersion,
|
|
169
|
+
state: stateEncoded.value,
|
|
170
|
+
metadata: metadataEncoded.value,
|
|
171
|
+
stateJson: stateEncoded.json,
|
|
172
|
+
stateDigest: stateEncoded.digest,
|
|
173
|
+
metadataJson: metadataEncoded.json,
|
|
174
|
+
metadataDigest: metadataEncoded.digest,
|
|
175
|
+
messages,
|
|
176
|
+
now: timestamp,
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function conflict(sessionId, expectedRevision, actualRevision) {
|
|
181
|
+
const error = new Error(`Session state conflict for ${sessionId}`)
|
|
182
|
+
error.statusCode = 409
|
|
183
|
+
error.errorCode = 'SESSION_STATE_CONFLICT'
|
|
184
|
+
error.expectedRevision = expectedRevision
|
|
185
|
+
error.actualRevision = actualRevision
|
|
186
|
+
return error
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function stateVersionConflict(sessionId, expectedStateVersion, actualStateVersion) {
|
|
190
|
+
const error = new Error(`Session state version conflict for ${sessionId}`)
|
|
191
|
+
error.statusCode = 409
|
|
192
|
+
error.errorCode = 'SESSION_STATE_CONFLICT'
|
|
193
|
+
error.expectedStateVersion = expectedStateVersion
|
|
194
|
+
error.actualStateVersion = actualStateVersion
|
|
195
|
+
return error
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function duplicate(sessionId) {
|
|
199
|
+
const error = new Error(`Duplicate authoritative session id: ${sessionId}`)
|
|
200
|
+
error.statusCode = 409
|
|
201
|
+
error.errorCode = 'SESSION_STATE_DUPLICATE_ID'
|
|
202
|
+
return error
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function mapRow(row) {
|
|
206
|
+
if (!row) return null
|
|
207
|
+
return {
|
|
208
|
+
scope: row.scope,
|
|
209
|
+
projectId: row.scope === 'project' ? row.project_id : null,
|
|
210
|
+
sessionId: row.session_id,
|
|
211
|
+
revision: Number(row.revision),
|
|
212
|
+
stateVersion: Number(row.state_version),
|
|
213
|
+
state: JSON.parse(row.state_json),
|
|
214
|
+
stateDigest: row.state_digest,
|
|
215
|
+
metadata: JSON.parse(row.metadata_json),
|
|
216
|
+
metadataDigest: row.metadata_digest,
|
|
217
|
+
createdAt: row.created_at,
|
|
218
|
+
updatedAt: row.updated_at,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function nullableString(value) {
|
|
223
|
+
return typeof value === 'string' ? value : null
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function nullableInteger(value) {
|
|
227
|
+
return Number.isInteger(value) && value >= 0 ? value : null
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function upsertIndex(database, record) {
|
|
231
|
+
const metadata = record.metadata
|
|
232
|
+
database.prepare(`
|
|
233
|
+
INSERT INTO session_index (
|
|
234
|
+
scope, project_id, session_id, created_at, last_modified, message_count,
|
|
235
|
+
pinned_at, archived_at, is_pinned, is_archived, state_version,
|
|
236
|
+
metadata_json, metadata_digest, indexed_at
|
|
237
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
238
|
+
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
|
|
239
|
+
created_at = excluded.created_at,
|
|
240
|
+
last_modified = excluded.last_modified,
|
|
241
|
+
message_count = excluded.message_count,
|
|
242
|
+
pinned_at = excluded.pinned_at,
|
|
243
|
+
archived_at = excluded.archived_at,
|
|
244
|
+
is_pinned = excluded.is_pinned,
|
|
245
|
+
is_archived = excluded.is_archived,
|
|
246
|
+
state_version = excluded.state_version,
|
|
247
|
+
metadata_json = excluded.metadata_json,
|
|
248
|
+
metadata_digest = excluded.metadata_digest,
|
|
249
|
+
indexed_at = excluded.indexed_at
|
|
250
|
+
`).run(
|
|
251
|
+
record.scope, record.projectId, record.sessionId,
|
|
252
|
+
nullableString(metadata.createdAt), nullableString(metadata.lastModified), nullableInteger(metadata.messageCount),
|
|
253
|
+
nullableString(metadata.pinnedAt), nullableString(metadata.archivedAt), metadata.pinnedAt ? 1 : 0, metadata.archivedAt ? 1 : 0,
|
|
254
|
+
record.stateVersion, record.metadataJson, record.metadataDigest, record.now,
|
|
255
|
+
)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function enqueueMirror(database, record, operation, revision) {
|
|
259
|
+
database.prepare(`
|
|
260
|
+
INSERT INTO session_json_mirror_queue (
|
|
261
|
+
scope, project_id, session_id, operation, revision, state_json, metadata_json, attempts, last_error, updated_at
|
|
262
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, NULL, ?)
|
|
263
|
+
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
|
|
264
|
+
operation = excluded.operation,
|
|
265
|
+
revision = excluded.revision,
|
|
266
|
+
state_json = excluded.state_json,
|
|
267
|
+
metadata_json = excluded.metadata_json,
|
|
268
|
+
attempts = 0,
|
|
269
|
+
last_error = NULL,
|
|
270
|
+
updated_at = excluded.updated_at
|
|
271
|
+
`).run(
|
|
272
|
+
record.scope, record.projectId, record.sessionId, operation, revision,
|
|
273
|
+
operation === 'upsert' ? record.stateJson : null,
|
|
274
|
+
operation === 'upsert' ? record.metadataJson : null,
|
|
275
|
+
record.now,
|
|
276
|
+
)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function digestRows(database) {
|
|
280
|
+
const rows = database.prepare('SELECT scope, project_id, session_id, state_digest, metadata_digest FROM session_states').all()
|
|
281
|
+
const groups = new Map()
|
|
282
|
+
for (const row of database.prepare('SELECT scope, project_id, session_id, seq, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()) {
|
|
283
|
+
const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
|
|
284
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
285
|
+
groups.get(key).push(row)
|
|
286
|
+
}
|
|
287
|
+
return rows.map((row) => ({
|
|
288
|
+
scope: row.scope,
|
|
289
|
+
project_id: row.project_id,
|
|
290
|
+
session_id: row.session_id,
|
|
291
|
+
state_digest: row.state_digest,
|
|
292
|
+
metadata_digest: row.metadata_digest,
|
|
293
|
+
messages_digest: messagesDigest(groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []),
|
|
294
|
+
}))
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function verificationDigest(rows) {
|
|
298
|
+
const values = rows.map((row) => snapshotDigestLine(row.scope, row.project_id, row.session_id, row.state_digest, row.metadata_digest, row.messages_digest)).sort()
|
|
299
|
+
return createHash('sha256').update(values.join('\n')).digest('hex')
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function sessionStateSnapshotDigest(records) {
|
|
303
|
+
return verificationDigest(records.map((record) => ({
|
|
304
|
+
scope: record.scope,
|
|
305
|
+
project_id: record.projectId || '',
|
|
306
|
+
session_id: record.sessionId,
|
|
307
|
+
state_digest: record.stateDigest,
|
|
308
|
+
metadata_digest: record.metadataDigest,
|
|
309
|
+
messages_digest: typeof record.messagesDigest === 'string'
|
|
310
|
+
? record.messagesDigest
|
|
311
|
+
: (Array.isArray(record.messages) ? messagesDigestFromValues(record.messages) : ''),
|
|
312
|
+
})))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function actualRevision(database, record) {
|
|
316
|
+
const active = database.prepare('SELECT revision, state_version, created_at FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
317
|
+
.get(record.scope, record.projectId, record.sessionId)
|
|
318
|
+
if (active) return { revision: Number(active.revision), stateVersion: Number(active.state_version), createdAt: active.created_at, active: true }
|
|
319
|
+
const tombstone = database.prepare('SELECT revision FROM session_state_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
320
|
+
.get(record.scope, record.projectId, record.sessionId)
|
|
321
|
+
return { revision: Number(tombstone?.revision || 0), stateVersion: null, createdAt: null, active: false }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assertNoCrossBucketDuplicate(database, record) {
|
|
325
|
+
const other = database.prepare(`SELECT scope, project_id FROM session_states
|
|
326
|
+
WHERE session_id = ? AND NOT (scope = ? AND project_id = ?) LIMIT 1`)
|
|
327
|
+
.get(record.sessionId, record.scope, record.projectId)
|
|
328
|
+
if (other) throw duplicate(record.sessionId)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function writeMessages(database, record, mode, timestamp) {
|
|
332
|
+
if (record.messages === undefined) return
|
|
333
|
+
if (mode !== 'replace' && mode !== 'append') throw new TypeError('messagesMode must be replace or append')
|
|
334
|
+
const params = [record.scope, record.projectId, record.sessionId]
|
|
335
|
+
if (mode === 'replace') {
|
|
336
|
+
database.prepare('DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').run(...params)
|
|
337
|
+
const insert = database.prepare(`
|
|
338
|
+
INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
|
|
339
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
340
|
+
`)
|
|
341
|
+
record.messages.forEach((message, seq) => {
|
|
342
|
+
insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest, timestamp, timestamp)
|
|
343
|
+
})
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
const maxSeq = Number(database.prepare('SELECT COALESCE(MAX(seq), -1) AS max_seq FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?').get(...params).max_seq)
|
|
347
|
+
const existingIds = new Set(database.prepare('SELECT message_id FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ? AND message_id IS NOT NULL').all(...params).map((row) => row.message_id))
|
|
348
|
+
const insert = database.prepare(`
|
|
349
|
+
INSERT INTO session_messages (scope, project_id, session_id, seq, message_id, message_json, message_digest, created, updated)
|
|
350
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
351
|
+
`)
|
|
352
|
+
let seq = maxSeq + 1
|
|
353
|
+
for (const message of record.messages) {
|
|
354
|
+
if (message.messageId !== null && existingIds.has(message.messageId)) continue
|
|
355
|
+
insert.run(record.scope, record.projectId, record.sessionId, seq, message.messageId, message.messageJson, message.messageDigest, timestamp, timestamp)
|
|
356
|
+
if (message.messageId !== null) existingIds.add(message.messageId)
|
|
357
|
+
seq += 1
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function saveInTransaction(database, record, expectedRevision, expectedStateVersion = null, { messagesMode } = {}) {
|
|
362
|
+
assertNoCrossBucketDuplicate(database, record)
|
|
363
|
+
const current = actualRevision(database, record)
|
|
364
|
+
if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
|
|
365
|
+
if (expectedStateVersion !== null && current.stateVersion !== expectedStateVersion) throw stateVersionConflict(record.sessionId, expectedStateVersion, current.stateVersion)
|
|
366
|
+
const revision = current.revision + 1
|
|
367
|
+
database.prepare('DELETE FROM session_state_tombstones WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
368
|
+
.run(record.scope, record.projectId, record.sessionId)
|
|
369
|
+
if (record.messages !== undefined) writeMessages(database, record, messagesMode ?? 'replace', record.now)
|
|
370
|
+
database.prepare(`
|
|
371
|
+
INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
372
|
+
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET
|
|
373
|
+
revision = excluded.revision,
|
|
374
|
+
state_version = excluded.state_version,
|
|
375
|
+
state_json = excluded.state_json,
|
|
376
|
+
state_digest = excluded.state_digest,
|
|
377
|
+
metadata_json = excluded.metadata_json,
|
|
378
|
+
metadata_digest = excluded.metadata_digest,
|
|
379
|
+
updated_at = excluded.updated_at
|
|
380
|
+
`).run(
|
|
381
|
+
record.scope, record.projectId, record.sessionId, revision, record.stateVersion,
|
|
382
|
+
record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest,
|
|
383
|
+
current.createdAt || record.now, record.now,
|
|
384
|
+
)
|
|
385
|
+
upsertIndex(database, record)
|
|
386
|
+
enqueueMirror(database, record, 'upsert', revision)
|
|
387
|
+
return { ...record, revision, createdAt: current.createdAt || record.now, updatedAt: record.now }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function deleteInTransaction(database, input, expectedRevision, timestamp) {
|
|
391
|
+
const normalizedBucket = bucket(input.scope, input.projectId)
|
|
392
|
+
const record = { ...normalizedBucket, sessionId: nonEmptyString(input.sessionId, 'sessionId') }
|
|
393
|
+
const current = actualRevision(database, record)
|
|
394
|
+
if (expectedRevision !== null && current.revision !== expectedRevision) throw conflict(record.sessionId, expectedRevision, current.revision)
|
|
395
|
+
if (!current.active) return false
|
|
396
|
+
const deletionRevision = current.revision + 1
|
|
397
|
+
database.prepare('DELETE FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
398
|
+
.run(record.scope, record.projectId, record.sessionId)
|
|
399
|
+
database.prepare('DELETE FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
400
|
+
.run(record.scope, record.projectId, record.sessionId)
|
|
401
|
+
database.prepare('DELETE FROM session_index WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
402
|
+
.run(record.scope, record.projectId, record.sessionId)
|
|
403
|
+
database.prepare(`INSERT INTO session_state_tombstones (scope, project_id, session_id, revision, deleted_at)
|
|
404
|
+
VALUES (?, ?, ?, ?, ?)
|
|
405
|
+
ON CONFLICT(scope, project_id, session_id) DO UPDATE SET revision = excluded.revision, deleted_at = excluded.deleted_at`)
|
|
406
|
+
.run(record.scope, record.projectId, record.sessionId, deletionRevision, timestamp)
|
|
407
|
+
enqueueMirror(database, { ...record, stateJson: null, metadataJson: null, now: timestamp }, 'delete', deletionRevision)
|
|
408
|
+
return true
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function updateStorageState(database, values) {
|
|
412
|
+
if (!values) return
|
|
413
|
+
database.prepare(`UPDATE session_storage_state SET phase = ?, state_count = ?, digest = ?, backup_file = ?, diagnostic_json = ?, updated_at = ?
|
|
414
|
+
WHERE singleton = 1`).run(
|
|
415
|
+
values.phase,
|
|
416
|
+
values.stateCount ?? null,
|
|
417
|
+
values.digest ?? null,
|
|
418
|
+
values.backupFile ?? null,
|
|
419
|
+
values.diagnostic ? JSON.stringify(values.diagnostic) : null,
|
|
420
|
+
values.updatedAt,
|
|
421
|
+
)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
export function createSessionStateRepository(storageHandle, { now = () => new Date().toISOString() } = {}) {
|
|
425
|
+
const storage = storageHandle ?? getSqliteStorage()
|
|
426
|
+
if (!storage || typeof storage.prepare !== 'function' || typeof storage.transaction !== 'function') {
|
|
427
|
+
throw new TypeError('Session state repository requires a SQLite storage handle')
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function get(scope, projectId, sessionId) {
|
|
431
|
+
const normalizedBucket = bucket(scope, projectId)
|
|
432
|
+
nonEmptyString(sessionId, 'sessionId')
|
|
433
|
+
return mapRow(storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?`)
|
|
434
|
+
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function findBySessionId(sessionId) {
|
|
438
|
+
nonEmptyString(sessionId, 'sessionId')
|
|
439
|
+
const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states WHERE session_id = ? ORDER BY scope, project_id`).all(sessionId)
|
|
440
|
+
if (rows.length > 1) throw duplicate(sessionId)
|
|
441
|
+
return mapRow(rows[0])
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function save(input, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
|
|
445
|
+
const expected = normalizeRevision(expectedRevision, { optional: true })
|
|
446
|
+
const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
|
|
447
|
+
const record = normalizeRecord(input, now())
|
|
448
|
+
return storage.transaction((database) => {
|
|
449
|
+
const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: record.messages !== undefined ? 'replace' : undefined })
|
|
450
|
+
beforeCommit?.(database)
|
|
451
|
+
return result
|
|
452
|
+
}, { mode: 'immediate' })
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function replaceMessages(input, messages, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
|
|
456
|
+
const expected = normalizeRevision(expectedRevision, { optional: true })
|
|
457
|
+
const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
|
|
458
|
+
const record = normalizeRecord({ ...input, messages }, now())
|
|
459
|
+
return storage.transaction((database) => {
|
|
460
|
+
const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'replace' })
|
|
461
|
+
beforeCommit?.(database)
|
|
462
|
+
return result
|
|
463
|
+
}, { mode: 'immediate' })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function appendMessages(input, messages, { expectedRevision = null, expectedStateVersion = null, beforeCommit } = {}) {
|
|
467
|
+
const expected = normalizeRevision(expectedRevision, { optional: true })
|
|
468
|
+
const expectedVersion = normalizeExpectedStateVersion(expectedStateVersion)
|
|
469
|
+
const record = normalizeRecord({ ...input, messages }, now())
|
|
470
|
+
return storage.transaction((database) => {
|
|
471
|
+
const current = actualRevision(database, record)
|
|
472
|
+
if (current.active) {
|
|
473
|
+
const stored = database.prepare('SELECT state_json FROM session_states WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
474
|
+
.get(record.scope, record.projectId, record.sessionId)
|
|
475
|
+
const parsed = JSON.parse(stored.state_json)
|
|
476
|
+
if (Array.isArray(parsed.messages)) throw new TypeError('Session is not split; use replaceMessages to split it first')
|
|
477
|
+
}
|
|
478
|
+
const result = saveInTransaction(database, record, expected, expectedVersion, { messagesMode: 'append' })
|
|
479
|
+
beforeCommit?.(database)
|
|
480
|
+
return result
|
|
481
|
+
}, { mode: 'immediate' })
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function readMessagesPage({ scope, projectId, sessionId, limit = 100, offset = 0, afterSeq = null } = {}) {
|
|
485
|
+
const normalizedBucket = bucket(scope, projectId)
|
|
486
|
+
nonEmptyString(sessionId, 'sessionId')
|
|
487
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > MESSAGES_PAGE_LIMIT_MAX) throw new TypeError('limit must be an integer between 1 and 5000')
|
|
488
|
+
if (!Number.isInteger(offset) || offset < 0) throw new TypeError('offset must be a non-negative integer')
|
|
489
|
+
if (afterSeq !== null && (!Number.isInteger(afterSeq) || afterSeq < 0)) throw new TypeError('afterSeq must be a non-negative integer or null')
|
|
490
|
+
const where = ['scope = ?', 'project_id = ?', 'session_id = ?']
|
|
491
|
+
const params = [normalizedBucket.scope, normalizedBucket.projectId, sessionId]
|
|
492
|
+
let limitParams = [limit]
|
|
493
|
+
if (afterSeq !== null) {
|
|
494
|
+
where.push('seq > ?')
|
|
495
|
+
params.push(afterSeq)
|
|
496
|
+
} else if (offset > 0) {
|
|
497
|
+
limitParams = [limit, offset]
|
|
498
|
+
}
|
|
499
|
+
const rows = storage.prepare(`SELECT seq, message_id, message_json, message_digest FROM session_messages
|
|
500
|
+
WHERE ${where.join(' AND ')} ORDER BY seq ASC LIMIT ?${offset > 0 && afterSeq === null ? ' OFFSET ?' : ''}`)
|
|
501
|
+
.all(...params, ...limitParams)
|
|
502
|
+
const total = Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
503
|
+
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
|
|
504
|
+
const lastSeq = rows.length > 0 ? Number(rows[rows.length - 1].seq) : null
|
|
505
|
+
return {
|
|
506
|
+
messages: rows.map((row) => ({ seq: Number(row.seq), message: JSON.parse(row.message_json), digest: row.message_digest })),
|
|
507
|
+
total,
|
|
508
|
+
limit,
|
|
509
|
+
offset,
|
|
510
|
+
hasMore: afterSeq !== null ? rows.length === limit : offset + rows.length < total,
|
|
511
|
+
nextOffset: offset + rows.length,
|
|
512
|
+
lastSeq,
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function messageCount({ scope, projectId, sessionId }) {
|
|
517
|
+
const normalizedBucket = bucket(scope, projectId)
|
|
518
|
+
nonEmptyString(sessionId, 'sessionId')
|
|
519
|
+
return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_messages WHERE scope = ? AND project_id = ? AND session_id = ?')
|
|
520
|
+
.get(normalizedBucket.scope, normalizedBucket.projectId, sessionId).count)
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function deleteRecord({ scope, projectId, sessionId, expectedRevision = null, beforeCommit } = {}) {
|
|
524
|
+
const expected = normalizeRevision(expectedRevision, { optional: true })
|
|
525
|
+
const timestamp = now()
|
|
526
|
+
return storage.transaction((database) => {
|
|
527
|
+
const result = deleteInTransaction(database, { scope, projectId, sessionId }, expected, timestamp)
|
|
528
|
+
beforeCommit?.(database)
|
|
529
|
+
return result
|
|
530
|
+
}, { mode: 'immediate' })
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function deleteBySessionId(sessionId, options = {}) {
|
|
534
|
+
const existing = findBySessionId(sessionId)
|
|
535
|
+
if (!existing) {
|
|
536
|
+
if (options.expectedRevision !== undefined && options.expectedRevision !== null) throw conflict(sessionId, options.expectedRevision, 0)
|
|
537
|
+
return false
|
|
538
|
+
}
|
|
539
|
+
return deleteRecord({ ...existing, ...options })
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function applyBatch({ upserts = [], deletes = [] } = {}, { beforeCommit } = {}) {
|
|
543
|
+
if (!Array.isArray(upserts) || !Array.isArray(deletes) || upserts.length + deletes.length === 0) throw new TypeError('Session batch changes are required')
|
|
544
|
+
const timestamp = now()
|
|
545
|
+
const normalizedUpserts = upserts.map((entry) => {
|
|
546
|
+
const input = entry.record ?? entry
|
|
547
|
+
const messages = entry.messages !== undefined ? entry.messages : input.messages
|
|
548
|
+
const record = normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
|
|
549
|
+
return {
|
|
550
|
+
record,
|
|
551
|
+
expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
|
|
552
|
+
expectedStateVersion: normalizeExpectedStateVersion(entry.expectedStateVersion),
|
|
553
|
+
messagesMode: entry.messagesMode ?? (record.messages !== undefined ? 'replace' : undefined),
|
|
554
|
+
}
|
|
555
|
+
})
|
|
556
|
+
const normalizedDeletes = deletes.map((entry) => ({
|
|
557
|
+
input: entry,
|
|
558
|
+
expectedRevision: normalizeRevision(entry.expectedRevision, { optional: true }),
|
|
559
|
+
}))
|
|
560
|
+
const keys = new Set()
|
|
561
|
+
for (const entry of [...normalizedUpserts.map(({ record }) => record), ...normalizedDeletes.map(({ input }) => ({ ...bucket(input.scope, input.projectId), sessionId: input.sessionId }))]) {
|
|
562
|
+
const key = JSON.stringify([entry.scope, entry.projectId || '', entry.sessionId])
|
|
563
|
+
if (keys.has(key)) throw new TypeError(`Duplicate session batch key: ${entry.sessionId}`)
|
|
564
|
+
keys.add(key)
|
|
565
|
+
}
|
|
566
|
+
return storage.transaction((database) => {
|
|
567
|
+
const saved = normalizedUpserts.map(({ record, expectedRevision, expectedStateVersion, messagesMode }) => saveInTransaction(database, record, expectedRevision, expectedStateVersion, { messagesMode }))
|
|
568
|
+
const deleted = normalizedDeletes.map(({ input, expectedRevision }) => deleteInTransaction(database, input, expectedRevision, timestamp))
|
|
569
|
+
beforeCommit?.(database)
|
|
570
|
+
return { saved, deleted }
|
|
571
|
+
}, { mode: 'immediate' })
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function saveMany(inputs, options = {}) {
|
|
575
|
+
if (!Array.isArray(inputs) || inputs.length === 0) throw new TypeError('records must be a non-empty array')
|
|
576
|
+
return applyBatch({
|
|
577
|
+
upserts: inputs.map((input) => ({
|
|
578
|
+
record: input.record ?? input,
|
|
579
|
+
expectedRevision: input.expectedRevision,
|
|
580
|
+
expectedStateVersion: input.expectedStateVersion,
|
|
581
|
+
})),
|
|
582
|
+
}, options).saved
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function replaceAll(inputs, { beforeCommit, storageState, expectedCount, expectedDigest } = {}) {
|
|
586
|
+
if (!Array.isArray(inputs)) throw new TypeError('records must be an array')
|
|
587
|
+
const timestamp = now()
|
|
588
|
+
const records = inputs.map((input) => {
|
|
589
|
+
let messages = input.messages
|
|
590
|
+
if (messages === undefined && input.state?.messageStorage === 'split') {
|
|
591
|
+
messages = Array.isArray(input.state.messages) ? input.state.messages : []
|
|
592
|
+
}
|
|
593
|
+
return normalizeRecord(messages === undefined ? input : { ...input, messages }, timestamp)
|
|
594
|
+
})
|
|
595
|
+
const ids = new Set()
|
|
596
|
+
for (const record of records) {
|
|
597
|
+
if (ids.has(record.sessionId)) throw new TypeError(`Duplicate session id: ${record.sessionId}`)
|
|
598
|
+
ids.add(record.sessionId)
|
|
599
|
+
}
|
|
600
|
+
return storage.transaction((database) => {
|
|
601
|
+
database.exec('DELETE FROM session_states; DELETE FROM session_state_tombstones; DELETE FROM session_index; DELETE FROM session_json_mirror_queue; DELETE FROM session_messages;')
|
|
602
|
+
for (const record of records) {
|
|
603
|
+
database.prepare(`INSERT INTO session_states (${STATE_COLUMNS}) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?)`)
|
|
604
|
+
.run(record.scope, record.projectId, record.sessionId, record.stateVersion, record.stateJson, record.stateDigest, record.metadataJson, record.metadataDigest, timestamp, timestamp)
|
|
605
|
+
if (record.messages !== undefined) writeMessages(database, record, 'replace', timestamp)
|
|
606
|
+
upsertIndex(database, record)
|
|
607
|
+
enqueueMirror(database, record, 'upsert', 1)
|
|
608
|
+
}
|
|
609
|
+
const digest = verificationDigest(digestRows(database))
|
|
610
|
+
if (expectedCount !== undefined && records.length !== expectedCount) throw new Error('Session state replace count verification failed')
|
|
611
|
+
if (expectedDigest !== undefined && digest !== expectedDigest) throw new Error('Session state replace digest verification failed')
|
|
612
|
+
updateStorageState(database, storageState ? { ...storageState, stateCount: records.length, digest, updatedAt: timestamp } : null)
|
|
613
|
+
beforeCommit?.(database)
|
|
614
|
+
return records.length
|
|
615
|
+
}, { mode: 'immediate' })
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function exportSnapshot() {
|
|
619
|
+
return storage.transaction((database) => {
|
|
620
|
+
const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
|
|
621
|
+
const messageRows = database.prepare('SELECT scope, project_id, session_id, seq, message_json, message_digest FROM session_messages ORDER BY scope, project_id, session_id, seq').all()
|
|
622
|
+
const groups = new Map()
|
|
623
|
+
for (const row of messageRows) {
|
|
624
|
+
const key = `${row.scope}\0${row.project_id}\0${row.session_id}`
|
|
625
|
+
if (!groups.has(key)) groups.set(key, [])
|
|
626
|
+
groups.get(key).push(row)
|
|
627
|
+
}
|
|
628
|
+
const records = rows.map((row) => {
|
|
629
|
+
const group = groups.get(`${row.scope}\0${row.project_id}\0${row.session_id}`) || []
|
|
630
|
+
const record = mapRow(row)
|
|
631
|
+
if (group.length > 0) record.messages = group.map((messageRow) => JSON.parse(messageRow.message_json))
|
|
632
|
+
record.messagesDigest = messagesDigest(group)
|
|
633
|
+
return record
|
|
634
|
+
})
|
|
635
|
+
return { records, count: records.length, digest: verificationDigest(digestRows(database)) }
|
|
636
|
+
}, { mode: 'deferred' })
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function count() {
|
|
640
|
+
return Number(storage.prepare('SELECT COUNT(*) AS count FROM session_states').get().count)
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function digest() {
|
|
644
|
+
return verificationDigest(digestRows(storage))
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function verifyIntegrity({ quickCheck = false } = {}) {
|
|
648
|
+
if (quickCheck) {
|
|
649
|
+
const result = storage.prepare('PRAGMA quick_check').all().map((row) => row.quick_check)
|
|
650
|
+
if (result.length !== 1 || result[0] !== 'ok') throw new Error(`SQLite quick_check failed: ${result.join(', ')}`)
|
|
651
|
+
}
|
|
652
|
+
const rows = storage.prepare(`SELECT ${STATE_COLUMNS} FROM session_states`).all()
|
|
653
|
+
let invalidRecords = 0
|
|
654
|
+
let invalidDigests = 0
|
|
655
|
+
let invalidMessageRepresentations = 0
|
|
656
|
+
for (const row of rows) {
|
|
657
|
+
try {
|
|
658
|
+
const stateEncoded = jsonAndDigest(JSON.parse(row.state_json), 'state')
|
|
659
|
+
const metadataEncoded = jsonAndDigest(JSON.parse(row.metadata_json), 'metadata')
|
|
660
|
+
if (stateEncoded.digest !== row.state_digest || metadataEncoded.digest !== row.metadata_digest) invalidDigests += 1
|
|
661
|
+
const record = normalizeRecord(mapRow(row), row.updated_at)
|
|
662
|
+
if (record.stateJson !== row.state_json || record.metadataJson !== row.metadata_json) invalidRecords += 1
|
|
663
|
+
const parsedState = JSON.parse(row.state_json)
|
|
664
|
+
if (parsedState.messageStorage === 'split' && Array.isArray(parsedState.messages)) invalidMessageRepresentations += 1
|
|
665
|
+
} catch {
|
|
666
|
+
invalidRecords += 1
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
const messageRows = storage.prepare('SELECT * FROM session_messages').all()
|
|
670
|
+
let invalidMessageDigests = 0
|
|
671
|
+
for (const row of messageRows) {
|
|
672
|
+
try {
|
|
673
|
+
if (jsonAndDigest(JSON.parse(row.message_json), 'message').digest !== row.message_digest) invalidMessageDigests += 1
|
|
674
|
+
} catch {
|
|
675
|
+
invalidMessageDigests += 1
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const orphanMessages = Number(storage.prepare(`
|
|
679
|
+
SELECT COUNT(*) AS count FROM session_messages m
|
|
680
|
+
LEFT JOIN session_states s ON s.scope = m.scope AND s.project_id = m.project_id AND s.session_id = m.session_id
|
|
681
|
+
WHERE s.session_id IS NULL
|
|
682
|
+
`).get().count)
|
|
683
|
+
const indexRows = storage.prepare('SELECT metadata_json, metadata_digest FROM session_index').all()
|
|
684
|
+
let invalidIndexDigests = 0
|
|
685
|
+
for (const row of indexRows) {
|
|
686
|
+
try {
|
|
687
|
+
if (jsonAndDigest(JSON.parse(row.metadata_json), 'metadata').digest !== row.metadata_digest) invalidIndexDigests += 1
|
|
688
|
+
} catch {
|
|
689
|
+
invalidIndexDigests += 1
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const missingIndex = Number(storage.prepare(`
|
|
693
|
+
SELECT COUNT(*) AS count FROM session_states s
|
|
694
|
+
LEFT JOIN session_index i ON i.scope = s.scope AND i.project_id = s.project_id AND i.session_id = s.session_id
|
|
695
|
+
WHERE i.session_id IS NULL OR i.metadata_digest <> s.metadata_digest OR i.state_version <> s.state_version
|
|
696
|
+
`).get().count)
|
|
697
|
+
const orphanIndex = Number(storage.prepare(`
|
|
698
|
+
SELECT COUNT(*) AS count FROM session_index i
|
|
699
|
+
LEFT JOIN session_states s ON s.scope = i.scope AND s.project_id = i.project_id AND s.session_id = i.session_id
|
|
700
|
+
WHERE s.session_id IS NULL
|
|
701
|
+
`).get().count)
|
|
702
|
+
const duplicateIds = Number(storage.prepare('SELECT COUNT(*) AS count FROM (SELECT session_id FROM session_states GROUP BY session_id HAVING COUNT(*) > 1)').get().count)
|
|
703
|
+
const activeTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
|
|
704
|
+
JOIN session_states s ON s.scope = t.scope AND s.project_id = t.project_id AND s.session_id = t.session_id`).get().count)
|
|
705
|
+
const staleTombstones = Number(storage.prepare(`SELECT COUNT(*) AS count FROM session_state_tombstones t
|
|
706
|
+
JOIN session_states s ON s.session_id = t.session_id AND NOT (s.scope = t.scope AND s.project_id = t.project_id)`).get().count)
|
|
707
|
+
return {
|
|
708
|
+
ok: missingIndex === 0 && orphanIndex === 0 && duplicateIds === 0 && activeTombstones === 0 && staleTombstones === 0
|
|
709
|
+
&& invalidRecords === 0 && invalidDigests === 0 && invalidIndexDigests === 0
|
|
710
|
+
&& invalidMessageDigests === 0 && orphanMessages === 0 && invalidMessageRepresentations === 0,
|
|
711
|
+
count: rows.length,
|
|
712
|
+
digest: verificationDigest(digestRows(storage)),
|
|
713
|
+
missingIndex,
|
|
714
|
+
orphanIndex,
|
|
715
|
+
duplicateIds,
|
|
716
|
+
activeTombstones,
|
|
717
|
+
staleTombstones,
|
|
718
|
+
invalidRecords,
|
|
719
|
+
invalidDigests,
|
|
720
|
+
invalidIndexDigests,
|
|
721
|
+
invalidMessageDigests,
|
|
722
|
+
orphanMessages,
|
|
723
|
+
invalidMessageRepresentations,
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function rebuildIndex() {
|
|
728
|
+
return storage.transaction((database) => {
|
|
729
|
+
database.exec('DELETE FROM session_index')
|
|
730
|
+
const rows = database.prepare(`SELECT ${STATE_COLUMNS} FROM session_states ORDER BY scope, project_id, session_id`).all()
|
|
731
|
+
for (const row of rows) upsertIndex(database, normalizeRecord(mapRow(row), row.updated_at))
|
|
732
|
+
const indexCount = Number(database.prepare('SELECT COUNT(*) AS count FROM session_index').get().count)
|
|
733
|
+
if (indexCount !== rows.length) throw new Error('Session index rebuild verification failed')
|
|
734
|
+
return rows.length
|
|
735
|
+
}, { mode: 'immediate' })
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function listMirrorQueue() {
|
|
739
|
+
return storage.prepare('SELECT * FROM session_json_mirror_queue ORDER BY updated_at, scope, project_id, session_id').all().map((row) => ({
|
|
740
|
+
scope: row.scope,
|
|
741
|
+
projectId: row.scope === 'project' ? row.project_id : null,
|
|
742
|
+
sessionId: row.session_id,
|
|
743
|
+
operation: row.operation,
|
|
744
|
+
revision: Number(row.revision),
|
|
745
|
+
state: row.state_json ? JSON.parse(row.state_json) : null,
|
|
746
|
+
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : null,
|
|
747
|
+
attempts: Number(row.attempts),
|
|
748
|
+
lastError: row.last_error,
|
|
749
|
+
updatedAt: row.updated_at,
|
|
750
|
+
}))
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function acknowledgeMirror(entry) {
|
|
754
|
+
return Number(storage.prepare('DELETE FROM session_json_mirror_queue WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?')
|
|
755
|
+
.run(entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision).changes) === 1
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function failMirror(entry, error) {
|
|
759
|
+
storage.prepare(`UPDATE session_json_mirror_queue SET attempts = attempts + 1, last_error = ?, updated_at = ?
|
|
760
|
+
WHERE scope = ? AND project_id = ? AND session_id = ? AND revision = ?`)
|
|
761
|
+
.run(String(error?.message || error).slice(0, 1000), now(), entry.scope, entry.scope === 'project' ? entry.projectId : '', entry.sessionId, entry.revision)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return Object.freeze({
|
|
765
|
+
get,
|
|
766
|
+
findBySessionId,
|
|
767
|
+
save,
|
|
768
|
+
replaceMessages,
|
|
769
|
+
appendMessages,
|
|
770
|
+
readMessagesPage,
|
|
771
|
+
messageCount,
|
|
772
|
+
saveMany,
|
|
773
|
+
applyBatch,
|
|
774
|
+
delete: deleteRecord,
|
|
775
|
+
deleteBySessionId,
|
|
776
|
+
replaceAll,
|
|
777
|
+
exportSnapshot,
|
|
778
|
+
verifyIntegrity,
|
|
779
|
+
count,
|
|
780
|
+
digest,
|
|
781
|
+
rebuildIndex,
|
|
782
|
+
listMirrorQueue,
|
|
783
|
+
acknowledgeMirror,
|
|
784
|
+
failMirror,
|
|
785
|
+
})
|
|
786
|
+
}
|