@wangmo-bit/ds-link 0.0.0-stage → 0.8.18
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/LICENSE +21 -0
- package/README.md +134 -2
- package/TESTING.md +59 -0
- package/THIRD_PARTY_NOTICES.md +313 -0
- package/assets/icon.svg +7 -0
- package/cordis.patch.yml +3 -0
- package/dist/client.js +6012 -0
- package/package.json +102 -4
- package/src/core/constants.js +188 -0
- package/src/core/downgrade.js +80 -0
- package/src/core/engine.js +4669 -0
- package/src/core/errors.js +51 -0
- package/src/core/ledger.js +1174 -0
- package/src/core/migration.js +741 -0
- package/src/core/render.js +61 -0
- package/src/core/settings.js +147 -0
- package/src/core/validation.js +454 -0
- package/src/host/adapter.js +1152 -0
- package/src/host/connection.js +113 -0
- package/src/host/entry-0.2.0.js +13 -0
- package/src/host/entry-0.3.0.js +13 -0
- package/src/host/entry-0.4.0.js +13 -0
- package/src/host/entry-0.8.10.js +13 -0
- package/src/host/entry-0.8.11.js +13 -0
- package/src/host/entry-0.8.12.js +13 -0
- package/src/host/entry-0.8.13.js +13 -0
- package/src/host/entry-0.8.14.js +13 -0
- package/src/host/entry-0.8.15.js +13 -0
- package/src/host/entry-0.8.16-pre.1.js +13 -0
- package/src/host/entry-0.8.16-pre.2.js +13 -0
- package/src/host/entry-0.8.16-pre.3.js +13 -0
- package/src/host/entry-0.8.16-pre.4.js +13 -0
- package/src/host/entry-0.8.16-pre.5.js +13 -0
- package/src/host/entry-0.8.18.js +13 -0
- package/src/host/entry-0.8.5.js +13 -0
- package/src/host/entry-0.8.6.js +13 -0
- package/src/host/entry-0.8.7.js +13 -0
- package/src/host/entry-0.8.8.js +13 -0
- package/src/host/entry-0.8.9.js +13 -0
- package/src/host/index.js +74 -0
- package/src/host/plugin.js +403 -0
- package/src/host/prompt.js +22 -0
- package/src/host/tools.js +312 -0
|
@@ -0,0 +1,1174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Private, atomic, single-writer v0.2 ledger.
|
|
3
|
+
*
|
|
4
|
+
* - directory 0700, files 0600, same-directory temp + fsync + rename + dir fsync;
|
|
5
|
+
* - one OS-backed SQLite EXCLUSIVE writer claim for the life of the ledger;
|
|
6
|
+
* - v1 ledgers are validated with the frozen v0.1 rules, backed up verbatim,
|
|
7
|
+
* then converted and atomically persisted as v2;
|
|
8
|
+
* - a persist failure poisons the ledger: reads may show the last confirmed
|
|
9
|
+
* memory, but no mutation is accepted until a safe reopen;
|
|
10
|
+
* - unknown versions and malformed records fail closed and are never rewritten.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomUUID } from 'node:crypto'
|
|
14
|
+
import fs from 'node:fs/promises'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite'
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
CHAIN_STATES,
|
|
21
|
+
CREATE_PHASES,
|
|
22
|
+
CREATE_RENAME_STATUSES,
|
|
23
|
+
DISPATCH_STATUSES,
|
|
24
|
+
LEDGER_FILE,
|
|
25
|
+
LOCK_DB_FILE,
|
|
26
|
+
LOCK_FILE,
|
|
27
|
+
MESSAGE_KINDS,
|
|
28
|
+
REPLY_STATUSES,
|
|
29
|
+
STATE_VERSION,
|
|
30
|
+
LIMITS,
|
|
31
|
+
V1_BACKUP_PREFIX,
|
|
32
|
+
V2_BACKUP_PREFIX,
|
|
33
|
+
V2_ROLLBACK_BACKUP_PREFIX,
|
|
34
|
+
V3_ROLLBACK_BACKUP_PREFIX,
|
|
35
|
+
} from './constants.js'
|
|
36
|
+
import { fail } from './errors.js'
|
|
37
|
+
import { createDefaultSettings, validateStoredSettings } from './settings.js'
|
|
38
|
+
import { isPlainObject } from './validation.js'
|
|
39
|
+
import {
|
|
40
|
+
migrateV1State,
|
|
41
|
+
migrateV2State,
|
|
42
|
+
rollbackPreflight,
|
|
43
|
+
sha256,
|
|
44
|
+
validateMigrationMarker,
|
|
45
|
+
validateUpgradeMarker,
|
|
46
|
+
validateV1State,
|
|
47
|
+
v3ToV2Preflight,
|
|
48
|
+
V2_STATE_VERSION,
|
|
49
|
+
} from './migration.js'
|
|
50
|
+
|
|
51
|
+
export const SKIP = Symbol('session-link.skip-mutation')
|
|
52
|
+
|
|
53
|
+
/** @template T @param {T} value @returns {{ [SKIP]: true, value: T }} */
|
|
54
|
+
export function skipMutation(value) {
|
|
55
|
+
return { [SKIP]: true, value }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class SerialQueue {
|
|
59
|
+
#tail = Promise.resolve()
|
|
60
|
+
|
|
61
|
+
/** @template T @param {() => Promise<T> | T} task @returns {Promise<T>} */
|
|
62
|
+
run(task) {
|
|
63
|
+
const result = this.#tail.then(task, task)
|
|
64
|
+
this.#tail = result.then(
|
|
65
|
+
() => undefined,
|
|
66
|
+
() => undefined,
|
|
67
|
+
)
|
|
68
|
+
return result
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async drain() {
|
|
72
|
+
await this.#tail
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @returns {Record<string, any>} */
|
|
77
|
+
function initialState(now) {
|
|
78
|
+
return {
|
|
79
|
+
version: STATE_VERSION,
|
|
80
|
+
revision: 1,
|
|
81
|
+
settingsRevision: 1,
|
|
82
|
+
createdAt: now,
|
|
83
|
+
updatedAt: now,
|
|
84
|
+
settings: createDefaultSettings(),
|
|
85
|
+
messages: [],
|
|
86
|
+
chains: {},
|
|
87
|
+
operations: {},
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** @param {string} message */
|
|
92
|
+
function corrupt(message) {
|
|
93
|
+
return fail('storage_corrupt', message)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** @param {unknown} value @param {string} label @returns {Record<string, any>} */
|
|
97
|
+
function record(value, label) {
|
|
98
|
+
if (!isPlainObject(value)) throw corrupt(`${label} is invalid`)
|
|
99
|
+
return value
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** @param {unknown} value @param {string} label */
|
|
103
|
+
function nonEmptyString(value, label) {
|
|
104
|
+
if (typeof value !== 'string' || value.length === 0) throw corrupt(`${label} is invalid`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** @param {unknown} value @param {string} label @param {number} [min] */
|
|
108
|
+
function safeInteger(value, label, min = 0) {
|
|
109
|
+
if (!Number.isSafeInteger(value) || value < min) throw corrupt(`${label} is invalid`)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** @param {unknown} value @param {string} label */
|
|
113
|
+
function optionalTime(value, label) {
|
|
114
|
+
if (value === undefined || value === null) return
|
|
115
|
+
safeInteger(value, label)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @param {unknown} value @param {string} label */
|
|
119
|
+
function optionalString(value, label) {
|
|
120
|
+
if (value === undefined || value === null) return
|
|
121
|
+
nonEmptyString(value, label)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** @param {string} id @param {unknown} chain */
|
|
125
|
+
function validateChain(id, chain) {
|
|
126
|
+
const value = record(chain, `ledger chain ${id}`)
|
|
127
|
+
if (value.id !== id) throw corrupt(`ledger chain ${id} id mismatch`)
|
|
128
|
+
nonEmptyString(value.rootOwner, `ledger chain ${id} rootOwner`)
|
|
129
|
+
safeInteger(value.createdAt, `ledger chain ${id} createdAt`)
|
|
130
|
+
if (!CHAIN_STATES.includes(value.state)) throw corrupt(`ledger chain ${id} state is invalid`)
|
|
131
|
+
// v1 chain budget fields are inert audit fields in v2 when present.
|
|
132
|
+
if (value.maxMessages !== undefined) safeInteger(value.maxMessages, `ledger chain ${id} maxMessages`, 1)
|
|
133
|
+
if (value.maxHops !== undefined) safeInteger(value.maxHops, `ledger chain ${id} maxHops`, 1)
|
|
134
|
+
if (value.messageCount !== undefined) safeInteger(value.messageCount, `ledger chain ${id} messageCount`)
|
|
135
|
+
optionalTime(value.pausedAt, `ledger chain ${id} pausedAt`)
|
|
136
|
+
optionalTime(value.closedAt, `ledger chain ${id} closedAt`)
|
|
137
|
+
optionalString(value.pausedReason, `ledger chain ${id} pausedReason`)
|
|
138
|
+
optionalString(value.closedBy, `ledger chain ${id} closedBy`)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* @param {unknown} message
|
|
143
|
+
* @param {Set<string>} chainIds
|
|
144
|
+
* @param {Set<string>} messageIds
|
|
145
|
+
* @param {Record<string, any>} chains
|
|
146
|
+
*/
|
|
147
|
+
function validateMessage(message, chainIds, messageIds, chains) {
|
|
148
|
+
const value = record(message, 'ledger message')
|
|
149
|
+
nonEmptyString(value.id, 'ledger message id')
|
|
150
|
+
if (messageIds.has(value.id)) throw corrupt('ledger message id is duplicated')
|
|
151
|
+
messageIds.add(value.id)
|
|
152
|
+
nonEmptyString(value.from, `ledger message ${value.id} from`)
|
|
153
|
+
nonEmptyString(value.to, `ledger message ${value.id} to`)
|
|
154
|
+
if (value.from === value.to) throw corrupt(`ledger message ${value.id} targets itself`)
|
|
155
|
+
if (!MESSAGE_KINDS.includes(value.kind)) throw corrupt(`ledger message ${value.id} kind is invalid`)
|
|
156
|
+
nonEmptyString(value.chainId, `ledger message ${value.id} chainId`)
|
|
157
|
+
if (!chainIds.has(value.chainId)) throw corrupt(`ledger message ${value.id} references an unknown chain`)
|
|
158
|
+
safeInteger(value.hop, `ledger message ${value.id} hop`)
|
|
159
|
+
safeInteger(value.createdAt, `ledger message ${value.id} createdAt`)
|
|
160
|
+
if (!DISPATCH_STATUSES.includes(value.status)) throw corrupt(`ledger message ${value.id} status is invalid`)
|
|
161
|
+
if (!REPLY_STATUSES.includes(value.replyStatus)) throw corrupt(`ledger message ${value.id} replyStatus is invalid`)
|
|
162
|
+
optionalString(value.operationId, `ledger message ${value.id} operationId`)
|
|
163
|
+
if (value.deliveryAttempts !== undefined) safeInteger(value.deliveryAttempts, `ledger message ${value.id} deliveryAttempts`)
|
|
164
|
+
if (value.durableMessageId !== undefined && value.durableMessageId !== null) {
|
|
165
|
+
nonEmptyString(value.durableMessageId, `ledger message ${value.id} durableMessageId`)
|
|
166
|
+
}
|
|
167
|
+
const content = record(value.content, `ledger message ${value.id} content`)
|
|
168
|
+
if (typeof content.text !== 'string') throw corrupt(`ledger message ${value.id} content.text is invalid`)
|
|
169
|
+
if (!Array.isArray(content.references)) throw corrupt(`ledger message ${value.id} references are invalid`)
|
|
170
|
+
for (const [index, reference] of content.references.entries()) {
|
|
171
|
+
const entry = record(reference, `ledger message ${value.id} reference ${index}`)
|
|
172
|
+
nonEmptyString(entry.sessionId, `ledger message ${value.id} reference sessionId`)
|
|
173
|
+
safeInteger(entry.seq, `ledger message ${value.id} reference seq`)
|
|
174
|
+
if (typeof entry.text !== 'string') throw corrupt(`ledger message ${value.id} reference text is invalid`)
|
|
175
|
+
}
|
|
176
|
+
if (!Array.isArray(content.paths) || content.paths.some((item) => typeof item !== 'string')) {
|
|
177
|
+
throw corrupt(`ledger message ${value.id} paths are invalid`)
|
|
178
|
+
}
|
|
179
|
+
if (value.error !== undefined && value.error !== null) {
|
|
180
|
+
const error = record(value.error, `ledger message ${value.id} error`)
|
|
181
|
+
nonEmptyString(error.code, `ledger message ${value.id} error.code`)
|
|
182
|
+
if (typeof error.message !== 'string') throw corrupt(`ledger message ${value.id} error.message is invalid`)
|
|
183
|
+
}
|
|
184
|
+
if (value.contentBytes !== undefined) safeInteger(value.contentBytes, `ledger message ${value.id} contentBytes`)
|
|
185
|
+
if (value.subject !== undefined && value.subject !== null && typeof value.subject !== 'string') {
|
|
186
|
+
throw corrupt(`ledger message ${value.id} subject is invalid`)
|
|
187
|
+
}
|
|
188
|
+
for (const [field, time] of [
|
|
189
|
+
['acceptedAt', value.acceptedAt],
|
|
190
|
+
['deadlineAt', value.deadlineAt],
|
|
191
|
+
['timedOutAt', value.timedOutAt],
|
|
192
|
+
['lateReplyAt', value.lateReplyAt],
|
|
193
|
+
['heldAt', value.heldAt],
|
|
194
|
+
['cancelledAt', value.cancelledAt],
|
|
195
|
+
['failedAt', value.failedAt],
|
|
196
|
+
['answeredAt', value.answeredAt],
|
|
197
|
+
['completedAt', value.completedAt],
|
|
198
|
+
['redactedAt', value.redactedAt],
|
|
199
|
+
['unknownAt', value.unknownAt],
|
|
200
|
+
['dispatchStartedAt', value.dispatchStartedAt],
|
|
201
|
+
['releasedAt', value.releasedAt],
|
|
202
|
+
['autoResumedAt', value.autoResumedAt],
|
|
203
|
+
['manualConfirmedAt', value.manualConfirmedAt],
|
|
204
|
+
['unconfirmedAt', value.unconfirmedAt],
|
|
205
|
+
]) {
|
|
206
|
+
optionalTime(time, `ledger message ${value.id} ${field}`)
|
|
207
|
+
}
|
|
208
|
+
optionalString(value.heldReason, `ledger message ${value.id} heldReason`)
|
|
209
|
+
if (value.releaseAuthorized !== undefined && typeof value.releaseAuthorized !== 'boolean') {
|
|
210
|
+
throw corrupt(`ledger message ${value.id} releaseAuthorized is invalid`)
|
|
211
|
+
}
|
|
212
|
+
if (value.late !== undefined && typeof value.late !== 'boolean') throw corrupt(`ledger message ${value.id} late is invalid`)
|
|
213
|
+
const chain = chains[value.chainId]
|
|
214
|
+
if (chain === undefined) throw corrupt(`ledger message ${value.id} references an unknown chain`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** @param {unknown} operation @param {string} key */
|
|
218
|
+
function validateOperation(key, operation) {
|
|
219
|
+
const value = record(operation, `ledger operation ${key}`)
|
|
220
|
+
if (value.key !== undefined && value.key !== key) throw corrupt('ledger operation key mismatch')
|
|
221
|
+
nonEmptyString(value.from, `ledger operation ${key} from`)
|
|
222
|
+
nonEmptyString(value.operationId, `ledger operation ${key} operationId`)
|
|
223
|
+
nonEmptyString(value.fingerprint, `ledger operation ${key} fingerprint`)
|
|
224
|
+
if (!Array.isArray(value.targets) || value.targets.length === 0) throw corrupt(`ledger operation ${key} targets are invalid`)
|
|
225
|
+
if (new Set(value.targets).size !== value.targets.length) throw corrupt(`ledger operation ${key} targets are duplicated`)
|
|
226
|
+
for (const target of value.targets) nonEmptyString(target, `ledger operation ${key} target`)
|
|
227
|
+
const messageIds = record(value.messageIds, `ledger operation ${key} messageIds`)
|
|
228
|
+
for (const target of value.targets) nonEmptyString(messageIds[target], `ledger operation ${key} messageId`)
|
|
229
|
+
safeInteger(value.at, `ledger operation ${key} at`)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Validate a v2 state. There is intentionally no message-count or hop cap here:
|
|
234
|
+
* a legal large v2 ledger must load, and v1 hop limits never block continuations.
|
|
235
|
+
* @param {unknown} state
|
|
236
|
+
* @returns {Record<string, any>}
|
|
237
|
+
*/
|
|
238
|
+
export function validateV2State(state) {
|
|
239
|
+
if (!isPlainObject(state)) throw corrupt('ledger state is not an object')
|
|
240
|
+
if (state.version !== V2_STATE_VERSION) throw corrupt(`ledger uses unsupported state version ${String(state.version)}`)
|
|
241
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 1) throw corrupt('ledger revision is invalid')
|
|
242
|
+
if (!Number.isSafeInteger(state.settingsRevision) || state.settingsRevision < 1) {
|
|
243
|
+
throw corrupt('ledger settingsRevision is invalid')
|
|
244
|
+
}
|
|
245
|
+
if (!Number.isSafeInteger(state.createdAt) || state.createdAt < 0) throw corrupt('ledger createdAt is invalid')
|
|
246
|
+
if (!Number.isSafeInteger(state.updatedAt) || state.updatedAt < 0) throw corrupt('ledger updatedAt is invalid')
|
|
247
|
+
if (!Array.isArray(state.messages)) throw corrupt('ledger messages are invalid')
|
|
248
|
+
if (!isPlainObject(state.chains)) throw corrupt('ledger chains are invalid')
|
|
249
|
+
if (!isPlainObject(state.operations)) throw corrupt('ledger operations are invalid')
|
|
250
|
+
state.settings = validateStoredSettings(state.settings)
|
|
251
|
+
if (state.migration !== undefined) validateMigrationMarker(state.migration)
|
|
252
|
+
const chainIds = new Set(Object.keys(state.chains))
|
|
253
|
+
for (const [id, chain] of Object.entries(state.chains)) validateChain(id, chain)
|
|
254
|
+
const messageIds = new Set()
|
|
255
|
+
const messagesById = new Map()
|
|
256
|
+
for (const message of state.messages) {
|
|
257
|
+
validateMessage(message, chainIds, messageIds, state.chains)
|
|
258
|
+
messagesById.set(message.id, message)
|
|
259
|
+
}
|
|
260
|
+
for (const message of state.messages) {
|
|
261
|
+
if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
|
|
262
|
+
throw corrupt(`ledger message ${message.id} replyTo mismatch`)
|
|
263
|
+
}
|
|
264
|
+
for (const field of ['parentMessageId', 'replyTo']) {
|
|
265
|
+
const parentId = message[field]
|
|
266
|
+
if (parentId === undefined || parentId === null) continue
|
|
267
|
+
const parent = messagesById.get(parentId)
|
|
268
|
+
if (parent === undefined) throw corrupt(`ledger message ${message.id} ${field} is dangling`)
|
|
269
|
+
if (parent.chainId !== message.chainId) throw corrupt(`ledger message ${message.id} ${field} crosses chains`)
|
|
270
|
+
if (message.hop !== parent.hop + 1) throw corrupt(`ledger message ${message.id} hop does not follow its parent`)
|
|
271
|
+
if (message.kind === 'reply') {
|
|
272
|
+
if (parent.kind !== 'request' || parent.to !== message.from) {
|
|
273
|
+
throw corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
|
|
274
|
+
}
|
|
275
|
+
} else if (parent.from !== message.from && parent.to !== message.from) {
|
|
276
|
+
throw corrupt(`ledger message ${message.id} parent does not involve the sender`)
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
for (const [key, operation] of Object.entries(state.operations)) validateOperation(key, operation)
|
|
281
|
+
return state
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const RECEIPT_POLICIES = Object.freeze(['context', 'legacy-accepted'])
|
|
285
|
+
const RECEIPT_STATES = Object.freeze(['not_sent', 'queued', 'received', 'uncertain'])
|
|
286
|
+
const TRANSFER_STATES = Object.freeze(['prepared', 'moved', 'uncertain'])
|
|
287
|
+
const RECEIPT_KEYS = Object.freeze([
|
|
288
|
+
'policy',
|
|
289
|
+
'state',
|
|
290
|
+
'receivedAt',
|
|
291
|
+
'receivedSeq',
|
|
292
|
+
'timeoutMs',
|
|
293
|
+
'transfer',
|
|
294
|
+
])
|
|
295
|
+
const TRANSFER_KEYS = Object.freeze(['state', 'at', 'error'])
|
|
296
|
+
|
|
297
|
+
/** @param {unknown} receipt @param {string} messageId */
|
|
298
|
+
function validateReceipt(receipt, messageId) {
|
|
299
|
+
const value = record(receipt, `ledger message ${messageId} receipt`)
|
|
300
|
+
for (const key of Object.keys(value)) {
|
|
301
|
+
if (!RECEIPT_KEYS.includes(key)) throw corrupt(`ledger message ${messageId} receipt has unknown field ${key}`)
|
|
302
|
+
}
|
|
303
|
+
for (const key of RECEIPT_KEYS) {
|
|
304
|
+
if (!(key in value)) throw corrupt(`ledger message ${messageId} receipt is missing ${key}`)
|
|
305
|
+
}
|
|
306
|
+
if (!RECEIPT_POLICIES.includes(value.policy)) {
|
|
307
|
+
throw corrupt(`ledger message ${messageId} receipt.policy is invalid`)
|
|
308
|
+
}
|
|
309
|
+
if (!RECEIPT_STATES.includes(value.state)) {
|
|
310
|
+
throw corrupt(`ledger message ${messageId} receipt.state is invalid`)
|
|
311
|
+
}
|
|
312
|
+
if (value.receivedAt !== null && (!Number.isSafeInteger(value.receivedAt) || value.receivedAt < 0)) {
|
|
313
|
+
throw corrupt(`ledger message ${messageId} receipt.receivedAt is invalid`)
|
|
314
|
+
}
|
|
315
|
+
if (value.receivedSeq !== null && (!Number.isSafeInteger(value.receivedSeq) || value.receivedSeq < 0)) {
|
|
316
|
+
throw corrupt(`ledger message ${messageId} receipt.receivedSeq is invalid`)
|
|
317
|
+
}
|
|
318
|
+
if (value.state === 'received') {
|
|
319
|
+
if (value.receivedAt === null || value.receivedSeq === null) {
|
|
320
|
+
throw corrupt(`ledger message ${messageId} received receipt lacks exact event evidence`)
|
|
321
|
+
}
|
|
322
|
+
} else if (value.receivedAt !== null || value.receivedSeq !== null) {
|
|
323
|
+
throw corrupt(`ledger message ${messageId} non-received receipt carries evidence`)
|
|
324
|
+
}
|
|
325
|
+
if (value.timeoutMs !== null && (!Number.isSafeInteger(value.timeoutMs) || value.timeoutMs <= 0)) {
|
|
326
|
+
throw corrupt(`ledger message ${messageId} receipt.timeoutMs is invalid`)
|
|
327
|
+
}
|
|
328
|
+
if (value.transfer !== null) {
|
|
329
|
+
const transfer = record(value.transfer, `ledger message ${messageId} receipt.transfer`)
|
|
330
|
+
for (const key of Object.keys(transfer)) {
|
|
331
|
+
if (!TRANSFER_KEYS.includes(key)) {
|
|
332
|
+
throw corrupt(`ledger message ${messageId} receipt.transfer has unknown field ${key}`)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!TRANSFER_STATES.includes(transfer.state)) {
|
|
336
|
+
throw corrupt(`ledger message ${messageId} receipt.transfer.state is invalid`)
|
|
337
|
+
}
|
|
338
|
+
safeInteger(transfer.at, `ledger message ${messageId} receipt.transfer.at`)
|
|
339
|
+
if (transfer.error !== undefined && typeof transfer.error !== 'string') {
|
|
340
|
+
throw corrupt(`ledger message ${messageId} receipt.transfer.error is invalid`)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Validate the private transfer journal. Journals never reach the public view:
|
|
347
|
+
* they retain the original host message only for crash recovery and exact
|
|
348
|
+
* identity checks.
|
|
349
|
+
* @param {unknown} transfers
|
|
350
|
+
* @param {Map<string, Record<string, any>>} messagesById
|
|
351
|
+
*/
|
|
352
|
+
function validateTransfers(transfers, messagesById) {
|
|
353
|
+
const value = record(transfers, 'ledger transfers')
|
|
354
|
+
for (const [messageId, rawJournal] of Object.entries(value)) {
|
|
355
|
+
const journal = record(rawJournal, `ledger transfer ${messageId}`)
|
|
356
|
+
const message = messagesById.get(messageId)
|
|
357
|
+
if (message === undefined) throw corrupt(`ledger transfer ${messageId} references an unknown message`)
|
|
358
|
+
if (journal.messageId !== messageId) throw corrupt(`ledger transfer ${messageId} id mismatch`)
|
|
359
|
+
nonEmptyString(journal.target, `ledger transfer ${messageId} target`)
|
|
360
|
+
if (journal.target !== message.to) throw corrupt(`ledger transfer ${messageId} target mismatch`)
|
|
361
|
+
nonEmptyString(journal.durableMessageId, `ledger transfer ${messageId} durableMessageId`)
|
|
362
|
+
if (journal.durableMessageId !== message.durableMessageId) {
|
|
363
|
+
throw corrupt(`ledger transfer ${messageId} durableMessageId mismatch`)
|
|
364
|
+
}
|
|
365
|
+
const hostMessage = record(journal.hostMessage, `ledger transfer ${messageId} hostMessage`)
|
|
366
|
+
if (hostMessage.id !== journal.durableMessageId) {
|
|
367
|
+
throw corrupt(`ledger transfer ${messageId} hostMessage id mismatch`)
|
|
368
|
+
}
|
|
369
|
+
if (!Array.isArray(hostMessage.content)) {
|
|
370
|
+
throw corrupt(`ledger transfer ${messageId} hostMessage content is invalid`)
|
|
371
|
+
}
|
|
372
|
+
const source = record(hostMessage.source, `ledger transfer ${messageId} hostMessage source`)
|
|
373
|
+
if (Object.keys(source).length !== 3) {
|
|
374
|
+
throw corrupt(`ledger transfer ${messageId} hostMessage source is not the standard shape`)
|
|
375
|
+
}
|
|
376
|
+
if (source.kind !== 'agent-message' || source.form !== 'relay') {
|
|
377
|
+
throw corrupt(`ledger transfer ${messageId} hostMessage source is invalid`)
|
|
378
|
+
}
|
|
379
|
+
if (source.senderSessionId !== message.from) {
|
|
380
|
+
throw corrupt(`ledger transfer ${messageId} hostMessage sender mismatch`)
|
|
381
|
+
}
|
|
382
|
+
safeInteger(journal.queuePosition, `ledger transfer ${messageId} queuePosition`)
|
|
383
|
+
safeInteger(journal.hostSessionSeq, `ledger transfer ${messageId} hostSessionSeq`, -1)
|
|
384
|
+
safeInteger(journal.at, `ledger transfer ${messageId} at`)
|
|
385
|
+
if (journal.attempts !== undefined) safeInteger(journal.attempts, `ledger transfer ${messageId} attempts`)
|
|
386
|
+
if (!TRANSFER_STATES.includes(journal.state)) {
|
|
387
|
+
throw corrupt(`ledger transfer ${messageId} state is invalid`)
|
|
388
|
+
}
|
|
389
|
+
if (typeof journal.digest !== 'string' || !/^[0-9a-f]{64}$/.test(journal.digest)) {
|
|
390
|
+
throw corrupt(`ledger transfer ${messageId} digest is invalid`)
|
|
391
|
+
}
|
|
392
|
+
if (journal.error !== undefined && typeof journal.error !== 'string') {
|
|
393
|
+
throw corrupt(`ledger transfer ${messageId} error is invalid`)
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** Exact per-record whitelist for the optional create journal region. */
|
|
399
|
+
const CREATE_JOURNAL_KEYS = Object.freeze([
|
|
400
|
+
'key',
|
|
401
|
+
'from',
|
|
402
|
+
'operationId',
|
|
403
|
+
'fingerprint',
|
|
404
|
+
'at',
|
|
405
|
+
'updatedAt',
|
|
406
|
+
'phase',
|
|
407
|
+
'task',
|
|
408
|
+
'subject',
|
|
409
|
+
'title',
|
|
410
|
+
'location',
|
|
411
|
+
'requestedSessionId',
|
|
412
|
+
'sessionId',
|
|
413
|
+
'workspaceId',
|
|
414
|
+
'workspacePath',
|
|
415
|
+
'renameStatus',
|
|
416
|
+
'renameError',
|
|
417
|
+
'sendOperationId',
|
|
418
|
+
'firstMessageId',
|
|
419
|
+
'error',
|
|
420
|
+
'terminalAt',
|
|
421
|
+
'bodyStripped',
|
|
422
|
+
])
|
|
423
|
+
|
|
424
|
+
const CREATE_LOCATION_KEYS = Object.freeze([
|
|
425
|
+
'kind',
|
|
426
|
+
'workspaceId',
|
|
427
|
+
'parentDir',
|
|
428
|
+
'directoryName',
|
|
429
|
+
'workspacePath',
|
|
430
|
+
'workspaceTitle',
|
|
431
|
+
])
|
|
432
|
+
|
|
433
|
+
const CREATE_ERROR_KEYS = Object.freeze(['code', 'message'])
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Validate the optional create journal region with a strict per-record
|
|
437
|
+
* whitelist: an unknown field, phase, rename status, or malformed effect
|
|
438
|
+
* rejects the whole ledger load instead of being silently dropped. Records
|
|
439
|
+
* keep their private task body only until the first message is delivered;
|
|
440
|
+
* a stripped record carries an empty `task`/`null` `subject` and keeps its
|
|
441
|
+
* fingerprint, status, and association fields.
|
|
442
|
+
* @param {unknown} creates
|
|
443
|
+
*/
|
|
444
|
+
function validateCreates(creates) {
|
|
445
|
+
const value = record(creates, 'ledger creates')
|
|
446
|
+
for (const [key, rawJournal] of Object.entries(value)) {
|
|
447
|
+
const journal = record(rawJournal, `ledger create ${key}`)
|
|
448
|
+
for (const field of Object.keys(journal)) {
|
|
449
|
+
if (!CREATE_JOURNAL_KEYS.includes(field)) {
|
|
450
|
+
throw corrupt(`ledger create ${key} has unknown field ${field}`)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
for (const field of CREATE_JOURNAL_KEYS) {
|
|
454
|
+
if (!(field in journal)) throw corrupt(`ledger create ${key} is missing ${field}`)
|
|
455
|
+
}
|
|
456
|
+
nonEmptyString(journal.from, `ledger create ${key} from`)
|
|
457
|
+
nonEmptyString(journal.operationId, `ledger create ${key} operationId`)
|
|
458
|
+
if (journal.key !== key || key !== `${journal.from}|${journal.operationId}`) {
|
|
459
|
+
throw corrupt(`ledger create ${key} key mismatch`)
|
|
460
|
+
}
|
|
461
|
+
nonEmptyString(journal.fingerprint, `ledger create ${key} fingerprint`)
|
|
462
|
+
safeInteger(journal.at, `ledger create ${key} at`)
|
|
463
|
+
safeInteger(journal.updatedAt, `ledger create ${key} updatedAt`)
|
|
464
|
+
if (!CREATE_PHASES.includes(journal.phase)) throw corrupt(`ledger create ${key} phase is invalid`)
|
|
465
|
+
if (typeof journal.task !== 'string') throw corrupt(`ledger create ${key} task is invalid`)
|
|
466
|
+
if (journal.subject !== null && typeof journal.subject !== 'string') {
|
|
467
|
+
throw corrupt(`ledger create ${key} subject is invalid`)
|
|
468
|
+
}
|
|
469
|
+
if (journal.title !== null && typeof journal.title !== 'string') {
|
|
470
|
+
throw corrupt(`ledger create ${key} title is invalid`)
|
|
471
|
+
}
|
|
472
|
+
const location = record(journal.location, `ledger create ${key} location`)
|
|
473
|
+
for (const field of Object.keys(location)) {
|
|
474
|
+
if (!CREATE_LOCATION_KEYS.includes(field)) {
|
|
475
|
+
throw corrupt(`ledger create ${key} location has unknown field ${field}`)
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const field of CREATE_LOCATION_KEYS) {
|
|
479
|
+
// workspacePath is additive for the existing_project mode; older 0.4
|
|
480
|
+
// journals (workspace/new_project) legitimately lack it.
|
|
481
|
+
if (field === 'workspacePath') continue
|
|
482
|
+
if (!(field in location)) throw corrupt(`ledger create ${key} location is missing ${field}`)
|
|
483
|
+
}
|
|
484
|
+
if (location.kind !== 'workspace' && location.kind !== 'new_project' && location.kind !== 'existing_project') {
|
|
485
|
+
throw corrupt(`ledger create ${key} location.kind is invalid`)
|
|
486
|
+
}
|
|
487
|
+
for (const field of ['workspaceId', 'parentDir', 'directoryName', 'workspacePath', 'workspaceTitle']) {
|
|
488
|
+
// workspacePath is additive; an older journal missing it entirely is
|
|
489
|
+
// valid, while a present value must be a string or null.
|
|
490
|
+
if (field === 'workspacePath' && !(field in location)) continue
|
|
491
|
+
if (location[field] !== null && typeof location[field] !== 'string') {
|
|
492
|
+
throw corrupt(`ledger create ${key} location.${field} is invalid`)
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (location.kind === 'workspace' && typeof location.workspaceId !== 'string') {
|
|
496
|
+
throw corrupt(`ledger create ${key} workspace location lacks workspaceId`)
|
|
497
|
+
}
|
|
498
|
+
if (location.kind === 'new_project' && (typeof location.parentDir !== 'string' || typeof location.directoryName !== 'string')) {
|
|
499
|
+
throw corrupt(`ledger create ${key} new_project location lacks parentDir/directoryName`)
|
|
500
|
+
}
|
|
501
|
+
if (location.kind === 'existing_project' && typeof location.workspacePath !== 'string') {
|
|
502
|
+
throw corrupt(`ledger create ${key} existing_project location lacks workspacePath`)
|
|
503
|
+
}
|
|
504
|
+
for (const field of ['requestedSessionId', 'sessionId', 'workspaceId', 'workspacePath', 'sendOperationId', 'firstMessageId']) {
|
|
505
|
+
if (journal[field] !== null && typeof journal[field] !== 'string') {
|
|
506
|
+
throw corrupt(`ledger create ${key} ${field} is invalid`)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (!CREATE_RENAME_STATUSES.includes(journal.renameStatus)) {
|
|
510
|
+
throw corrupt(`ledger create ${key} renameStatus is invalid`)
|
|
511
|
+
}
|
|
512
|
+
if (journal.renameError !== null && typeof journal.renameError !== 'string') {
|
|
513
|
+
throw corrupt(`ledger create ${key} renameError is invalid`)
|
|
514
|
+
}
|
|
515
|
+
if (journal.error !== null) {
|
|
516
|
+
const error = record(journal.error, `ledger create ${key} error`)
|
|
517
|
+
for (const field of Object.keys(error)) {
|
|
518
|
+
if (!CREATE_ERROR_KEYS.includes(field)) throw corrupt(`ledger create ${key} error has unknown field ${field}`)
|
|
519
|
+
}
|
|
520
|
+
nonEmptyString(error.code, `ledger create ${key} error.code`)
|
|
521
|
+
if (typeof error.message !== 'string') throw corrupt(`ledger create ${key} error.message is invalid`)
|
|
522
|
+
}
|
|
523
|
+
if (journal.terminalAt !== null) safeInteger(journal.terminalAt, `ledger create ${key} terminalAt`)
|
|
524
|
+
if (typeof journal.bodyStripped !== 'boolean') throw corrupt(`ledger create ${key} bodyStripped is invalid`)
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Validate a v3 state. The v2 rules remain frozen in validateV2State; this
|
|
530
|
+
* validator adds the public receipt, the private upgrade marker, and the
|
|
531
|
+
* private transfer journal without relaxing any 0.2 invariant.
|
|
532
|
+
* @param {unknown} state
|
|
533
|
+
* @returns {Record<string, any>}
|
|
534
|
+
*/
|
|
535
|
+
export function validateV3State(state) {
|
|
536
|
+
if (!isPlainObject(state)) throw corrupt('ledger state is not an object')
|
|
537
|
+
if (state.version !== STATE_VERSION) throw corrupt(`ledger uses unsupported state version ${String(state.version)}`)
|
|
538
|
+
if (!Number.isSafeInteger(state.revision) || state.revision < 1) throw corrupt('ledger revision is invalid')
|
|
539
|
+
if (!Number.isSafeInteger(state.settingsRevision) || state.settingsRevision < 1) {
|
|
540
|
+
throw corrupt('ledger settingsRevision is invalid')
|
|
541
|
+
}
|
|
542
|
+
if (!Number.isSafeInteger(state.createdAt) || state.createdAt < 0) throw corrupt('ledger createdAt is invalid')
|
|
543
|
+
if (!Number.isSafeInteger(state.updatedAt) || state.updatedAt < 0) throw corrupt('ledger updatedAt is invalid')
|
|
544
|
+
if (!Array.isArray(state.messages)) throw corrupt('ledger messages are invalid')
|
|
545
|
+
if (!isPlainObject(state.chains)) throw corrupt('ledger chains are invalid')
|
|
546
|
+
if (!isPlainObject(state.operations)) throw corrupt('ledger operations are invalid')
|
|
547
|
+
state.settings = validateStoredSettings(state.settings)
|
|
548
|
+
if (state.migration !== undefined) validateMigrationMarker(state.migration)
|
|
549
|
+
if (state.upgrade !== undefined) validateUpgradeMarker(state.upgrade)
|
|
550
|
+
const chainIds = new Set(Object.keys(state.chains))
|
|
551
|
+
for (const [id, chain] of Object.entries(state.chains)) validateChain(id, chain)
|
|
552
|
+
const messageIds = new Set()
|
|
553
|
+
const messagesById = new Map()
|
|
554
|
+
for (const message of state.messages) {
|
|
555
|
+
validateMessage(message, chainIds, messageIds, state.chains)
|
|
556
|
+
validateReceipt(message.receipt, message.id)
|
|
557
|
+
messagesById.set(message.id, message)
|
|
558
|
+
}
|
|
559
|
+
for (const message of state.messages) {
|
|
560
|
+
if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
|
|
561
|
+
throw corrupt(`ledger message ${message.id} replyTo mismatch`)
|
|
562
|
+
}
|
|
563
|
+
for (const field of ['parentMessageId', 'replyTo']) {
|
|
564
|
+
const parentId = message[field]
|
|
565
|
+
if (parentId === undefined || parentId === null) continue
|
|
566
|
+
const parent = messagesById.get(parentId)
|
|
567
|
+
if (parent === undefined) throw corrupt(`ledger message ${message.id} ${field} is dangling`)
|
|
568
|
+
if (parent.chainId !== message.chainId) throw corrupt(`ledger message ${message.id} ${field} crosses chains`)
|
|
569
|
+
if (message.hop !== parent.hop + 1) throw corrupt(`ledger message ${message.id} hop does not follow its parent`)
|
|
570
|
+
if (message.kind === 'reply') {
|
|
571
|
+
if (parent.kind !== 'request' || parent.to !== message.from) {
|
|
572
|
+
throw corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
|
|
573
|
+
}
|
|
574
|
+
} else if (parent.from !== message.from && parent.to !== message.from) {
|
|
575
|
+
throw corrupt(`ledger message ${message.id} parent does not involve the sender`)
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
for (const [key, operation] of Object.entries(state.operations)) validateOperation(key, operation)
|
|
580
|
+
if (state.creates !== undefined) validateCreates(state.creates)
|
|
581
|
+
if (state.transfers !== undefined) validateTransfers(state.transfers, messagesById)
|
|
582
|
+
return state
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export class Ledger {
|
|
586
|
+
/**
|
|
587
|
+
* @param {{
|
|
588
|
+
* root: string,
|
|
589
|
+
* clock?: () => number,
|
|
590
|
+
* hostname?: string,
|
|
591
|
+
* pid?: number,
|
|
592
|
+
* processStart?: string,
|
|
593
|
+
* limits?: { maxDiskBytes?: number },
|
|
594
|
+
* }} options
|
|
595
|
+
*/
|
|
596
|
+
constructor(options) {
|
|
597
|
+
if (typeof options?.root !== 'string' || options.root.length === 0) throw fail('invalid_argument', 'ledger root is required')
|
|
598
|
+
this.root = path.resolve(options.root)
|
|
599
|
+
this.statePath = path.join(this.root, LEDGER_FILE)
|
|
600
|
+
this.lockPath = path.join(this.root, LOCK_FILE)
|
|
601
|
+
this.lockDbPath = path.join(this.root, LOCK_DB_FILE)
|
|
602
|
+
this.clock = options.clock ?? Date.now
|
|
603
|
+
this.hostname = options.hostname ?? os.hostname()
|
|
604
|
+
this.pid = options.pid ?? process.pid
|
|
605
|
+
this.processStart =
|
|
606
|
+
options.processStart ?? new Date(Date.now() - process.uptime() * 1000).toISOString()
|
|
607
|
+
this.limits = {
|
|
608
|
+
maxDiskBytes: options.limits?.maxDiskBytes ?? LIMITS.MAX_LEDGER_BYTES,
|
|
609
|
+
}
|
|
610
|
+
if (!Number.isSafeInteger(this.limits.maxDiskBytes) || this.limits.maxDiskBytes <= 0) {
|
|
611
|
+
throw fail('invalid_argument', 'ledger maxDiskBytes must be a positive safe integer')
|
|
612
|
+
}
|
|
613
|
+
this.#queue = new SerialQueue()
|
|
614
|
+
this.#opened = false
|
|
615
|
+
this.#state = undefined
|
|
616
|
+
this.#lockNonce = undefined
|
|
617
|
+
this.#lockDb = undefined
|
|
618
|
+
this.#poisoned = undefined
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
#queue
|
|
622
|
+
#opened
|
|
623
|
+
#state
|
|
624
|
+
#lockNonce
|
|
625
|
+
#lockDb
|
|
626
|
+
#poisoned
|
|
627
|
+
|
|
628
|
+
/** @returns {Promise<Ledger>} */
|
|
629
|
+
async open() {
|
|
630
|
+
if (this.#opened) return this
|
|
631
|
+
// A failed open may have poisoned this instance mid-migration. A fresh
|
|
632
|
+
// open starts from the on-disk state and must re-evaluate health.
|
|
633
|
+
this.#poisoned = undefined
|
|
634
|
+
await fs.mkdir(this.root, { recursive: true, mode: 0o700 })
|
|
635
|
+
await fs.chmod(this.root, 0o700)
|
|
636
|
+
await this.#acquireLock()
|
|
637
|
+
try {
|
|
638
|
+
this.#state = await this.#load()
|
|
639
|
+
this.#opened = true
|
|
640
|
+
return this
|
|
641
|
+
} catch (error) {
|
|
642
|
+
await this.#releaseLock().catch(() => undefined)
|
|
643
|
+
throw error
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
get opened() {
|
|
648
|
+
return this.#opened
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** @returns {boolean} */
|
|
652
|
+
get writable() {
|
|
653
|
+
return this.#opened && this.#poisoned === undefined
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** @returns {Record<string, any> | undefined} */
|
|
657
|
+
get storageError() {
|
|
658
|
+
return this.#poisoned === undefined ? undefined : structuredClone(this.#poisoned)
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/** @returns {Record<string, any>} detached deep copy of the current state */
|
|
662
|
+
read() {
|
|
663
|
+
if (!this.#opened) throw fail('internal', 'ledger is not open')
|
|
664
|
+
return structuredClone(this.#state)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* @returns {{
|
|
669
|
+
* root: string, messageCount: number, chainCount: number, revision: number,
|
|
670
|
+
* bytes: number, maxBytes: number,
|
|
671
|
+
* health: 'ok'|'near_capacity'|'full'|'storage_error',
|
|
672
|
+
* error?: Record<string, any>,
|
|
673
|
+
* }}
|
|
674
|
+
*/
|
|
675
|
+
stats() {
|
|
676
|
+
const state = this.read()
|
|
677
|
+
const bytes = Buffer.byteLength(JSON.stringify(state), 'utf8')
|
|
678
|
+
const maxBytes = this.limits.maxDiskBytes
|
|
679
|
+
let health = 'ok'
|
|
680
|
+
if (this.#poisoned !== undefined) health = 'storage_error'
|
|
681
|
+
else if (bytes >= maxBytes) health = 'full'
|
|
682
|
+
else if (bytes >= maxBytes * 0.9) health = 'near_capacity'
|
|
683
|
+
return {
|
|
684
|
+
root: this.root,
|
|
685
|
+
messageCount: state.messages.length,
|
|
686
|
+
chainCount: Object.keys(state.chains).length,
|
|
687
|
+
revision: state.revision,
|
|
688
|
+
bytes,
|
|
689
|
+
maxBytes,
|
|
690
|
+
health,
|
|
691
|
+
...(this.#poisoned === undefined ? {} : { error: structuredClone(this.#poisoned) }),
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Serialize one read-modify-write transaction.
|
|
697
|
+
*
|
|
698
|
+
* A task may return {@link skipMutation} to report a value without writing.
|
|
699
|
+
* Otherwise revision is incremented, the full state is durably replaced, and
|
|
700
|
+
* only then does the new state become visible to readers.
|
|
701
|
+
*
|
|
702
|
+
* @template T
|
|
703
|
+
* @param {(state: Record<string, any>) => T | { [SKIP]: true, value: T }} task
|
|
704
|
+
* @param {{ expectedRevision?: number, delivery?: boolean }} [options]
|
|
705
|
+
* @returns {Promise<T>}
|
|
706
|
+
*/
|
|
707
|
+
mutate(task, options = {}) {
|
|
708
|
+
return this.#queue.run(async () => {
|
|
709
|
+
if (!this.#opened) throw fail('internal', 'ledger is not open')
|
|
710
|
+
if (this.#poisoned !== undefined) throw this.#poisonError()
|
|
711
|
+
const draft = structuredClone(this.#state)
|
|
712
|
+
if (options.expectedRevision !== undefined && draft.revision !== options.expectedRevision) {
|
|
713
|
+
throw fail('revision_conflict', 'state changed since the panel snapshot; refresh and retry', {
|
|
714
|
+
currentRevision: draft.revision,
|
|
715
|
+
})
|
|
716
|
+
}
|
|
717
|
+
const result = task(draft)
|
|
718
|
+
if (isPlainObject(result) && result[SKIP] === true) return result.value
|
|
719
|
+
draft.revision = this.#state.revision + 1
|
|
720
|
+
draft.updatedAt = this.clock()
|
|
721
|
+
if (options.delivery === true) this.#assertCapacity(draft)
|
|
722
|
+
await this.#persist(draft)
|
|
723
|
+
this.#state = draft
|
|
724
|
+
return cloneResult(result)
|
|
725
|
+
})
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async close() {
|
|
729
|
+
if (!this.#opened) return
|
|
730
|
+
await this.#queue.run(async () => {
|
|
731
|
+
this.#opened = false
|
|
732
|
+
})
|
|
733
|
+
await this.#releaseLock()
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** @returns {never} */
|
|
737
|
+
#poisonError() {
|
|
738
|
+
return fail('storage_error', 'session-link ledger is in storage_error; safe reopen required', {
|
|
739
|
+
...(this.#poisoned ?? {}),
|
|
740
|
+
})
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** @param {{ stage: string, mayHaveWritten: boolean, message: string }} detail */
|
|
744
|
+
#poison(detail) {
|
|
745
|
+
if (this.#poisoned !== undefined) return
|
|
746
|
+
this.#poisoned = {
|
|
747
|
+
code: 'storage_error',
|
|
748
|
+
stage: detail.stage,
|
|
749
|
+
mayHaveWritten: detail.mayHaveWritten,
|
|
750
|
+
message: detail.message,
|
|
751
|
+
at: this.clock(),
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @param {Record<string, any>} state
|
|
757
|
+
* @returns {void}
|
|
758
|
+
*/
|
|
759
|
+
#assertCapacity(state) {
|
|
760
|
+
const bytes = Buffer.byteLength(JSON.stringify(state), 'utf8')
|
|
761
|
+
if (bytes > this.limits.maxDiskBytes) {
|
|
762
|
+
throw fail('storage_full', 'session-link ledger capacity is full', {
|
|
763
|
+
bytes,
|
|
764
|
+
maxBytes: this.limits.maxDiskBytes,
|
|
765
|
+
})
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** @returns {Promise<Record<string, any>>} */
|
|
770
|
+
async #load() {
|
|
771
|
+
let raw
|
|
772
|
+
try {
|
|
773
|
+
raw = await fs.readFile(this.statePath)
|
|
774
|
+
} catch (error) {
|
|
775
|
+
if (error?.code === 'ENOENT') return initialState(this.clock())
|
|
776
|
+
throw fail('storage_corrupt', `ledger cannot be read: ${errorCodeOf(error)}`)
|
|
777
|
+
}
|
|
778
|
+
let parsed
|
|
779
|
+
try {
|
|
780
|
+
parsed = JSON.parse(raw.toString('utf8'))
|
|
781
|
+
} catch {
|
|
782
|
+
throw fail('storage_corrupt', 'ledger JSON is corrupt; refusing to overwrite it')
|
|
783
|
+
}
|
|
784
|
+
if (!isPlainObject(parsed)) throw corrupt('ledger state is not an object')
|
|
785
|
+
if (parsed.version === 1) {
|
|
786
|
+
const v1 = validateV1State(parsed)
|
|
787
|
+
const fromStateSha256 = sha256(raw)
|
|
788
|
+
const backupPath = await this.#writeExactBackup(raw, fromStateSha256, V1_BACKUP_PREFIX, 'v1')
|
|
789
|
+
const migratedV2 = migrateV1State(v1, {
|
|
790
|
+
now: this.clock(),
|
|
791
|
+
fromStateSha256,
|
|
792
|
+
backupPath,
|
|
793
|
+
})
|
|
794
|
+
// Never persist a migration product that the next open would reject.
|
|
795
|
+
// The raw v1 file and backup remain untouched if this fails.
|
|
796
|
+
validateV2State(migratedV2)
|
|
797
|
+
// The v1 -> v2 rules stay frozen at schema 2. Take an exact private v2
|
|
798
|
+
// snapshot of that validated product, then run the separate v2 -> v3
|
|
799
|
+
// conversion so a later 0.3 change cannot pollute the historical chain.
|
|
800
|
+
const v2Bytes = Buffer.from(JSON.stringify(migratedV2), 'utf8')
|
|
801
|
+
const v2Digest = sha256(v2Bytes)
|
|
802
|
+
const v2BackupPath = await this.#writeExactBackup(v2Bytes, v2Digest, V2_BACKUP_PREFIX, 'v2')
|
|
803
|
+
const migratedV3 = migrateV2State(migratedV2, {
|
|
804
|
+
now: this.clock(),
|
|
805
|
+
fromStateSha256: v2Digest,
|
|
806
|
+
backupPath: v2BackupPath,
|
|
807
|
+
})
|
|
808
|
+
validateV3State(migratedV3)
|
|
809
|
+
await this.#persist(migratedV3)
|
|
810
|
+
return migratedV3
|
|
811
|
+
}
|
|
812
|
+
if (parsed.version === V2_STATE_VERSION) {
|
|
813
|
+
const v2 = validateV2State(parsed)
|
|
814
|
+
const v2Digest = sha256(raw)
|
|
815
|
+
const v2BackupPath = await this.#writeExactBackup(raw, v2Digest, V2_BACKUP_PREFIX, 'v2')
|
|
816
|
+
const migrated = migrateV2State(v2, {
|
|
817
|
+
now: this.clock(),
|
|
818
|
+
fromStateSha256: v2Digest,
|
|
819
|
+
backupPath: v2BackupPath,
|
|
820
|
+
})
|
|
821
|
+
validateV3State(migrated)
|
|
822
|
+
await this.#persist(migrated)
|
|
823
|
+
return migrated
|
|
824
|
+
}
|
|
825
|
+
if (parsed.version === STATE_VERSION) return validateV3State(parsed)
|
|
826
|
+
throw corrupt(`ledger uses unsupported state version ${String(parsed.version)}`)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Write exact bytes under an O_EXCL backup name. An existing file is reused
|
|
831
|
+
* only when its digest matches; a backup is never overwritten.
|
|
832
|
+
* @param {Buffer} raw
|
|
833
|
+
* @param {string} digest
|
|
834
|
+
* @param {string} prefix
|
|
835
|
+
* @param {string} label
|
|
836
|
+
* @returns {Promise<string>}
|
|
837
|
+
*/
|
|
838
|
+
async #writeExactBackup(raw, digest, prefix, label) {
|
|
839
|
+
const backupPath = path.join(this.root, `${prefix}${digest}.json`)
|
|
840
|
+
try {
|
|
841
|
+
const handle = await fs.open(backupPath, 'wx', 0o600)
|
|
842
|
+
try {
|
|
843
|
+
await handle.writeFile(raw)
|
|
844
|
+
await handle.sync()
|
|
845
|
+
} finally {
|
|
846
|
+
await handle.close()
|
|
847
|
+
}
|
|
848
|
+
await this.#fsyncDir()
|
|
849
|
+
return backupPath
|
|
850
|
+
} catch (error) {
|
|
851
|
+
if (error?.code === 'EEXIST') {
|
|
852
|
+
const existing = await fs.readFile(backupPath)
|
|
853
|
+
if (sha256(existing) !== digest) {
|
|
854
|
+
throw fail('storage_error', `an existing ${label} backup does not match the ledger being migrated`, {
|
|
855
|
+
backupPath,
|
|
856
|
+
})
|
|
857
|
+
}
|
|
858
|
+
await fs.chmod(backupPath, 0o600)
|
|
859
|
+
return backupPath
|
|
860
|
+
}
|
|
861
|
+
await fs.unlink(backupPath).catch(() => undefined)
|
|
862
|
+
throw fail('storage_failure', `${label} backup cannot be written: ${errorCodeOf(error)}`)
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** @param {Record<string, any>} state @returns {Promise<void>} */
|
|
867
|
+
async #persist(state) {
|
|
868
|
+
await this.#persistRaw(JSON.stringify(state))
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** @param {string|Buffer} json @returns {Promise<void>} */
|
|
872
|
+
async #persistRaw(json) {
|
|
873
|
+
const temp = path.join(this.root, `.${LEDGER_FILE}.${this.pid}.${randomUUID()}.tmp`)
|
|
874
|
+
let handle
|
|
875
|
+
let stage = 'write'
|
|
876
|
+
try {
|
|
877
|
+
handle = await fs.open(temp, 'wx', 0o600)
|
|
878
|
+
await handle.writeFile(json)
|
|
879
|
+
await handle.sync()
|
|
880
|
+
await handle.close()
|
|
881
|
+
handle = undefined
|
|
882
|
+
stage = 'rename'
|
|
883
|
+
await fs.rename(temp, this.statePath)
|
|
884
|
+
stage = 'chmod'
|
|
885
|
+
await fs.chmod(this.statePath, 0o600)
|
|
886
|
+
stage = 'fsync'
|
|
887
|
+
await this.#fsyncDir()
|
|
888
|
+
} catch (error) {
|
|
889
|
+
if (handle !== undefined) await handle.close().catch(() => undefined)
|
|
890
|
+
await fs.unlink(temp).catch(() => undefined)
|
|
891
|
+
const mayHaveWritten = stage === 'rename' || stage === 'chmod' || stage === 'fsync'
|
|
892
|
+
this.#poison({
|
|
893
|
+
stage,
|
|
894
|
+
mayHaveWritten,
|
|
895
|
+
message: `ledger write failed: ${errorCodeOf(error)}`,
|
|
896
|
+
})
|
|
897
|
+
throw fail('storage_error', 'ledger write failed; safe reopen required before further writes', {
|
|
898
|
+
stage,
|
|
899
|
+
mayHaveWritten,
|
|
900
|
+
})
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
async #fsyncDir() {
|
|
905
|
+
let handle
|
|
906
|
+
try {
|
|
907
|
+
handle = await fs.open(this.root, 'r')
|
|
908
|
+
await handle.sync()
|
|
909
|
+
} catch (error) {
|
|
910
|
+
// Directory fsync is unsupported on a few filesystems; a failed metadata
|
|
911
|
+
// flush is still a storage failure on the supported Linux target.
|
|
912
|
+
if (error?.code === 'EINVAL' || error?.code === 'ENOTSUP' || error?.code === 'EISDIR') return
|
|
913
|
+
throw error
|
|
914
|
+
} finally {
|
|
915
|
+
await handle?.close().catch(() => undefined)
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* Acquire the one-writer claim with an OS-backed SQLite EXCLUSIVE
|
|
921
|
+
* transaction held for the life of this Ledger. The kernel releases it on
|
|
922
|
+
* any process exit, so a crashed writer needs no stale-pid heuristics and
|
|
923
|
+
* two concurrent recoverers cannot both unlink and rebuild a lock file.
|
|
924
|
+
*
|
|
925
|
+
* `writer.lock` remains as bounded, human-readable metadata; it is written
|
|
926
|
+
* only while the database transaction is held and is never consulted to
|
|
927
|
+
* decide ownership.
|
|
928
|
+
* @returns {Promise<void>}
|
|
929
|
+
*/
|
|
930
|
+
async #acquireLock() {
|
|
931
|
+
try {
|
|
932
|
+
const handle = await fs.open(this.lockDbPath, 'a', 0o600)
|
|
933
|
+
await handle.close()
|
|
934
|
+
await fs.chmod(this.lockDbPath, 0o600)
|
|
935
|
+
} catch (error) {
|
|
936
|
+
throw fail('storage_failure', `writer lock database cannot be created: ${errorCodeOf(error)}`)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let db
|
|
940
|
+
try {
|
|
941
|
+
db = new DatabaseSync(this.lockDbPath)
|
|
942
|
+
db.exec('PRAGMA busy_timeout = 0')
|
|
943
|
+
db.exec('BEGIN EXCLUSIVE')
|
|
944
|
+
} catch (error) {
|
|
945
|
+
db?.close?.()
|
|
946
|
+
if (isLocked(error)) {
|
|
947
|
+
throw fail('lock_active', 'session-link state is locked by another live writer')
|
|
948
|
+
}
|
|
949
|
+
throw fail('storage_failure', `writer lock cannot be acquired: ${errorCodeOf(error)}`)
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
const nonce = randomUUID()
|
|
953
|
+
const metadata = {
|
|
954
|
+
version: 1,
|
|
955
|
+
plugin: 'dsh-session-link',
|
|
956
|
+
hostname: this.hostname,
|
|
957
|
+
pid: this.pid,
|
|
958
|
+
processStart: this.processStart,
|
|
959
|
+
nonce,
|
|
960
|
+
createdAt: this.clock(),
|
|
961
|
+
mechanism: 'sqlite-exclusive',
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
db.exec(`
|
|
965
|
+
CREATE TABLE IF NOT EXISTS writer (
|
|
966
|
+
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
967
|
+
version INTEGER NOT NULL,
|
|
968
|
+
plugin TEXT NOT NULL,
|
|
969
|
+
hostname TEXT NOT NULL,
|
|
970
|
+
pid INTEGER NOT NULL,
|
|
971
|
+
processStart TEXT NOT NULL,
|
|
972
|
+
nonce TEXT NOT NULL,
|
|
973
|
+
createdAt INTEGER NOT NULL
|
|
974
|
+
)
|
|
975
|
+
`)
|
|
976
|
+
db.exec('DELETE FROM writer')
|
|
977
|
+
db.prepare(
|
|
978
|
+
'INSERT INTO writer (id, version, plugin, hostname, pid, processStart, nonce, createdAt) VALUES (1, ?, ?, ?, ?, ?, ?, ?)',
|
|
979
|
+
).run(metadata.version, metadata.plugin, metadata.hostname, metadata.pid, metadata.processStart, metadata.nonce, metadata.createdAt)
|
|
980
|
+
await fs.writeFile(this.lockPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 })
|
|
981
|
+
await fs.chmod(this.lockPath, 0o600)
|
|
982
|
+
await this.#fsyncDir()
|
|
983
|
+
} catch (error) {
|
|
984
|
+
try {
|
|
985
|
+
db.exec('ROLLBACK')
|
|
986
|
+
} catch {}
|
|
987
|
+
db.close()
|
|
988
|
+
throw fail('storage_failure', `writer lock metadata cannot be written: ${errorCodeOf(error)}`)
|
|
989
|
+
}
|
|
990
|
+
this.#lockDb = db
|
|
991
|
+
this.#lockNonce = nonce
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/** @returns {Promise<void>} */
|
|
995
|
+
async #releaseLock() {
|
|
996
|
+
if (this.#lockDb === undefined) return
|
|
997
|
+
const db = this.#lockDb
|
|
998
|
+
this.#lockDb = undefined
|
|
999
|
+
const nonce = this.#lockNonce
|
|
1000
|
+
this.#lockNonce = undefined
|
|
1001
|
+
try {
|
|
1002
|
+
db.exec('COMMIT')
|
|
1003
|
+
} catch {
|
|
1004
|
+
try {
|
|
1005
|
+
db.exec('ROLLBACK')
|
|
1006
|
+
} catch {}
|
|
1007
|
+
}
|
|
1008
|
+
try {
|
|
1009
|
+
db.close()
|
|
1010
|
+
} finally {
|
|
1011
|
+
let raw
|
|
1012
|
+
try {
|
|
1013
|
+
raw = await fs.readFile(this.lockPath, 'utf8')
|
|
1014
|
+
} catch (error) {
|
|
1015
|
+
if (error?.code !== 'ENOENT') throw fail('storage_failure', 'writer lock metadata cannot be read during release')
|
|
1016
|
+
}
|
|
1017
|
+
if (raw !== undefined) {
|
|
1018
|
+
let metadata
|
|
1019
|
+
try {
|
|
1020
|
+
metadata = JSON.parse(raw)
|
|
1021
|
+
} catch {
|
|
1022
|
+
metadata = undefined
|
|
1023
|
+
}
|
|
1024
|
+
if (metadata?.nonce === nonce) {
|
|
1025
|
+
await fs.unlink(this.lockPath).catch((error) => {
|
|
1026
|
+
if (error?.code !== 'ENOENT') throw fail('storage_failure', 'writer lock metadata cannot be removed')
|
|
1027
|
+
})
|
|
1028
|
+
await this.#fsyncDir()
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/**
|
|
1035
|
+
* Offline rollback helper for Lead-owned scripts. The caller must ensure the
|
|
1036
|
+
* running service is stopped; this method still requires the writer lock and
|
|
1037
|
+
* re-checks the preflight inside it. It writes a v2 backup, then atomically
|
|
1038
|
+
* replaces ledger.json with the exact v1 backup (emergencyPaused=true).
|
|
1039
|
+
* It is intentionally not exposed by the running plugin/service.
|
|
1040
|
+
* @returns {Promise<{ restored: true, v2BackupPath: string, v1BackupPath: string }>}
|
|
1041
|
+
*/
|
|
1042
|
+
async restoreV1Backup() {
|
|
1043
|
+
if (!this.#opened) throw fail('internal', 'ledger is not open')
|
|
1044
|
+
if (this.#poisoned !== undefined) throw this.#poisonError()
|
|
1045
|
+
const state = this.read()
|
|
1046
|
+
// A v3 ledger has already run the separate v2 -> v3 upgrade. Restoring the
|
|
1047
|
+
// old v1 bytes here would silently discard schema-3 receipts and journals;
|
|
1048
|
+
// the only supported v3 exit is the exact v2 backup restore below.
|
|
1049
|
+
if (state.version !== V2_STATE_VERSION) {
|
|
1050
|
+
throw fail('rollback_conflict', 'v1 restore only supports a v2 ledger; use the v2 upgrade backup for 0.3', {
|
|
1051
|
+
stateVersion: state.version,
|
|
1052
|
+
})
|
|
1053
|
+
}
|
|
1054
|
+
const marker = state.migration
|
|
1055
|
+
if (!isPlainObject(marker)) throw fail('invalid_argument', 'ledger has no v1 migration marker')
|
|
1056
|
+
const v1BackupPath = path.resolve(String(marker.backupPath))
|
|
1057
|
+
if (
|
|
1058
|
+
path.dirname(v1BackupPath) !== this.root ||
|
|
1059
|
+
!path.basename(v1BackupPath).startsWith(V1_BACKUP_PREFIX)
|
|
1060
|
+
) {
|
|
1061
|
+
throw fail('storage_error', 'migration backup path is outside the state root')
|
|
1062
|
+
}
|
|
1063
|
+
let raw
|
|
1064
|
+
try {
|
|
1065
|
+
raw = await fs.readFile(v1BackupPath)
|
|
1066
|
+
} catch (error) {
|
|
1067
|
+
throw fail('storage_failure', `v1 backup cannot be read for rollback: ${errorCodeOf(error)}`)
|
|
1068
|
+
}
|
|
1069
|
+
const v1 = validateV1State(JSON.parse(raw.toString('utf8')))
|
|
1070
|
+
const preflight = rollbackPreflight({ currentState: state, migration: marker, backupRaw: raw })
|
|
1071
|
+
if (preflight.ok !== true) {
|
|
1072
|
+
throw fail('rollback_conflict', 'rollback preflight rejected; keep 0.2 and export data for manual merge', {
|
|
1073
|
+
reasons: preflight.reasons,
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
const currentJson = JSON.stringify(state)
|
|
1077
|
+
const v2Digest = sha256(Buffer.from(currentJson, 'utf8'))
|
|
1078
|
+
const v2BackupPath = await this.#writeExactBackup(
|
|
1079
|
+
Buffer.from(currentJson, 'utf8'),
|
|
1080
|
+
v2Digest,
|
|
1081
|
+
V2_ROLLBACK_BACKUP_PREFIX,
|
|
1082
|
+
'rollback v2',
|
|
1083
|
+
)
|
|
1084
|
+
v1.settings.emergencyPaused = true
|
|
1085
|
+
v1.updatedAt = this.clock()
|
|
1086
|
+
await this.#persist(v1)
|
|
1087
|
+
this.#state = v1
|
|
1088
|
+
return { restored: true, v2BackupPath, v1BackupPath }
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Offline v3 -> v2 restore for Lead-owned scripts. It requires the exact v2
|
|
1093
|
+
* backup recorded by the v2 -> v3 upgrade and rejects any write after the
|
|
1094
|
+
* upgrade (revision/settingsRevision or baseline content changed). It writes
|
|
1095
|
+
* an exact v3 backup first, then atomically replaces ledger.json with the
|
|
1096
|
+
* original v2 bytes. The running service must be stopped; this method is not
|
|
1097
|
+
* exposed by the plugin.
|
|
1098
|
+
* @returns {Promise<{ restored: true, v3BackupPath: string, v2BackupPath: string }>}
|
|
1099
|
+
*/
|
|
1100
|
+
async restoreV2Backup() {
|
|
1101
|
+
if (!this.#opened) throw fail('internal', 'ledger is not open')
|
|
1102
|
+
if (this.#poisoned !== undefined) throw this.#poisonError()
|
|
1103
|
+
const state = this.read()
|
|
1104
|
+
if (state.version !== STATE_VERSION) {
|
|
1105
|
+
throw fail('rollback_conflict', 'v2 upgrade restore only supports a v3 ledger', {
|
|
1106
|
+
stateVersion: state.version,
|
|
1107
|
+
})
|
|
1108
|
+
}
|
|
1109
|
+
const upgrade = state.upgrade
|
|
1110
|
+
if (!isPlainObject(upgrade)) throw fail('invalid_argument', 'ledger has no v2 upgrade marker')
|
|
1111
|
+
const v2BackupPath = path.resolve(String(upgrade.backupPath))
|
|
1112
|
+
if (
|
|
1113
|
+
path.dirname(v2BackupPath) !== this.root ||
|
|
1114
|
+
!path.basename(v2BackupPath).startsWith(V2_BACKUP_PREFIX)
|
|
1115
|
+
) {
|
|
1116
|
+
throw fail('storage_error', 'upgrade backup path is outside the state root')
|
|
1117
|
+
}
|
|
1118
|
+
let raw
|
|
1119
|
+
try {
|
|
1120
|
+
raw = await fs.readFile(v2BackupPath)
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
throw fail('storage_failure', `v2 backup cannot be read for rollback: ${errorCodeOf(error)}`)
|
|
1123
|
+
}
|
|
1124
|
+
let v2
|
|
1125
|
+
try {
|
|
1126
|
+
v2 = validateV2State(JSON.parse(raw.toString('utf8')))
|
|
1127
|
+
} catch {
|
|
1128
|
+
throw fail('rollback_conflict', 'the recorded v2 upgrade backup is invalid; refusing to replace v3')
|
|
1129
|
+
}
|
|
1130
|
+
const preflight = v3ToV2Preflight({ currentState: state, upgrade, backupRaw: raw })
|
|
1131
|
+
if (preflight.ok !== true) {
|
|
1132
|
+
throw fail('rollback_conflict', 'v3 -> v2 preflight rejected; no post-upgrade transaction is allowed', {
|
|
1133
|
+
reasons: preflight.reasons,
|
|
1134
|
+
})
|
|
1135
|
+
}
|
|
1136
|
+
const currentJson = Buffer.from(JSON.stringify(state), 'utf8')
|
|
1137
|
+
const v3Digest = sha256(currentJson)
|
|
1138
|
+
const v3BackupPath = await this.#writeExactBackup(
|
|
1139
|
+
currentJson,
|
|
1140
|
+
v3Digest,
|
|
1141
|
+
V3_ROLLBACK_BACKUP_PREFIX,
|
|
1142
|
+
'rollback v3',
|
|
1143
|
+
)
|
|
1144
|
+
await this.#persistRaw(raw)
|
|
1145
|
+
this.#state = v2
|
|
1146
|
+
return { restored: true, v3BackupPath, v2BackupPath }
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** @param {unknown} error @returns {boolean} */
|
|
1151
|
+
function isLocked(error) {
|
|
1152
|
+
return (
|
|
1153
|
+
(error && typeof error === 'object' && (error.errcode === 5 || error.errstr === 'database is locked')) ||
|
|
1154
|
+
(error instanceof Error && error.message.toLowerCase().includes('database is locked'))
|
|
1155
|
+
)
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** @param {unknown} error @returns {string} */
|
|
1159
|
+
function errorCodeOf(error) {
|
|
1160
|
+
if (error && typeof error === 'object' && typeof error.code === 'string') return error.code
|
|
1161
|
+
return 'unknown'
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
/** @param {unknown} value */
|
|
1165
|
+
function cloneResult(value) {
|
|
1166
|
+
if (value === undefined) return undefined
|
|
1167
|
+
try {
|
|
1168
|
+
return structuredClone(value)
|
|
1169
|
+
} catch {
|
|
1170
|
+
return value
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export { rollbackPreflight } from './migration.js'
|