@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,741 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen schema chain: v0.1 validation, v1 -> v2 conversion, v2 -> v3 conversion.
|
|
3
|
+
*
|
|
4
|
+
* This module intentionally duplicates old-format rules with literal values.
|
|
5
|
+
* It must never read the current STATE_VERSION for a historical decision: a
|
|
6
|
+
* later protocol change (including 0.3's schema 3) must not silently redefine
|
|
7
|
+
* what v1/v2 data was allowed to contain or how it converted. V2_STATE_VERSION
|
|
8
|
+
* is frozen at 2 and every v2 validation/rollback path uses that literal, while
|
|
9
|
+
* the v0.3 conversion writes schema 3 explicitly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
AUTO_HELD_REASONS,
|
|
16
|
+
RETIRED_CHAIN_PAUSE_REASONS,
|
|
17
|
+
V1_STATE_VERSION,
|
|
18
|
+
V2_BACKUP_PREFIX,
|
|
19
|
+
} from './constants.js'
|
|
20
|
+
import { fail } from './errors.js'
|
|
21
|
+
import { isPlainObject } from './validation.js'
|
|
22
|
+
|
|
23
|
+
/** Frozen schema version produced by the historical v1 -> v2 conversion. */
|
|
24
|
+
export const V2_STATE_VERSION = 2
|
|
25
|
+
/** Frozen schema version produced by the v0.3 v2 -> v3 conversion. */
|
|
26
|
+
export const V3_STATE_VERSION = 3
|
|
27
|
+
|
|
28
|
+
/** Frozen v0.1 settings vocabulary (nine keys, exactly as shipped in 0.1.0). */
|
|
29
|
+
export const V1_DEFAULT_SETTINGS = Object.freeze({
|
|
30
|
+
enabled: true,
|
|
31
|
+
emergencyPaused: false,
|
|
32
|
+
autoCollaborate: true,
|
|
33
|
+
maxMessages: 20,
|
|
34
|
+
maxHops: 8,
|
|
35
|
+
maxConcurrent: 2,
|
|
36
|
+
maxTextChars: 12000,
|
|
37
|
+
requestTimeoutMs: 30 * 60 * 1000,
|
|
38
|
+
retentionDays: 30,
|
|
39
|
+
})
|
|
40
|
+
export const V1_SETTINGS_KEYS = Object.freeze(Object.keys(V1_DEFAULT_SETTINGS))
|
|
41
|
+
const V1_SETTINGS_BOUNDS = Object.freeze({
|
|
42
|
+
maxMessages: { min: 2, max: 20 },
|
|
43
|
+
maxHops: { min: 1, max: 8 },
|
|
44
|
+
maxConcurrent: { min: 1, max: 2 },
|
|
45
|
+
maxTextChars: { min: 1000, max: 12000 },
|
|
46
|
+
requestTimeoutMs: { min: 60_000, max: 86_400_000 },
|
|
47
|
+
retentionDays: { min: 1, max: 90 },
|
|
48
|
+
})
|
|
49
|
+
const V1_MAX_MESSAGES = 10_000
|
|
50
|
+
const V1_CHAIN_MAX_MESSAGES = 20
|
|
51
|
+
const V1_CHAIN_MAX_HOPS = 8
|
|
52
|
+
const V1_DISPATCH_STATUSES = Object.freeze([
|
|
53
|
+
'held',
|
|
54
|
+
'pending',
|
|
55
|
+
'dispatching',
|
|
56
|
+
'accepted',
|
|
57
|
+
'failed',
|
|
58
|
+
'unknown',
|
|
59
|
+
'cancelled',
|
|
60
|
+
])
|
|
61
|
+
const V1_REPLY_STATUSES = Object.freeze(['none', 'waiting', 'answered', 'timed_out'])
|
|
62
|
+
const V1_MESSAGE_KINDS = Object.freeze(['notification', 'request', 'reply'])
|
|
63
|
+
const V1_CHAIN_STATES = Object.freeze(['open', 'paused', 'closed'])
|
|
64
|
+
|
|
65
|
+
/** @param {unknown} error @returns {never} */
|
|
66
|
+
function corrupt(message) {
|
|
67
|
+
throw fail('storage_corrupt', message)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** @param {unknown} value @param {string} label */
|
|
71
|
+
function record(value, label) {
|
|
72
|
+
if (!isPlainObject(value)) corrupt(`${label} is invalid`)
|
|
73
|
+
return value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** @param {unknown} value @param {string} label */
|
|
77
|
+
function nonEmptyString(value, label) {
|
|
78
|
+
if (typeof value !== 'string' || value.length === 0) corrupt(`${label} is invalid`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {unknown} value @param {string} label @param {number} [min] */
|
|
82
|
+
function safeInteger(value, label, min = 0) {
|
|
83
|
+
if (!Number.isSafeInteger(value) || value < min) corrupt(`${label} is invalid`)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {unknown} value @param {string} label */
|
|
87
|
+
function optionalTime(value, label) {
|
|
88
|
+
if (value === undefined || value === null) return
|
|
89
|
+
safeInteger(value, label)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {unknown} value */
|
|
93
|
+
function validateV1Settings(value) {
|
|
94
|
+
const settings = record(value, 'ledger settings')
|
|
95
|
+
for (const key of V1_SETTINGS_KEYS) {
|
|
96
|
+
if (!(key in settings)) corrupt(`ledger settings are missing ${key}`)
|
|
97
|
+
}
|
|
98
|
+
for (const key of ['enabled', 'emergencyPaused', 'autoCollaborate']) {
|
|
99
|
+
if (typeof settings[key] !== 'boolean') corrupt('ledger settings are invalid')
|
|
100
|
+
}
|
|
101
|
+
for (const [key, bounds] of Object.entries(V1_SETTINGS_BOUNDS)) {
|
|
102
|
+
const number = settings[key]
|
|
103
|
+
if (!Number.isSafeInteger(number) || number < bounds.min || number > bounds.max) {
|
|
104
|
+
corrupt('ledger settings are invalid')
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
enabled: settings.enabled,
|
|
109
|
+
emergencyPaused: settings.emergencyPaused,
|
|
110
|
+
autoCollaborate: settings.autoCollaborate,
|
|
111
|
+
maxMessages: settings.maxMessages,
|
|
112
|
+
maxHops: settings.maxHops,
|
|
113
|
+
maxConcurrent: settings.maxConcurrent,
|
|
114
|
+
maxTextChars: settings.maxTextChars,
|
|
115
|
+
requestTimeoutMs: settings.requestTimeoutMs,
|
|
116
|
+
retentionDays: settings.retentionDays,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** @param {unknown} budget */
|
|
121
|
+
function validateV1Budget(budget) {
|
|
122
|
+
const value = record(budget, 'ledger budget')
|
|
123
|
+
if (!Array.isArray(value.events)) corrupt('ledger budget events are invalid')
|
|
124
|
+
if (value.events.length > V1_MAX_MESSAGES) corrupt('ledger budget event list is oversized')
|
|
125
|
+
for (const [index, event] of value.events.entries()) {
|
|
126
|
+
const entry = record(event, `ledger budget event ${index}`)
|
|
127
|
+
safeInteger(entry.at, `ledger budget event ${index}.at`)
|
|
128
|
+
nonEmptyString(entry.from, `ledger budget event ${index}.from`)
|
|
129
|
+
nonEmptyString(entry.to, `ledger budget event ${index}.to`)
|
|
130
|
+
}
|
|
131
|
+
const lastSubmitAt = record(value.lastSubmitAt, 'ledger budget lastSubmitAt')
|
|
132
|
+
for (const [sessionId, at] of Object.entries(lastSubmitAt)) {
|
|
133
|
+
nonEmptyString(sessionId, 'ledger budget lastSubmitAt session')
|
|
134
|
+
safeInteger(at, `ledger budget lastSubmitAt ${sessionId}`)
|
|
135
|
+
}
|
|
136
|
+
const daily = record(value.daily, 'ledger budget daily')
|
|
137
|
+
for (const [day, count] of Object.entries(daily)) {
|
|
138
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) corrupt('ledger daily key is invalid')
|
|
139
|
+
safeInteger(count, `ledger daily ${day}`)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @param {unknown} latches */
|
|
144
|
+
function validateV1Latches(latches) {
|
|
145
|
+
if (!Array.isArray(latches)) corrupt('ledger latches are invalid')
|
|
146
|
+
const ids = new Set()
|
|
147
|
+
for (const latch of latches) {
|
|
148
|
+
const entry = record(latch, 'ledger latch')
|
|
149
|
+
nonEmptyString(entry.id, 'ledger latch id')
|
|
150
|
+
if (ids.has(entry.id)) corrupt('ledger latch id is duplicated')
|
|
151
|
+
ids.add(entry.id)
|
|
152
|
+
nonEmptyString(entry.scope, 'ledger latch scope')
|
|
153
|
+
nonEmptyString(entry.reason, 'ledger latch reason')
|
|
154
|
+
safeInteger(entry.createdAt, 'ledger latch createdAt')
|
|
155
|
+
optionalTime(entry.releasedAt, 'ledger latch releasedAt')
|
|
156
|
+
if (entry.releasedAt !== undefined && entry.releasedAt < entry.createdAt) {
|
|
157
|
+
corrupt('ledger latch release precedes creation')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** @param {string} id @param {unknown} chain */
|
|
163
|
+
function validateV1Chain(id, chain) {
|
|
164
|
+
const value = record(chain, `ledger chain ${id}`)
|
|
165
|
+
if (value.id !== id) corrupt(`ledger chain ${id} id mismatch`)
|
|
166
|
+
nonEmptyString(value.rootOwner, `ledger chain ${id} rootOwner`)
|
|
167
|
+
safeInteger(value.createdAt, `ledger chain ${id} createdAt`)
|
|
168
|
+
if (!V1_CHAIN_STATES.includes(value.state)) corrupt(`ledger chain ${id} state is invalid`)
|
|
169
|
+
safeInteger(value.maxMessages, `ledger chain ${id} maxMessages`, 1)
|
|
170
|
+
if (value.maxMessages > V1_CHAIN_MAX_MESSAGES) corrupt(`ledger chain ${id} maxMessages is oversized`)
|
|
171
|
+
safeInteger(value.maxHops, `ledger chain ${id} maxHops`, 1)
|
|
172
|
+
if (value.maxHops > V1_CHAIN_MAX_HOPS) corrupt(`ledger chain ${id} maxHops is oversized`)
|
|
173
|
+
if (value.messageCount !== undefined) safeInteger(value.messageCount, `ledger chain ${id} messageCount`)
|
|
174
|
+
optionalTime(value.pausedAt, `ledger chain ${id} pausedAt`)
|
|
175
|
+
optionalTime(value.closedAt, `ledger chain ${id} closedAt`)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* @param {unknown} message
|
|
180
|
+
* @param {Set<string>} chainIds
|
|
181
|
+
* @param {Set<string>} messageIds
|
|
182
|
+
* @param {Record<string, any>} chains
|
|
183
|
+
*/
|
|
184
|
+
function validateV1Message(message, chainIds, messageIds, chains) {
|
|
185
|
+
const value = record(message, 'ledger message')
|
|
186
|
+
nonEmptyString(value.id, 'ledger message id')
|
|
187
|
+
if (messageIds.has(value.id)) corrupt('ledger message id is duplicated')
|
|
188
|
+
messageIds.add(value.id)
|
|
189
|
+
nonEmptyString(value.from, `ledger message ${value.id} from`)
|
|
190
|
+
nonEmptyString(value.to, `ledger message ${value.id} to`)
|
|
191
|
+
if (value.from === value.to) corrupt(`ledger message ${value.id} targets itself`)
|
|
192
|
+
if (!V1_MESSAGE_KINDS.includes(value.kind)) corrupt(`ledger message ${value.id} kind is invalid`)
|
|
193
|
+
nonEmptyString(value.chainId, `ledger message ${value.id} chainId`)
|
|
194
|
+
if (!chainIds.has(value.chainId)) corrupt(`ledger message ${value.id} references an unknown chain`)
|
|
195
|
+
safeInteger(value.hop, `ledger message ${value.id} hop`)
|
|
196
|
+
safeInteger(value.createdAt, `ledger message ${value.id} createdAt`)
|
|
197
|
+
if (!V1_DISPATCH_STATUSES.includes(value.status)) corrupt(`ledger message ${value.id} status is invalid`)
|
|
198
|
+
if (!V1_REPLY_STATUSES.includes(value.replyStatus)) corrupt(`ledger message ${value.id} replyStatus is invalid`)
|
|
199
|
+
if (value.operationId !== undefined) nonEmptyString(value.operationId, `ledger message ${value.id} operationId`)
|
|
200
|
+
if (value.deliveryAttempts !== undefined) safeInteger(value.deliveryAttempts, `ledger message ${value.id} deliveryAttempts`)
|
|
201
|
+
if (value.durableMessageId !== undefined && value.durableMessageId !== null) {
|
|
202
|
+
nonEmptyString(value.durableMessageId, `ledger message ${value.id} durableMessageId`)
|
|
203
|
+
}
|
|
204
|
+
const content = record(value.content, `ledger message ${value.id} content`)
|
|
205
|
+
if (typeof content.text !== 'string') corrupt(`ledger message ${value.id} content.text is invalid`)
|
|
206
|
+
if (!Array.isArray(content.references)) corrupt(`ledger message ${value.id} references are invalid`)
|
|
207
|
+
for (const [index, reference] of content.references.entries()) {
|
|
208
|
+
const entry = record(reference, `ledger message ${value.id} reference ${index}`)
|
|
209
|
+
nonEmptyString(entry.sessionId, `ledger message ${value.id} reference sessionId`)
|
|
210
|
+
safeInteger(entry.seq, `ledger message ${value.id} reference seq`)
|
|
211
|
+
if (typeof entry.text !== 'string') corrupt(`ledger message ${value.id} reference text is invalid`)
|
|
212
|
+
}
|
|
213
|
+
if (!Array.isArray(content.paths) || content.paths.some((item) => typeof item !== 'string')) {
|
|
214
|
+
corrupt(`ledger message ${value.id} paths are invalid`)
|
|
215
|
+
}
|
|
216
|
+
if (value.error !== undefined && value.error !== null) {
|
|
217
|
+
const error = record(value.error, `ledger message ${value.id} error`)
|
|
218
|
+
nonEmptyString(error.code, `ledger message ${value.id} error.code`)
|
|
219
|
+
if (typeof error.message !== 'string') corrupt(`ledger message ${value.id} error.message is invalid`)
|
|
220
|
+
}
|
|
221
|
+
for (const [field, time] of [
|
|
222
|
+
['acceptedAt', value.acceptedAt],
|
|
223
|
+
['deadlineAt', value.deadlineAt],
|
|
224
|
+
['timedOutAt', value.timedOutAt],
|
|
225
|
+
['lateReplyAt', value.lateReplyAt],
|
|
226
|
+
['heldAt', value.heldAt],
|
|
227
|
+
['cancelledAt', value.cancelledAt],
|
|
228
|
+
['budgetReservedAt', value.budgetReservedAt],
|
|
229
|
+
]) {
|
|
230
|
+
optionalTime(time, `ledger message ${value.id} ${field}`)
|
|
231
|
+
}
|
|
232
|
+
if (value.budgetReservedDay !== undefined) {
|
|
233
|
+
if (typeof value.budgetReservedDay !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value.budgetReservedDay)) {
|
|
234
|
+
corrupt(`ledger message ${value.id} budgetReservedDay is invalid`)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const chain = chains[value.chainId]
|
|
238
|
+
if (value.hop > chain.maxHops) {
|
|
239
|
+
corrupt(`ledger message ${value.id} hop exceeds its chain budget`)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @param {string} key @param {unknown} operation */
|
|
244
|
+
function validateV1Operation(key, operation) {
|
|
245
|
+
const value = record(operation, `ledger operation ${key}`)
|
|
246
|
+
if (value.key !== undefined && value.key !== key) corrupt('ledger operation key mismatch')
|
|
247
|
+
nonEmptyString(value.from, `ledger operation ${key} from`)
|
|
248
|
+
nonEmptyString(value.operationId, `ledger operation ${key} operationId`)
|
|
249
|
+
nonEmptyString(value.fingerprint, `ledger operation ${key} fingerprint`)
|
|
250
|
+
if (!Array.isArray(value.targets) || value.targets.length === 0) {
|
|
251
|
+
corrupt(`ledger operation ${key} targets are invalid`)
|
|
252
|
+
}
|
|
253
|
+
if (new Set(value.targets).size !== value.targets.length) corrupt(`ledger operation ${key} targets are duplicated`)
|
|
254
|
+
for (const target of value.targets) nonEmptyString(target, `ledger operation ${key} target`)
|
|
255
|
+
const messageIds = record(value.messageIds, `ledger operation ${key} messageIds`)
|
|
256
|
+
for (const target of value.targets) nonEmptyString(messageIds[target], `ledger operation ${key} messageId`)
|
|
257
|
+
safeInteger(value.at, `ledger operation ${key} at`)
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Validate one v0.1 ledger with the frozen v0.1 rules. Returns a detached copy
|
|
262
|
+
* so migration cannot mutate the parsed source or its raw backup.
|
|
263
|
+
* @param {unknown} state
|
|
264
|
+
* @returns {Record<string, any>}
|
|
265
|
+
*/
|
|
266
|
+
export function validateV1State(state) {
|
|
267
|
+
if (!isPlainObject(state)) corrupt('ledger state is not an object')
|
|
268
|
+
const value = structuredClone(state)
|
|
269
|
+
if (value.version !== V1_STATE_VERSION) corrupt(`ledger uses unsupported state version ${String(value.version)}`)
|
|
270
|
+
if (!Number.isSafeInteger(value.revision) || value.revision < 1) corrupt('ledger revision is invalid')
|
|
271
|
+
if (!Number.isSafeInteger(value.createdAt) || value.createdAt < 0) corrupt('ledger createdAt is invalid')
|
|
272
|
+
if (!Number.isSafeInteger(value.updatedAt) || value.updatedAt < 0) corrupt('ledger updatedAt is invalid')
|
|
273
|
+
if (!Array.isArray(value.messages)) corrupt('ledger messages are invalid')
|
|
274
|
+
if (value.messages.length > V1_MAX_MESSAGES) corrupt('ledger message count is oversized')
|
|
275
|
+
if (!isPlainObject(value.chains)) corrupt('ledger chains are invalid')
|
|
276
|
+
if (!isPlainObject(value.operations)) corrupt('ledger operations are invalid')
|
|
277
|
+
if (!Array.isArray(value.latches)) corrupt('ledger latches are invalid')
|
|
278
|
+
value.settings = validateV1Settings(value.settings)
|
|
279
|
+
validateV1Budget(value.budget)
|
|
280
|
+
validateV1Latches(value.latches)
|
|
281
|
+
const chainIds = new Set(Object.keys(value.chains))
|
|
282
|
+
for (const [id, chain] of Object.entries(value.chains)) validateV1Chain(id, chain)
|
|
283
|
+
const messageIds = new Set()
|
|
284
|
+
const messagesById = new Map()
|
|
285
|
+
for (const message of value.messages) {
|
|
286
|
+
validateV1Message(message, chainIds, messageIds, value.chains)
|
|
287
|
+
messagesById.set(message.id, message)
|
|
288
|
+
}
|
|
289
|
+
for (const message of value.messages) {
|
|
290
|
+
if (message.replyTo !== undefined && message.replyTo !== null && message.replyTo !== message.parentMessageId) {
|
|
291
|
+
corrupt(`ledger message ${message.id} replyTo mismatch`)
|
|
292
|
+
}
|
|
293
|
+
for (const field of ['parentMessageId', 'replyTo']) {
|
|
294
|
+
const parentId = message[field]
|
|
295
|
+
if (parentId === undefined || parentId === null) continue
|
|
296
|
+
const parent = messagesById.get(parentId)
|
|
297
|
+
if (parent === undefined) corrupt(`ledger message ${message.id} ${field} is dangling`)
|
|
298
|
+
if (parent.chainId !== message.chainId) corrupt(`ledger message ${message.id} ${field} crosses chains`)
|
|
299
|
+
if (message.hop !== parent.hop + 1) corrupt(`ledger message ${message.id} hop does not follow its parent`)
|
|
300
|
+
if (message.kind === 'reply') {
|
|
301
|
+
if (parent.kind !== 'request' || parent.to !== message.from) {
|
|
302
|
+
corrupt(`ledger message ${message.id} reply parent is not a request from the original recipient`)
|
|
303
|
+
}
|
|
304
|
+
} else if (parent.from !== message.from && parent.to !== message.from) {
|
|
305
|
+
corrupt(`ledger message ${message.id} parent does not involve the sender`)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
for (const [key, operation] of Object.entries(value.operations)) validateV1Operation(key, operation)
|
|
310
|
+
return value
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** @param {Record<string, any>} settings */
|
|
314
|
+
function mapV1Settings(settings) {
|
|
315
|
+
return {
|
|
316
|
+
enabled: settings.enabled,
|
|
317
|
+
paused: settings.emergencyPaused === true || settings.autoCollaborate === false,
|
|
318
|
+
maxConcurrent: settings.maxConcurrent === 2 ? 3 : settings.maxConcurrent,
|
|
319
|
+
requestTimeoutMs: settings.requestTimeoutMs,
|
|
320
|
+
retentionDays: settings.retentionDays,
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* v0.1 Engine.send can create a held row with only error.code and no
|
|
326
|
+
* heldReason (emergencyPaused/autoCollaborate=false). Normalize that real old
|
|
327
|
+
* shape to the v2 vocabulary, but never let error.code override an explicit
|
|
328
|
+
* heldReason (manual/custom/unknown stays conservative).
|
|
329
|
+
*/
|
|
330
|
+
const V1_AUTO_HELD_ERROR_CODES = Object.freeze([
|
|
331
|
+
'emergency_paused',
|
|
332
|
+
'auto_collaborate_off',
|
|
333
|
+
'disabled',
|
|
334
|
+
'held_full',
|
|
335
|
+
])
|
|
336
|
+
|
|
337
|
+
/** @param {Record<string, any>} message */
|
|
338
|
+
function normalizeHeldReason(message) {
|
|
339
|
+
if (message.status !== 'held') return
|
|
340
|
+
if (message.heldReason !== undefined && message.heldReason !== null) return
|
|
341
|
+
const code = message.error?.code
|
|
342
|
+
if (typeof code === 'string' && V1_AUTO_HELD_ERROR_CODES.includes(code)) {
|
|
343
|
+
message.heldReason = code
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @param {Record<string, any>} message */
|
|
348
|
+
function isAutomaticGlobalHeld(message) {
|
|
349
|
+
if (message.status !== 'held') return false
|
|
350
|
+
if (message.heldReason !== undefined && message.heldReason !== null) {
|
|
351
|
+
return AUTO_HELD_REASONS.includes(message.heldReason)
|
|
352
|
+
}
|
|
353
|
+
if (message.heldOverflow === true) return true
|
|
354
|
+
const code = message.error?.code
|
|
355
|
+
return typeof code === 'string' && V1_AUTO_HELD_ERROR_CODES.includes(code)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** @param {Record<string, any>} message @param {Record<string, any>} chains */
|
|
359
|
+
function chainIsOpen(message, chains) {
|
|
360
|
+
return chains[message.chainId]?.state === 'open'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Derive a completion time only from explicit timestamps the v0.1 record
|
|
365
|
+
* already carries. Missing evidence is left absent so retention keeps the
|
|
366
|
+
* record conservatively.
|
|
367
|
+
* @param {Record<string, any>} message
|
|
368
|
+
* @param {Record<string, any>} chains
|
|
369
|
+
* @returns {number | undefined}
|
|
370
|
+
*/
|
|
371
|
+
function derivedCompletedAt(message, chains) {
|
|
372
|
+
if (Number.isSafeInteger(message.completedAt)) return message.completedAt
|
|
373
|
+
if (message.status === 'failed') return message.failedAt
|
|
374
|
+
if (message.status === 'cancelled') return message.cancelledAt
|
|
375
|
+
if (message.kind === 'notification' || message.kind === 'reply') {
|
|
376
|
+
return message.status === 'accepted' && Number.isSafeInteger(message.acceptedAt) ? message.acceptedAt : undefined
|
|
377
|
+
}
|
|
378
|
+
if (message.kind === 'request') {
|
|
379
|
+
if (message.replyStatus === 'answered' && Number.isSafeInteger(message.answeredAt)) return message.answeredAt
|
|
380
|
+
if (message.replyStatus === 'timed_out' && Number.isSafeInteger(message.timedOutAt)) return message.timedOutAt
|
|
381
|
+
if (message.status === 'cancelled' && Number.isSafeInteger(message.cancelledAt)) return message.cancelledAt
|
|
382
|
+
if (message.status === 'accepted' && chains[message.chainId]?.state === 'closed' && Number.isSafeInteger(chains[message.chainId].closedAt)) {
|
|
383
|
+
return chains[message.chainId].closedAt
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return undefined
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @param {Record<string, any>} message @param {number} now @param {Record<string, any>} chains @param {Record<string, any>} settings */
|
|
390
|
+
function migrateMessage(message, now, chains, settings) {
|
|
391
|
+
const next = structuredClone(message)
|
|
392
|
+
if (!Number.isSafeInteger(next.contentBytes)) {
|
|
393
|
+
try {
|
|
394
|
+
next.contentBytes = Buffer.byteLength(JSON.stringify(next.content), 'utf8')
|
|
395
|
+
} catch {
|
|
396
|
+
next.contentBytes = 0
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
normalizeHeldReason(next)
|
|
400
|
+
const completedAt = derivedCompletedAt(next, chains)
|
|
401
|
+
if (completedAt !== undefined && !Number.isSafeInteger(next.completedAt)) next.completedAt = completedAt
|
|
402
|
+
if (isAutomaticGlobalHeld(next) && settings.enabled === true && settings.paused !== true && chainIsOpen(next, chains)) {
|
|
403
|
+
next.status = 'pending'
|
|
404
|
+
next.error = null
|
|
405
|
+
next.heldAt = null
|
|
406
|
+
next.heldReason = null
|
|
407
|
+
next.autoResumedAt = now
|
|
408
|
+
delete next.heldOverflow
|
|
409
|
+
if ('releaseAuthorized' in next) next.releaseAuthorized = false
|
|
410
|
+
}
|
|
411
|
+
// Historical budget reservations/release flags are audit-only in v2.
|
|
412
|
+
delete next.budgetReservedAt
|
|
413
|
+
delete next.budgetReservedDay
|
|
414
|
+
return next
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/** @param {Record<string, any>} chain */
|
|
418
|
+
function migrateChain(chain) {
|
|
419
|
+
const next = structuredClone(chain)
|
|
420
|
+
if (next.state === 'paused' && RETIRED_CHAIN_PAUSE_REASONS.includes(next.pausedReason)) {
|
|
421
|
+
return {
|
|
422
|
+
chain: { ...next, state: 'open', pausedAt: undefined, pausedReason: undefined },
|
|
423
|
+
retired: { chainId: next.id, pausedReason: next.pausedReason, pausedAt: next.pausedAt ?? null },
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return { chain: next, retired: undefined }
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Sort object keys recursively so a digest does not depend on JSON order. */
|
|
430
|
+
export function canonicalJson(value) {
|
|
431
|
+
if (Array.isArray(value)) return `[${value.map((item) => canonicalJson(item)).join(',')}]`
|
|
432
|
+
if (isPlainObject(value)) {
|
|
433
|
+
const keys = Object.keys(value).sort()
|
|
434
|
+
return `{${keys
|
|
435
|
+
.filter((key) => value[key] !== undefined)
|
|
436
|
+
.map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
|
437
|
+
.join(',')}}`
|
|
438
|
+
}
|
|
439
|
+
if (value === undefined) return 'null'
|
|
440
|
+
return JSON.stringify(value)
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** @param {unknown} bytes @returns {string} */
|
|
444
|
+
export function sha256(bytes) {
|
|
445
|
+
return createHash('sha256').update(bytes).digest('hex')
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Digest used for the conservative rollback gate: the persisted v2 snapshot
|
|
450
|
+
* excluding the mutable revision fields and the private marker itself.
|
|
451
|
+
* @param {Record<string, any>} state
|
|
452
|
+
* @returns {string}
|
|
453
|
+
*/
|
|
454
|
+
export function baselineStateDigest(state) {
|
|
455
|
+
const copy = structuredClone(state)
|
|
456
|
+
delete copy.revision
|
|
457
|
+
delete copy.updatedAt
|
|
458
|
+
delete copy.migration
|
|
459
|
+
return sha256(Buffer.from(canonicalJson(copy), 'utf8'))
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Digest for the v3 -> v2 rollback gate. Unlike the v1 marker digest this keeps
|
|
464
|
+
* the v1 migration marker (it is real v2 content) and ignores only the v3
|
|
465
|
+
* schema upgrade marker plus the mutable revision fields.
|
|
466
|
+
* @param {Record<string, any>} state
|
|
467
|
+
* @returns {string}
|
|
468
|
+
*/
|
|
469
|
+
export function baselineV3StateDigest(state) {
|
|
470
|
+
const copy = structuredClone(state)
|
|
471
|
+
delete copy.revision
|
|
472
|
+
delete copy.updatedAt
|
|
473
|
+
delete copy.upgrade
|
|
474
|
+
return sha256(Buffer.from(canonicalJson(copy), 'utf8'))
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Fixed legacy receipt created for every row that predates schema 3. */
|
|
478
|
+
function legacyReceipt() {
|
|
479
|
+
return {
|
|
480
|
+
policy: 'legacy-accepted',
|
|
481
|
+
state: 'uncertain',
|
|
482
|
+
receivedAt: null,
|
|
483
|
+
receivedSeq: null,
|
|
484
|
+
timeoutMs: null,
|
|
485
|
+
transfer: null,
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Pure v2 -> v3 conversion. It never deletes messages/chains/operations and
|
|
491
|
+
* never runs retention cleanup. Every old row receives a legacy-accepted
|
|
492
|
+
* receipt with no invented timing evidence; all IDs, associations, statuses,
|
|
493
|
+
* content, deadlines, timeout and answer history are preserved byte-for-value.
|
|
494
|
+
* @param {Record<string, any>} v2
|
|
495
|
+
* @param {{ now: number, fromStateSha256: string, backupPath: string }} options
|
|
496
|
+
* @returns {Record<string, any>}
|
|
497
|
+
*/
|
|
498
|
+
export function migrateV2State(v2, options) {
|
|
499
|
+
const state = structuredClone(v2)
|
|
500
|
+
// v2 never owns these v3-only private fields; drop any unknown copy so a
|
|
501
|
+
// hand-edited v2 file cannot preload a journal or upgrade marker.
|
|
502
|
+
delete state.upgrade
|
|
503
|
+
delete state.transfers
|
|
504
|
+
for (const message of state.messages) message.receipt = legacyReceipt()
|
|
505
|
+
state.version = V3_STATE_VERSION
|
|
506
|
+
state.updatedAt = options.now
|
|
507
|
+
state.upgrade = {
|
|
508
|
+
fromVersion: V2_STATE_VERSION,
|
|
509
|
+
fromStateSha256: options.fromStateSha256,
|
|
510
|
+
migratedAt: options.now,
|
|
511
|
+
migratedRevision: v2.revision,
|
|
512
|
+
migratedSettingsRevision: v2.settingsRevision,
|
|
513
|
+
backupPath: options.backupPath,
|
|
514
|
+
baselineStateDigest: baselineV3StateDigest(state),
|
|
515
|
+
}
|
|
516
|
+
return state
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Validate the private v2 -> v3 upgrade marker.
|
|
521
|
+
* @param {unknown} upgrade
|
|
522
|
+
* @returns {Record<string, any>}
|
|
523
|
+
*/
|
|
524
|
+
export function validateUpgradeMarker(upgrade) {
|
|
525
|
+
const value = record(upgrade, 'ledger upgrade marker')
|
|
526
|
+
if (value.fromVersion !== V2_STATE_VERSION) corrupt('ledger upgrade fromVersion is invalid')
|
|
527
|
+
if (typeof value.fromStateSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.fromStateSha256)) {
|
|
528
|
+
corrupt('ledger upgrade fromStateSha256 is invalid')
|
|
529
|
+
}
|
|
530
|
+
safeInteger(value.migratedAt, 'ledger upgrade migratedAt')
|
|
531
|
+
safeInteger(value.migratedRevision, 'ledger upgrade migratedRevision', 1)
|
|
532
|
+
safeInteger(value.migratedSettingsRevision, 'ledger upgrade migratedSettingsRevision', 1)
|
|
533
|
+
if (typeof value.backupPath !== 'string' || value.backupPath.length === 0) {
|
|
534
|
+
corrupt('ledger upgrade backupPath is invalid')
|
|
535
|
+
}
|
|
536
|
+
const basename = value.backupPath.split(/[\\/]/).pop() ?? ''
|
|
537
|
+
if (!basename.startsWith(V2_BACKUP_PREFIX)) corrupt('ledger upgrade backupPath is not a v2 backup')
|
|
538
|
+
if (typeof value.baselineStateDigest !== 'string' || !/^[0-9a-f]{64}$/.test(value.baselineStateDigest)) {
|
|
539
|
+
corrupt('ledger upgrade baselineStateDigest is invalid')
|
|
540
|
+
}
|
|
541
|
+
return value
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Pure v3 -> v2 preflight for Lead-owned offline scripts. It is deliberately
|
|
546
|
+
* conservative: any post-upgrade revision/settings change, receipt observation,
|
|
547
|
+
* message/operation change, or backup mismatch rejects the restore.
|
|
548
|
+
* @param {{ currentState: unknown, upgrade: unknown, backupRaw: string|Buffer }} request
|
|
549
|
+
* @returns {{ ok: boolean, reasons: string[] }}
|
|
550
|
+
*/
|
|
551
|
+
export function v3ToV2Preflight(request) {
|
|
552
|
+
const reasons = []
|
|
553
|
+
const state = request?.currentState
|
|
554
|
+
if (!isPlainObject(state) || state.version !== V3_STATE_VERSION) {
|
|
555
|
+
return { ok: false, reasons: ['current state is not a v3 ledger'] }
|
|
556
|
+
}
|
|
557
|
+
let marker
|
|
558
|
+
try {
|
|
559
|
+
marker = validateUpgradeMarker(request.upgrade)
|
|
560
|
+
} catch {
|
|
561
|
+
return { ok: false, reasons: ['upgrade marker is invalid'] }
|
|
562
|
+
}
|
|
563
|
+
if (canonicalJson(marker) !== canonicalJson(state.upgrade)) {
|
|
564
|
+
return { ok: false, reasons: ['upgrade marker does not belong to the current state'] }
|
|
565
|
+
}
|
|
566
|
+
const raw = request.backupRaw
|
|
567
|
+
const digest = sha256(Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw ?? ''), 'utf8'))
|
|
568
|
+
if (digest !== marker.fromStateSha256) reasons.push('v2 backup digest does not match the upgrade marker')
|
|
569
|
+
if (state.revision !== marker.migratedRevision) reasons.push('ledger revision changed after the v2 upgrade')
|
|
570
|
+
if (state.settingsRevision !== marker.migratedSettingsRevision) reasons.push('settings changed after the v2 upgrade')
|
|
571
|
+
let currentDigest
|
|
572
|
+
try {
|
|
573
|
+
currentDigest = baselineV3StateDigest(state)
|
|
574
|
+
} catch {
|
|
575
|
+
reasons.push('current state cannot be canonicalized')
|
|
576
|
+
}
|
|
577
|
+
if (currentDigest !== undefined && currentDigest !== marker.baselineStateDigest) {
|
|
578
|
+
reasons.push('state content changed after the v2 upgrade')
|
|
579
|
+
}
|
|
580
|
+
return { ok: reasons.length === 0, reasons }
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Pure v1 -> v2 conversion. It never deletes messages, chains, or operations
|
|
585
|
+
* and never runs retention cleanup; only the documented status/settings/pause
|
|
586
|
+
* transformations are applied.
|
|
587
|
+
* @param {Record<string, any>} v1
|
|
588
|
+
* @param {{ now: number, fromStateSha256: string, backupPath: string }} options
|
|
589
|
+
* @returns {Record<string, any>}
|
|
590
|
+
*/
|
|
591
|
+
export function migrateV1State(v1, options) {
|
|
592
|
+
const settings = mapV1Settings(v1.settings)
|
|
593
|
+
const chains = {}
|
|
594
|
+
const retiredQuotaPauses = []
|
|
595
|
+
for (const [id, chain] of Object.entries(v1.chains)) {
|
|
596
|
+
const migrated = migrateChain(chain)
|
|
597
|
+
chains[id] = migrated.chain
|
|
598
|
+
if (migrated.retired !== undefined) retiredQuotaPauses.push(migrated.retired)
|
|
599
|
+
}
|
|
600
|
+
const messages = v1.messages.map((message) => migrateMessage(message, options.now, chains, settings))
|
|
601
|
+
// The converted state's held -> pending pass already happened per message;
|
|
602
|
+
// chain references above use the chain ids, not the messages.
|
|
603
|
+
for (const chain of Object.values(chains)) {
|
|
604
|
+
if (chain.pausedAt === undefined) delete chain.pausedAt
|
|
605
|
+
if (chain.pausedReason === undefined) delete chain.pausedReason
|
|
606
|
+
}
|
|
607
|
+
const operations = structuredClone(v1.operations)
|
|
608
|
+
const state = {
|
|
609
|
+
version: V2_STATE_VERSION,
|
|
610
|
+
revision: v1.revision,
|
|
611
|
+
settingsRevision: 1,
|
|
612
|
+
createdAt: v1.createdAt,
|
|
613
|
+
updatedAt: options.now,
|
|
614
|
+
settings,
|
|
615
|
+
messages,
|
|
616
|
+
chains,
|
|
617
|
+
operations,
|
|
618
|
+
}
|
|
619
|
+
const digest = baselineStateDigest(state)
|
|
620
|
+
state.migration = {
|
|
621
|
+
fromVersion: V1_STATE_VERSION,
|
|
622
|
+
fromStateSha256: options.fromStateSha256,
|
|
623
|
+
migratedAt: options.now,
|
|
624
|
+
migratedRevision: v1.revision,
|
|
625
|
+
migratedSettingsRevision: 1,
|
|
626
|
+
backupPath: options.backupPath,
|
|
627
|
+
settingsBefore: structuredClone(v1.settings),
|
|
628
|
+
retiredQuotaPauses,
|
|
629
|
+
retiredLatches: structuredClone(v1.latches),
|
|
630
|
+
baselineMessageIds: messages.map((message) => message.id),
|
|
631
|
+
baselineOperationKeys: Object.keys(operations),
|
|
632
|
+
baselineStateDigest: digest,
|
|
633
|
+
}
|
|
634
|
+
return state
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Public, minimal migration summary for snapshot. The private marker retains
|
|
639
|
+
* backup paths, baseline lists, and retired audit arrays.
|
|
640
|
+
* @param {Record<string, any>} migration
|
|
641
|
+
* @returns {Record<string, any>}
|
|
642
|
+
*/
|
|
643
|
+
export function publicMigration(migration) {
|
|
644
|
+
if (!isPlainObject(migration)) return undefined
|
|
645
|
+
return {
|
|
646
|
+
fromVersion: migration.fromVersion,
|
|
647
|
+
migratedAt: migration.migratedAt,
|
|
648
|
+
migratedRevision: migration.migratedRevision,
|
|
649
|
+
migratedSettingsRevision: migration.migratedSettingsRevision,
|
|
650
|
+
backupAvailable: typeof migration.backupPath === 'string' && migration.backupPath.length > 0,
|
|
651
|
+
retiredQuotaPauses: Array.isArray(migration.retiredQuotaPauses) ? migration.retiredQuotaPauses.length : 0,
|
|
652
|
+
retiredLatches: Array.isArray(migration.retiredLatches) ? migration.retiredLatches.length : 0,
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Validate the public migration summary/full marker enough for the v2 loader.
|
|
658
|
+
* @param {unknown} migration
|
|
659
|
+
*/
|
|
660
|
+
export function validateMigrationMarker(migration) {
|
|
661
|
+
const value = record(migration, 'ledger migration marker')
|
|
662
|
+
if (value.fromVersion !== V1_STATE_VERSION) corrupt('ledger migration fromVersion is invalid')
|
|
663
|
+
if (typeof value.fromStateSha256 !== 'string' || !/^[0-9a-f]{64}$/.test(value.fromStateSha256)) {
|
|
664
|
+
corrupt('ledger migration fromStateSha256 is invalid')
|
|
665
|
+
}
|
|
666
|
+
safeInteger(value.migratedAt, 'ledger migration migratedAt')
|
|
667
|
+
safeInteger(value.migratedRevision, 'ledger migration migratedRevision', 1)
|
|
668
|
+
safeInteger(value.migratedSettingsRevision, 'ledger migration migratedSettingsRevision', 1)
|
|
669
|
+
if (typeof value.backupPath !== 'string' || value.backupPath.length === 0) {
|
|
670
|
+
corrupt('ledger migration backupPath is invalid')
|
|
671
|
+
}
|
|
672
|
+
if (!isPlainObject(value.settingsBefore)) corrupt('ledger migration settingsBefore is invalid')
|
|
673
|
+
if (!Array.isArray(value.retiredQuotaPauses)) corrupt('ledger migration retiredQuotaPauses is invalid')
|
|
674
|
+
for (const [index, entry] of value.retiredQuotaPauses.entries()) {
|
|
675
|
+
const item = record(entry, `ledger migration retiredQuotaPauses ${index}`)
|
|
676
|
+
nonEmptyString(item.chainId, `ledger migration retiredQuotaPauses ${index}.chainId`)
|
|
677
|
+
nonEmptyString(item.pausedReason, `ledger migration retiredQuotaPauses ${index}.pausedReason`)
|
|
678
|
+
optionalTime(item.pausedAt, `ledger migration retiredQuotaPauses ${index}.pausedAt`)
|
|
679
|
+
}
|
|
680
|
+
if (!Array.isArray(value.retiredLatches)) corrupt('ledger migration retiredLatches is invalid')
|
|
681
|
+
validateV1Latches(value.retiredLatches)
|
|
682
|
+
if (!Array.isArray(value.baselineMessageIds)) corrupt('ledger migration baselineMessageIds is invalid')
|
|
683
|
+
for (const id of value.baselineMessageIds) nonEmptyString(id, 'ledger migration baselineMessageId')
|
|
684
|
+
if (!Array.isArray(value.baselineOperationKeys)) corrupt('ledger migration baselineOperationKeys is invalid')
|
|
685
|
+
for (const key of value.baselineOperationKeys) nonEmptyString(key, 'ledger migration baselineOperationKey')
|
|
686
|
+
if (typeof value.baselineStateDigest !== 'string' || !/^[0-9a-f]{64}$/.test(value.baselineStateDigest)) {
|
|
687
|
+
corrupt('ledger migration baselineStateDigest is invalid')
|
|
688
|
+
}
|
|
689
|
+
return value
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Pure rollback preflight. Lead-owned offline scripts call this with the
|
|
694
|
+
* current v2 state, its private marker, and the exact v1 backup bytes; the
|
|
695
|
+
* function never writes anything. It is deliberately conservative: any new
|
|
696
|
+
* message, operation, status transition, or settings change rejects rollback.
|
|
697
|
+
* @param {{ currentState: unknown, migration: unknown, backupRaw: string|Buffer }} request
|
|
698
|
+
* @returns {{ ok: boolean, reasons: string[] }}
|
|
699
|
+
*/
|
|
700
|
+
export function rollbackPreflight(request) {
|
|
701
|
+
const reasons = []
|
|
702
|
+
const state = request?.currentState
|
|
703
|
+
if (!isPlainObject(state) || state.version !== V2_STATE_VERSION) {
|
|
704
|
+
return { ok: false, reasons: ['current state is not a v2 ledger'] }
|
|
705
|
+
}
|
|
706
|
+
let marker
|
|
707
|
+
try {
|
|
708
|
+
marker = validateMigrationMarker(request.migration)
|
|
709
|
+
} catch {
|
|
710
|
+
return { ok: false, reasons: ['migration marker is invalid'] }
|
|
711
|
+
}
|
|
712
|
+
const raw = request.backupRaw
|
|
713
|
+
const digest = sha256(Buffer.isBuffer(raw) ? raw : Buffer.from(String(raw ?? ''), 'utf8'))
|
|
714
|
+
if (digest !== marker.fromStateSha256) reasons.push('v1 backup digest does not match the migration marker')
|
|
715
|
+
if (state.settingsRevision !== marker.migratedSettingsRevision) reasons.push('settings changed after migration')
|
|
716
|
+
const stateIds = state.messages.map((message) => message.id)
|
|
717
|
+
if (
|
|
718
|
+
stateIds.length !== marker.baselineMessageIds.length ||
|
|
719
|
+
stateIds.some((id, index) => id !== marker.baselineMessageIds[index])
|
|
720
|
+
) {
|
|
721
|
+
reasons.push('message IDs differ from the migration baseline')
|
|
722
|
+
}
|
|
723
|
+
const operationKeys = Object.keys(state.operations)
|
|
724
|
+
if (
|
|
725
|
+
operationKeys.length !== marker.baselineOperationKeys.length ||
|
|
726
|
+
operationKeys.some((key, index) => key !== marker.baselineOperationKeys[index])
|
|
727
|
+
) {
|
|
728
|
+
reasons.push('operation keys differ from the migration baseline')
|
|
729
|
+
}
|
|
730
|
+
let currentDigest
|
|
731
|
+
try {
|
|
732
|
+
currentDigest = baselineStateDigest(state)
|
|
733
|
+
} catch {
|
|
734
|
+
reasons.push('current state cannot be canonicalized')
|
|
735
|
+
}
|
|
736
|
+
if (currentDigest !== undefined && currentDigest !== marker.baselineStateDigest) {
|
|
737
|
+
reasons.push('state content, IDs, associations, durable IDs, or statuses changed after migration')
|
|
738
|
+
}
|
|
739
|
+
if (state.revision !== marker.migratedRevision) reasons.push('ledger revision changed after migration')
|
|
740
|
+
return { ok: reasons.length === 0, reasons }
|
|
741
|
+
}
|