@anionex/dsh-turn-rewind 0.1.0
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 +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { join, resolve } from 'node:path'
|
|
4
|
+
import { lstat, realpath } from 'node:fs/promises'
|
|
5
|
+
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
6
|
+
import { discoverRepository, sameRepositoryFence } from './git.js'
|
|
7
|
+
import {
|
|
8
|
+
ensureSafeParents,
|
|
9
|
+
expandHome,
|
|
10
|
+
isNodeError,
|
|
11
|
+
isWithin,
|
|
12
|
+
pruneEmptyParents,
|
|
13
|
+
removeRestoreTarget,
|
|
14
|
+
replaceRegularFile,
|
|
15
|
+
replaceSymbolicLink,
|
|
16
|
+
resolveWorkspacePath,
|
|
17
|
+
validateRelativePath,
|
|
18
|
+
} from './path-utils.js'
|
|
19
|
+
import { captureStableTree, diffTrees, entriesEqual } from './snapshot.js'
|
|
20
|
+
import { LedgerStore } from './store.js'
|
|
21
|
+
import {
|
|
22
|
+
LEDGER_FORMAT_VERSION,
|
|
23
|
+
type ChangeLedgerConfig,
|
|
24
|
+
type RecoverySummary,
|
|
25
|
+
type ResolvedChangeLedgerConfig,
|
|
26
|
+
type RestoreOperation,
|
|
27
|
+
type RestorePlan,
|
|
28
|
+
type RestorePointInspection,
|
|
29
|
+
type RestorePointKind,
|
|
30
|
+
type RestorePointManifest,
|
|
31
|
+
type RestorePointSummary,
|
|
32
|
+
type RestoreResult,
|
|
33
|
+
type SnapshotEntry,
|
|
34
|
+
type WorkspaceChange,
|
|
35
|
+
} from './types.js'
|
|
36
|
+
|
|
37
|
+
const DEFAULTS = {
|
|
38
|
+
maxRestorePoints: 50,
|
|
39
|
+
maxTurnCheckpointsPerSession: 30,
|
|
40
|
+
maxFiles: 20_000,
|
|
41
|
+
maxFileBytes: 16 * 1024 * 1024,
|
|
42
|
+
maxSnapshotBytes: 512 * 1024 * 1024,
|
|
43
|
+
planTtlMs: 15 * 60 * 1_000,
|
|
44
|
+
staleLockMs: 30_000,
|
|
45
|
+
} as const
|
|
46
|
+
|
|
47
|
+
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
48
|
+
export class ChangeLedgerEngine {
|
|
49
|
+
readonly config: ResolvedChangeLedgerConfig
|
|
50
|
+
readonly store: LedgerStore
|
|
51
|
+
private readonly plans = new Map<string, RestorePlan>()
|
|
52
|
+
private readonly activePlans = new Set<string>()
|
|
53
|
+
private readonly ready: Promise<number>
|
|
54
|
+
|
|
55
|
+
/** Build an engine and start crash-journal reconciliation. */
|
|
56
|
+
constructor(config: ChangeLedgerConfig = {}) {
|
|
57
|
+
this.config = resolveConfig(config)
|
|
58
|
+
this.store = new LedgerStore(this.config)
|
|
59
|
+
this.ready = this.store.initialize()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
63
|
+
async initialize(): Promise<number> {
|
|
64
|
+
return this.ready
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Create a durable restore point for the current Git worktree. */
|
|
68
|
+
async create(options: {
|
|
69
|
+
readonly cwd: string
|
|
70
|
+
readonly sessionId?: string
|
|
71
|
+
readonly label?: string
|
|
72
|
+
readonly signal?: AbortSignal
|
|
73
|
+
}): Promise<RestorePointSummary> {
|
|
74
|
+
await this.ready
|
|
75
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
76
|
+
await this.assertStorageSeparated(source.state.root)
|
|
77
|
+
const release = await this.store.acquire(source.state.root)
|
|
78
|
+
try {
|
|
79
|
+
const label = normalizeLabel(options.label)
|
|
80
|
+
const manifest = await this.createLocked({
|
|
81
|
+
cwd: source.state.root,
|
|
82
|
+
kind: 'user',
|
|
83
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
84
|
+
...(label === undefined ? {} : { label }),
|
|
85
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
86
|
+
})
|
|
87
|
+
return summarize(manifest)
|
|
88
|
+
} finally {
|
|
89
|
+
await release()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Capture project files before one DSH turn begins its first step. */
|
|
94
|
+
async createTurnCheckpoint(options: {
|
|
95
|
+
readonly cwd: string
|
|
96
|
+
readonly sessionId: string
|
|
97
|
+
readonly turn: number
|
|
98
|
+
readonly turnStartSeq: number
|
|
99
|
+
readonly signal?: AbortSignal
|
|
100
|
+
}): Promise<RestorePointSummary> {
|
|
101
|
+
await this.ready
|
|
102
|
+
if (!Number.isSafeInteger(options.turn) || options.turn < 0) {
|
|
103
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turn must be a non-negative safe integer')
|
|
104
|
+
}
|
|
105
|
+
if (!Number.isSafeInteger(options.turnStartSeq) || options.turnStartSeq < 0) {
|
|
106
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'turnStartSeq must be a non-negative safe integer')
|
|
107
|
+
}
|
|
108
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
109
|
+
await this.assertStorageSeparated(source.state.root)
|
|
110
|
+
const release = await this.store.acquire(source.state.root)
|
|
111
|
+
try {
|
|
112
|
+
const existing = await this.store.listManifests(source.state.root)
|
|
113
|
+
const duplicate = existing.find(manifest => manifest.kind === 'turn'
|
|
114
|
+
&& manifest.sessionId === options.sessionId
|
|
115
|
+
&& manifest.turn === options.turn
|
|
116
|
+
&& manifest.turnStartSeq === options.turnStartSeq)
|
|
117
|
+
if (duplicate !== undefined) return summarize(duplicate)
|
|
118
|
+
|
|
119
|
+
const manifest = await this.createLocked({
|
|
120
|
+
cwd: source.state.root,
|
|
121
|
+
kind: 'turn',
|
|
122
|
+
sessionId: options.sessionId,
|
|
123
|
+
label: `Before turn ${String(options.turn)} checkpoint`,
|
|
124
|
+
turn: options.turn,
|
|
125
|
+
turnStartSeq: options.turnStartSeq,
|
|
126
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
127
|
+
})
|
|
128
|
+
const checkpoints = [...existing.filter(point => point.kind === 'turn' && point.sessionId === options.sessionId), manifest]
|
|
129
|
+
.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
|
|
130
|
+
for (const stale of checkpoints.slice(this.config.maxTurnCheckpointsPerSession)) {
|
|
131
|
+
if (await this.store.isReferencedByRecovery(source.state.root, stale.id)) continue
|
|
132
|
+
await this.store.deleteManifest(source.state.root, stale.id)
|
|
133
|
+
}
|
|
134
|
+
await this.store.collectGarbage(source.state.root)
|
|
135
|
+
return summarize(manifest)
|
|
136
|
+
} finally {
|
|
137
|
+
await release()
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Find the prompt-anchored checkpoint captured before one session turn. */
|
|
142
|
+
async findTurnCheckpoint(options: {
|
|
143
|
+
readonly cwd: string
|
|
144
|
+
readonly sessionId: string
|
|
145
|
+
readonly turn: number
|
|
146
|
+
readonly signal?: AbortSignal
|
|
147
|
+
}): Promise<RestorePointSummary | undefined> {
|
|
148
|
+
await this.ready
|
|
149
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
150
|
+
await this.assertStorageSeparated(source.state.root)
|
|
151
|
+
const manifest = (await this.store.listManifests(source.state.root)).find(point =>
|
|
152
|
+
point.kind === 'turn'
|
|
153
|
+
&& point.sessionId === options.sessionId
|
|
154
|
+
&& point.turn === options.turn
|
|
155
|
+
&& point.turnStartSeq !== undefined)
|
|
156
|
+
return manifest === undefined ? undefined : summarize(manifest)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** List restore points for the current worktree. */
|
|
160
|
+
async list(options: {
|
|
161
|
+
readonly cwd: string
|
|
162
|
+
readonly includeRescue?: boolean
|
|
163
|
+
readonly includeTurnCheckpoints?: boolean
|
|
164
|
+
readonly signal?: AbortSignal
|
|
165
|
+
}): Promise<RestorePointSummary[]> {
|
|
166
|
+
await this.ready
|
|
167
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
168
|
+
await this.assertStorageSeparated(source.state.root)
|
|
169
|
+
const manifests = await this.store.listManifests(source.state.root)
|
|
170
|
+
return manifests
|
|
171
|
+
.filter((manifest) => manifest.kind === 'user'
|
|
172
|
+
|| (manifest.kind === 'rescue' && options.includeRescue === true)
|
|
173
|
+
|| (manifest.kind === 'turn' && options.includeTurnCheckpoints === true))
|
|
174
|
+
.map(summarize)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Compare one restore point with the current worktree. */
|
|
178
|
+
async inspect(options: {
|
|
179
|
+
readonly cwd: string
|
|
180
|
+
readonly restorePointId: string
|
|
181
|
+
readonly signal?: AbortSignal
|
|
182
|
+
}): Promise<RestorePointInspection> {
|
|
183
|
+
await this.ready
|
|
184
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
185
|
+
await this.assertStorageSeparated(source.state.root)
|
|
186
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
187
|
+
const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
|
|
188
|
+
return {
|
|
189
|
+
restorePoint: summarize(manifest),
|
|
190
|
+
currentTreeHash: current.treeHash,
|
|
191
|
+
currentRepository: current.source.state,
|
|
192
|
+
...(current.source.state.head === undefined ? {} : { currentHead: current.source.state.head }),
|
|
193
|
+
...(current.source.state.branch === undefined ? {} : { currentBranch: current.source.state.branch }),
|
|
194
|
+
...(current.source.state.operation === undefined ? {} : { currentOperation: current.source.state.operation }),
|
|
195
|
+
headChanged: repositoryHeadChanged(manifest.repository, current.source.state),
|
|
196
|
+
operationChanged: manifest.repository.operation !== current.source.state.operation,
|
|
197
|
+
changes: diffTrees(manifest.entries, current.entries),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Produce an expiring, exact confirmation plan for a restore. */
|
|
202
|
+
async planRestore(options: {
|
|
203
|
+
readonly cwd: string
|
|
204
|
+
readonly restorePointId: string
|
|
205
|
+
readonly sessionId?: string
|
|
206
|
+
readonly paths?: readonly string[]
|
|
207
|
+
readonly allowHeadChange?: boolean
|
|
208
|
+
readonly expectedCurrentTreeHash?: string
|
|
209
|
+
readonly expectedRepository?: RestorePointManifest['repository']
|
|
210
|
+
readonly signal?: AbortSignal
|
|
211
|
+
}): Promise<RestorePlan> {
|
|
212
|
+
await this.ready
|
|
213
|
+
this.expirePlans()
|
|
214
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
215
|
+
await this.assertStorageSeparated(source.state.root)
|
|
216
|
+
const manifest = await this.store.readManifest(source.state.root, options.restorePointId)
|
|
217
|
+
const current = await captureStableTree({ cwd: source.state.root, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
|
|
218
|
+
if (options.expectedCurrentTreeHash !== undefined && options.expectedCurrentTreeHash !== current.treeHash) {
|
|
219
|
+
throw new ChangeLedgerError('PLAN_STALE', 'workspace changed after inspection; inspect and plan again')
|
|
220
|
+
}
|
|
221
|
+
if (options.expectedRepository !== undefined && !sameRepositoryFence(options.expectedRepository, current.source.state)) {
|
|
222
|
+
throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after inspection; inspect and plan again')
|
|
223
|
+
}
|
|
224
|
+
assertRepositoryCompatible(manifest, current.source.state, options.allowHeadChange === true)
|
|
225
|
+
const changes = diffTrees(manifest.entries, current.entries)
|
|
226
|
+
if (changes.length === 0) {
|
|
227
|
+
throw new ChangeLedgerError('NO_CHANGES', `workspace already matches restore point ${manifest.id}`)
|
|
228
|
+
}
|
|
229
|
+
const selected = selectChanges(changes, options.paths)
|
|
230
|
+
await assertNoUnmanagedRestoreConflicts(
|
|
231
|
+
source.state.root,
|
|
232
|
+
manifest.entries,
|
|
233
|
+
current.entries,
|
|
234
|
+
selected.map((change) => change.path),
|
|
235
|
+
)
|
|
236
|
+
const expected: Record<string, SnapshotEntry | null> = Object.create(null) as Record<string, SnapshotEntry | null>
|
|
237
|
+
for (const change of selected) expected[change.path] = current.entries[change.path] ?? null
|
|
238
|
+
const now = Date.now()
|
|
239
|
+
const plan: RestorePlan = {
|
|
240
|
+
id: makeId('plan'),
|
|
241
|
+
restorePointId: manifest.id,
|
|
242
|
+
workspace: manifest.workspace,
|
|
243
|
+
repository: current.source.state,
|
|
244
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
245
|
+
createdAt: now,
|
|
246
|
+
expiresAt: now + this.config.planTtlMs,
|
|
247
|
+
confirmation: `RESTORE-${randomBytes(4).toString('hex').toUpperCase()}`,
|
|
248
|
+
allowHeadChange: options.allowHeadChange === true,
|
|
249
|
+
paths: selected.map((change) => change.path),
|
|
250
|
+
changes: selected,
|
|
251
|
+
expected,
|
|
252
|
+
}
|
|
253
|
+
this.plans.set(plan.id, plan)
|
|
254
|
+
return clonePlan(plan)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Apply one approved restore plan, creating a durable rescue point first. */
|
|
258
|
+
async applyRestore(options: {
|
|
259
|
+
readonly planId: string
|
|
260
|
+
readonly confirmation: string
|
|
261
|
+
readonly sessionId?: string
|
|
262
|
+
readonly signal?: AbortSignal
|
|
263
|
+
}): Promise<RestoreResult> {
|
|
264
|
+
await this.ready
|
|
265
|
+
this.expirePlans()
|
|
266
|
+
const plan = this.plans.get(options.planId)
|
|
267
|
+
if (plan === undefined) throw new ChangeLedgerError('PLAN_NOT_FOUND', `restore plan ${options.planId} is absent or expired`)
|
|
268
|
+
if (plan.confirmation !== options.confirmation) {
|
|
269
|
+
throw new ChangeLedgerError('CONFIRMATION_MISMATCH', 'confirmation does not exactly match the restore plan')
|
|
270
|
+
}
|
|
271
|
+
if (plan.sessionId !== undefined && plan.sessionId !== options.sessionId) {
|
|
272
|
+
throw new ChangeLedgerError('SESSION_MISMATCH', 'restore plan belongs to a different DSH session')
|
|
273
|
+
}
|
|
274
|
+
if (this.activePlans.has(plan.id)) {
|
|
275
|
+
throw new ChangeLedgerError('PLAN_IN_PROGRESS', `restore plan ${plan.id} is already being applied`)
|
|
276
|
+
}
|
|
277
|
+
this.activePlans.add(plan.id)
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
await this.assertStorageSeparated(plan.workspace)
|
|
281
|
+
const release = await this.store.acquire(plan.workspace)
|
|
282
|
+
try {
|
|
283
|
+
const manifest = await this.store.readManifest(plan.workspace, plan.restorePointId)
|
|
284
|
+
const current = await captureStableTree({ cwd: plan.workspace, config: this.config, ...(options.signal === undefined ? {} : { signal: options.signal }) })
|
|
285
|
+
assertRepositoryCompatible(manifest, current.source.state, plan.allowHeadChange)
|
|
286
|
+
assertPlanRepositoryFresh(plan.repository, current.source.state)
|
|
287
|
+
assertPlanFresh(plan, current.entries)
|
|
288
|
+
await assertNoUnmanagedRestoreConflicts(plan.workspace, manifest.entries, current.entries, plan.paths)
|
|
289
|
+
|
|
290
|
+
const rescue = await this.createLocked({
|
|
291
|
+
cwd: plan.workspace,
|
|
292
|
+
kind: 'rescue',
|
|
293
|
+
label: `Before restoring ${manifest.id}`,
|
|
294
|
+
parentRestorePoint: manifest.id,
|
|
295
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
296
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
297
|
+
})
|
|
298
|
+
try {
|
|
299
|
+
assertPlanFresh(plan, rescue.entries)
|
|
300
|
+
await assertNoUnmanagedRestoreConflicts(plan.workspace, manifest.entries, rescue.entries, plan.paths)
|
|
301
|
+
} catch (error) {
|
|
302
|
+
await this.store.deleteManifest(plan.workspace, rescue.id)
|
|
303
|
+
await this.store.collectGarbage(plan.workspace)
|
|
304
|
+
throw error
|
|
305
|
+
}
|
|
306
|
+
const operation: RestoreOperation = {
|
|
307
|
+
version: LEDGER_FORMAT_VERSION,
|
|
308
|
+
id: makeId('op'),
|
|
309
|
+
workspace: plan.workspace,
|
|
310
|
+
restorePointId: manifest.id,
|
|
311
|
+
rescuePointId: rescue.id,
|
|
312
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
313
|
+
paths: plan.paths,
|
|
314
|
+
startedAt: Date.now(),
|
|
315
|
+
state: 'running',
|
|
316
|
+
}
|
|
317
|
+
await this.store.writeOperation(operation)
|
|
318
|
+
|
|
319
|
+
try {
|
|
320
|
+
await this.restorePaths(plan.workspace, manifest.entries, plan.paths, options.signal)
|
|
321
|
+
await this.verifyPaths(plan.workspace, manifest.entries, plan.paths, options.signal)
|
|
322
|
+
} catch (error) {
|
|
323
|
+
const primaryError = errorMessage(error)
|
|
324
|
+
let journalWarning: string | undefined
|
|
325
|
+
try {
|
|
326
|
+
await this.store.writeOperation({ ...operation, state: 'rollback-running', error: primaryError })
|
|
327
|
+
} catch (journalError) {
|
|
328
|
+
journalWarning = `could not persist rollback-running state: ${errorMessage(journalError)}`
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
let rollbackFailure: unknown
|
|
332
|
+
try {
|
|
333
|
+
await this.restorePaths(plan.workspace, rescue.entries, plan.paths)
|
|
334
|
+
await this.verifyPaths(plan.workspace, rescue.entries, plan.paths)
|
|
335
|
+
} catch (rollbackError) {
|
|
336
|
+
rollbackFailure = rollbackError
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (rollbackFailure === undefined) {
|
|
340
|
+
let terminalJournalWarning: string | undefined
|
|
341
|
+
try {
|
|
342
|
+
await this.store.writeOperation({
|
|
343
|
+
...operation,
|
|
344
|
+
state: 'rolled-back',
|
|
345
|
+
error: primaryError,
|
|
346
|
+
finishedAt: Date.now(),
|
|
347
|
+
})
|
|
348
|
+
} catch (journalError) {
|
|
349
|
+
terminalJournalWarning = `could not persist rolled-back state: ${errorMessage(journalError)}`
|
|
350
|
+
}
|
|
351
|
+
const warnings = [journalWarning, terminalJournalWarning].filter((value): value is string => value !== undefined)
|
|
352
|
+
const warningText = warnings.length === 0 ? '' : `; journal warning: ${warnings.join('; ')}`
|
|
353
|
+
throw new ChangeLedgerError(
|
|
354
|
+
'RESTORE_FAILED_ROLLED_BACK',
|
|
355
|
+
`restore failed and the pre-restore state was recovered from ${rescue.id}: ${primaryError}${warningText}`,
|
|
356
|
+
{ cause: error },
|
|
357
|
+
)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const rollbackMessage = errorMessage(rollbackFailure)
|
|
361
|
+
let recoveryJournalWarning: string | undefined
|
|
362
|
+
try {
|
|
363
|
+
await this.store.writeOperation({
|
|
364
|
+
...operation,
|
|
365
|
+
state: 'recovery-required',
|
|
366
|
+
error: primaryError,
|
|
367
|
+
rollbackError: rollbackMessage,
|
|
368
|
+
})
|
|
369
|
+
} catch (journalError) {
|
|
370
|
+
recoveryJournalWarning = `could not persist recovery-required state: ${errorMessage(journalError)}`
|
|
371
|
+
}
|
|
372
|
+
const warnings = [journalWarning, recoveryJournalWarning].filter((value): value is string => value !== undefined)
|
|
373
|
+
const warningText = warnings.length === 0 ? '' : `; journal warning: ${warnings.join('; ')}`
|
|
374
|
+
throw new ChangeLedgerError(
|
|
375
|
+
'RECOVERY_REQUIRED',
|
|
376
|
+
`restore and automatic rollback both failed; operation ${operation.id} can be recovered from rescue point ${rescue.id}: ${primaryError}; rollback: ${rollbackMessage}${warningText}`,
|
|
377
|
+
{ cause: error },
|
|
378
|
+
)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const finishedAt = Date.now()
|
|
382
|
+
await this.store.writeOperation({ ...operation, state: 'completed', finishedAt })
|
|
383
|
+
await this.store.writeManifest({
|
|
384
|
+
...manifest,
|
|
385
|
+
restoreCount: manifest.restoreCount + 1,
|
|
386
|
+
lastRestoredAt: finishedAt,
|
|
387
|
+
})
|
|
388
|
+
this.plans.delete(plan.id)
|
|
389
|
+
return {
|
|
390
|
+
operationId: operation.id,
|
|
391
|
+
restorePointId: manifest.id,
|
|
392
|
+
rescuePointId: rescue.id,
|
|
393
|
+
restoredPaths: plan.paths,
|
|
394
|
+
}
|
|
395
|
+
} finally {
|
|
396
|
+
await release()
|
|
397
|
+
}
|
|
398
|
+
} finally {
|
|
399
|
+
this.activePlans.delete(plan.id)
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Delete one restore point and collect unreferenced blobs. */
|
|
404
|
+
async delete(options: {
|
|
405
|
+
readonly cwd: string
|
|
406
|
+
readonly restorePointId: string
|
|
407
|
+
readonly confirmation: string
|
|
408
|
+
readonly signal?: AbortSignal
|
|
409
|
+
}): Promise<{ readonly restorePointId: string; readonly deletedBlobs: number; readonly retainedBlobs: number }> {
|
|
410
|
+
await this.ready
|
|
411
|
+
if (options.confirmation !== `DELETE ${options.restorePointId}`) {
|
|
412
|
+
throw new ChangeLedgerError('CONFIRMATION_MISMATCH', `confirmation must exactly equal "DELETE ${options.restorePointId}"`)
|
|
413
|
+
}
|
|
414
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
415
|
+
await this.assertStorageSeparated(source.state.root)
|
|
416
|
+
const release = await this.store.acquire(source.state.root)
|
|
417
|
+
try {
|
|
418
|
+
await this.store.readManifest(source.state.root, options.restorePointId)
|
|
419
|
+
if (await this.store.isReferencedByRecovery(source.state.root, options.restorePointId)) {
|
|
420
|
+
throw new ChangeLedgerError('RECOVERY_REFERENCE', 'restore point is required by an incomplete recovery journal')
|
|
421
|
+
}
|
|
422
|
+
await this.store.deleteManifest(source.state.root, options.restorePointId)
|
|
423
|
+
const gc = await this.store.collectGarbage(source.state.root)
|
|
424
|
+
return { restorePointId: options.restorePointId, ...gc }
|
|
425
|
+
} finally {
|
|
426
|
+
await release()
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** List restore operations that were interrupted or require manual recovery. */
|
|
431
|
+
async listRecovery(options: { readonly cwd: string; readonly signal?: AbortSignal }): Promise<RecoverySummary[]> {
|
|
432
|
+
await this.ready
|
|
433
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
434
|
+
await this.assertStorageSeparated(source.state.root)
|
|
435
|
+
return (await this.store.listOperations(source.state.root))
|
|
436
|
+
.filter((operation): operation is RestoreOperation & { state: 'interrupted' | 'recovery-required' } =>
|
|
437
|
+
operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
438
|
+
.map((operation) => ({
|
|
439
|
+
operationId: operation.id,
|
|
440
|
+
restorePointId: operation.restorePointId,
|
|
441
|
+
rescuePointId: operation.rescuePointId,
|
|
442
|
+
state: operation.state,
|
|
443
|
+
paths: operation.paths,
|
|
444
|
+
startedAt: operation.startedAt,
|
|
445
|
+
...(operation.error === undefined ? {} : { error: operation.error }),
|
|
446
|
+
...(operation.rollbackError === undefined ? {} : { rollbackError: operation.rollbackError }),
|
|
447
|
+
}))
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
private async createLocked(options: {
|
|
451
|
+
readonly cwd: string
|
|
452
|
+
readonly kind: RestorePointKind
|
|
453
|
+
readonly sessionId?: string
|
|
454
|
+
readonly label?: string
|
|
455
|
+
readonly parentRestorePoint?: string
|
|
456
|
+
readonly turn?: number
|
|
457
|
+
readonly turnStartSeq?: number
|
|
458
|
+
readonly turnEndSeq?: number
|
|
459
|
+
readonly signal?: AbortSignal
|
|
460
|
+
}): Promise<RestorePointManifest> {
|
|
461
|
+
const existing = await this.store.listManifests(options.cwd)
|
|
462
|
+
const durableUserPoints = existing.filter(point => point.kind !== 'turn')
|
|
463
|
+
if (options.kind !== 'turn' && durableUserPoints.length >= this.config.maxRestorePoints) {
|
|
464
|
+
throw new ChangeLedgerError(
|
|
465
|
+
'RESTORE_POINT_LIMIT',
|
|
466
|
+
`workspace already has ${durableUserPoints.length} user/rescue restore points; configured maximum is ${this.config.maxRestorePoints}`,
|
|
467
|
+
)
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const tree = await captureStableTree({
|
|
471
|
+
cwd: options.cwd,
|
|
472
|
+
config: this.config,
|
|
473
|
+
store: this.store,
|
|
474
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
475
|
+
})
|
|
476
|
+
const manifest: RestorePointManifest = {
|
|
477
|
+
version: LEDGER_FORMAT_VERSION,
|
|
478
|
+
id: makeId('rp'),
|
|
479
|
+
kind: options.kind,
|
|
480
|
+
workspace: tree.source.state.root,
|
|
481
|
+
repository: tree.source.state,
|
|
482
|
+
...(options.sessionId === undefined ? {} : { sessionId: options.sessionId }),
|
|
483
|
+
...(options.label === undefined ? {} : { label: options.label }),
|
|
484
|
+
...(options.parentRestorePoint === undefined ? {} : { parentRestorePoint: options.parentRestorePoint }),
|
|
485
|
+
...(options.turn === undefined ? {} : { turn: options.turn }),
|
|
486
|
+
...(options.turnStartSeq === undefined ? {} : { turnStartSeq: options.turnStartSeq }),
|
|
487
|
+
...(options.turnEndSeq === undefined ? {} : { turnEndSeq: options.turnEndSeq }),
|
|
488
|
+
createdAt: Date.now(),
|
|
489
|
+
treeHash: tree.treeHash,
|
|
490
|
+
fileCount: tree.fileCount,
|
|
491
|
+
totalBytes: tree.totalBytes,
|
|
492
|
+
entries: tree.entries,
|
|
493
|
+
restoreCount: 0,
|
|
494
|
+
}
|
|
495
|
+
await this.store.collectGarbage(
|
|
496
|
+
options.cwd,
|
|
497
|
+
Object.values(manifest.entries)
|
|
498
|
+
.filter((entry): entry is SnapshotEntry & { readonly kind: 'file' } => entry.kind === 'file')
|
|
499
|
+
.map((entry) => entry.blob),
|
|
500
|
+
)
|
|
501
|
+
await this.store.writeManifest(manifest)
|
|
502
|
+
return manifest
|
|
503
|
+
} catch (error) {
|
|
504
|
+
try {
|
|
505
|
+
await this.store.collectGarbage(options.cwd)
|
|
506
|
+
} catch (cleanupError) {
|
|
507
|
+
throw new ChangeLedgerError(
|
|
508
|
+
'SNAPSHOT_CLEANUP_FAILED',
|
|
509
|
+
`snapshot failed (${errorMessage(error)}) and unreferenced-blob cleanup also failed (${errorMessage(cleanupError)})`,
|
|
510
|
+
{ cause: error },
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
throw error
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private async restorePaths(
|
|
518
|
+
workspace: string,
|
|
519
|
+
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
520
|
+
paths: readonly string[],
|
|
521
|
+
signal?: AbortSignal,
|
|
522
|
+
): Promise<void> {
|
|
523
|
+
const deletions = paths.filter((path) => desiredEntries[path] === undefined).sort(compareDeepestFirst)
|
|
524
|
+
for (const path of deletions) {
|
|
525
|
+
throwIfAborted(signal)
|
|
526
|
+
const target = resolveWorkspacePath(workspace, path)
|
|
527
|
+
await removeRestoreTarget(target)
|
|
528
|
+
await pruneEmptyParents(workspace, target)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const restorations = paths.filter((path) => desiredEntries[path] !== undefined).sort(compareShallowestFirst)
|
|
532
|
+
for (const path of restorations) {
|
|
533
|
+
throwIfAborted(signal)
|
|
534
|
+
const entry = desiredEntries[path]
|
|
535
|
+
if (entry === undefined) continue
|
|
536
|
+
const target = resolveWorkspacePath(workspace, path)
|
|
537
|
+
await ensureSafeParents(workspace, target)
|
|
538
|
+
try {
|
|
539
|
+
const info = await lstat(target)
|
|
540
|
+
if (info.isDirectory() && !info.isSymbolicLink()) await removeRestoreTarget(target)
|
|
541
|
+
} catch (error) {
|
|
542
|
+
if (!isNodeError(error, 'ENOENT')) throw error
|
|
543
|
+
}
|
|
544
|
+
if (entry.kind === 'file') {
|
|
545
|
+
const content = await this.store.readBlob(workspace, entry.blob)
|
|
546
|
+
if (content.length !== entry.size) {
|
|
547
|
+
throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${entry.blob} has unexpected size for ${JSON.stringify(path)}`)
|
|
548
|
+
}
|
|
549
|
+
await replaceRegularFile(target, content, entry.mode)
|
|
550
|
+
} else {
|
|
551
|
+
await replaceSymbolicLink(target, entry.target)
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async verifyPaths(
|
|
557
|
+
workspace: string,
|
|
558
|
+
desiredEntries: Readonly<Record<string, SnapshotEntry>>,
|
|
559
|
+
paths: readonly string[],
|
|
560
|
+
signal?: AbortSignal,
|
|
561
|
+
): Promise<void> {
|
|
562
|
+
const current = await captureStableTree({ cwd: workspace, config: this.config, ...(signal === undefined ? {} : { signal }) })
|
|
563
|
+
for (const path of paths) {
|
|
564
|
+
if (!entriesEqual(desiredEntries[path], current.entries[path])) {
|
|
565
|
+
throw new ChangeLedgerError('RESTORE_VERIFY_FAILED', `restored path did not match its expected snapshot: ${JSON.stringify(path)}`)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
private async assertStorageSeparated(workspace: string): Promise<void> {
|
|
571
|
+
const storage = await realpath(resolve(this.config.storageDir))
|
|
572
|
+
if (isWithin(workspace, storage) || isWithin(storage, workspace)) {
|
|
573
|
+
throw new ChangeLedgerError(
|
|
574
|
+
'STATE_WORKSPACE_OVERLAP',
|
|
575
|
+
`storageDir ${JSON.stringify(storage)} must not overlap workspace ${JSON.stringify(workspace)}`,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
private expirePlans(): void {
|
|
581
|
+
const now = Date.now()
|
|
582
|
+
for (const [id, plan] of this.plans) {
|
|
583
|
+
if (plan.expiresAt <= now) this.plans.delete(id)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Resolve and validate every deployment-varying configuration value. */
|
|
589
|
+
export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig {
|
|
590
|
+
const home = homedir()
|
|
591
|
+
const configuredDshHome = process.env.DSH_HOME?.trim()
|
|
592
|
+
const dshHome = configuredDshHome === undefined || configuredDshHome === ''
|
|
593
|
+
? join(home, '.dsh')
|
|
594
|
+
: resolve(expandHome(configuredDshHome, home))
|
|
595
|
+
const storageInput = config.storageDir ?? join(dshHome, 'change-ledger', 'v1')
|
|
596
|
+
const storageDir = resolve(expandHome(requireNonEmptyString(storageInput, 'storageDir'), home))
|
|
597
|
+
return {
|
|
598
|
+
storageDir,
|
|
599
|
+
maxRestorePoints: positiveInteger(config.maxRestorePoints ?? DEFAULTS.maxRestorePoints, 'maxRestorePoints'),
|
|
600
|
+
maxTurnCheckpointsPerSession: positiveInteger(
|
|
601
|
+
config.maxTurnCheckpointsPerSession ?? DEFAULTS.maxTurnCheckpointsPerSession,
|
|
602
|
+
'maxTurnCheckpointsPerSession',
|
|
603
|
+
),
|
|
604
|
+
maxFiles: positiveInteger(config.maxFiles ?? DEFAULTS.maxFiles, 'maxFiles'),
|
|
605
|
+
maxFileBytes: positiveInteger(config.maxFileBytes ?? DEFAULTS.maxFileBytes, 'maxFileBytes'),
|
|
606
|
+
maxSnapshotBytes: positiveInteger(config.maxSnapshotBytes ?? DEFAULTS.maxSnapshotBytes, 'maxSnapshotBytes'),
|
|
607
|
+
planTtlMs: positiveInteger(config.planTtlMs ?? DEFAULTS.planTtlMs, 'planTtlMs'),
|
|
608
|
+
staleLockMs: positiveInteger(config.staleLockMs ?? DEFAULTS.staleLockMs, 'staleLockMs'),
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function summarize(manifest: RestorePointManifest): RestorePointSummary {
|
|
613
|
+
return {
|
|
614
|
+
id: manifest.id,
|
|
615
|
+
kind: manifest.kind,
|
|
616
|
+
workspace: manifest.workspace,
|
|
617
|
+
...(manifest.sessionId === undefined ? {} : { sessionId: manifest.sessionId }),
|
|
618
|
+
...(manifest.label === undefined ? {} : { label: manifest.label }),
|
|
619
|
+
...(manifest.parentRestorePoint === undefined ? {} : { parentRestorePoint: manifest.parentRestorePoint }),
|
|
620
|
+
...(manifest.turn === undefined ? {} : { turn: manifest.turn }),
|
|
621
|
+
...(manifest.turnStartSeq === undefined ? {} : { turnStartSeq: manifest.turnStartSeq }),
|
|
622
|
+
...(manifest.turnEndSeq === undefined ? {} : { turnEndSeq: manifest.turnEndSeq }),
|
|
623
|
+
createdAt: manifest.createdAt,
|
|
624
|
+
treeHash: manifest.treeHash,
|
|
625
|
+
fileCount: manifest.fileCount,
|
|
626
|
+
totalBytes: manifest.totalBytes,
|
|
627
|
+
restoreCount: manifest.restoreCount,
|
|
628
|
+
...(manifest.lastRestoredAt === undefined ? {} : { lastRestoredAt: manifest.lastRestoredAt }),
|
|
629
|
+
...(manifest.repository.head === undefined ? {} : { head: manifest.repository.head }),
|
|
630
|
+
...(manifest.repository.branch === undefined ? {} : { branch: manifest.repository.branch }),
|
|
631
|
+
...(manifest.repository.operation === undefined ? {} : { operation: manifest.repository.operation }),
|
|
632
|
+
stagedPathCount: manifest.repository.stagedPaths.length,
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function assertRepositoryCompatible(
|
|
637
|
+
manifest: RestorePointManifest,
|
|
638
|
+
current: RestorePointManifest['repository'],
|
|
639
|
+
allowHeadChange: boolean,
|
|
640
|
+
): void {
|
|
641
|
+
if (manifest.repository.root !== current.root || manifest.repository.commonDir !== current.commonDir) {
|
|
642
|
+
throw new ChangeLedgerError('REPOSITORY_CHANGED', 'restore point no longer belongs to this Git worktree')
|
|
643
|
+
}
|
|
644
|
+
if (manifest.repository.operation !== current.operation) {
|
|
645
|
+
throw new ChangeLedgerError(
|
|
646
|
+
'GIT_OPERATION_CHANGED',
|
|
647
|
+
`Git operation changed from ${manifest.repository.operation ?? 'none'} to ${current.operation ?? 'none'}`,
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
if (!allowHeadChange && repositoryHeadChanged(manifest.repository, current)) {
|
|
651
|
+
throw new ChangeLedgerError(
|
|
652
|
+
'HEAD_CHANGED',
|
|
653
|
+
'HEAD or branch changed since the restore point; re-plan with allowHeadChange only after reviewing the diff',
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function repositoryHeadChanged(
|
|
659
|
+
before: RestorePointManifest['repository'],
|
|
660
|
+
after: RestorePointManifest['repository'],
|
|
661
|
+
): boolean {
|
|
662
|
+
return before.head !== after.head || before.branch !== after.branch
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function assertPlanRepositoryFresh(
|
|
666
|
+
planned: RestorePointManifest['repository'],
|
|
667
|
+
current: RestorePointManifest['repository'],
|
|
668
|
+
): void {
|
|
669
|
+
if (planned.root !== current.root
|
|
670
|
+
|| planned.commonDir !== current.commonDir
|
|
671
|
+
|| planned.head !== current.head
|
|
672
|
+
|| planned.branch !== current.branch
|
|
673
|
+
|| planned.operation !== current.operation) {
|
|
674
|
+
throw new ChangeLedgerError('PLAN_STALE_REPOSITORY', 'Git repository state changed after restore planning; inspect and plan again')
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function selectChanges(changes: readonly WorkspaceChange[], requested?: readonly string[]): WorkspaceChange[] {
|
|
679
|
+
if (requested === undefined || requested.length === 0) return [...changes]
|
|
680
|
+
const normalized = requested.map(validateRelativePath)
|
|
681
|
+
if (new Set(normalized).size !== normalized.length) {
|
|
682
|
+
throw new ChangeLedgerError('DUPLICATE_PATH', 'restore path selection contains duplicates')
|
|
683
|
+
}
|
|
684
|
+
const byPath = new Map(changes.map((change) => [change.path, change]))
|
|
685
|
+
return normalized.map((path) => {
|
|
686
|
+
const change = byPath.get(path)
|
|
687
|
+
if (change === undefined) throw new ChangeLedgerError('PATH_NOT_CHANGED', `${JSON.stringify(path)} is not changed from the restore point`)
|
|
688
|
+
return change
|
|
689
|
+
})
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function assertPlanFresh(plan: RestorePlan, current: Readonly<Record<string, SnapshotEntry>>): void {
|
|
693
|
+
for (const path of plan.paths) {
|
|
694
|
+
const expected = plan.expected[path]
|
|
695
|
+
if (expected === undefined) throw new ChangeLedgerError('PLAN_CORRUPT', `plan lacks expected state for ${JSON.stringify(path)}`)
|
|
696
|
+
const normalizedExpected = expected === null ? undefined : expected
|
|
697
|
+
if (!entriesEqual(normalizedExpected, current[path])) {
|
|
698
|
+
throw new ChangeLedgerError('PLAN_STALE', `workspace changed after planning at ${JSON.stringify(path)}; inspect and plan again`)
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async function assertNoUnmanagedRestoreConflicts(
|
|
704
|
+
workspace: string,
|
|
705
|
+
desired: Readonly<Record<string, SnapshotEntry>>,
|
|
706
|
+
current: Readonly<Record<string, SnapshotEntry>>,
|
|
707
|
+
paths: readonly string[],
|
|
708
|
+
): Promise<void> {
|
|
709
|
+
for (const path of paths) {
|
|
710
|
+
if (desired[path] === undefined || current[path] !== undefined) continue
|
|
711
|
+
const target = resolveWorkspacePath(workspace, path)
|
|
712
|
+
try {
|
|
713
|
+
const info = await lstat(target)
|
|
714
|
+
if (info.isDirectory() && !info.isSymbolicLink()) continue
|
|
715
|
+
} catch (error) {
|
|
716
|
+
if (isNodeError(error, 'ENOENT')) continue
|
|
717
|
+
throw error
|
|
718
|
+
}
|
|
719
|
+
throw new ChangeLedgerError(
|
|
720
|
+
'UNMANAGED_PATH_CONFLICT',
|
|
721
|
+
`refusing to replace ${JSON.stringify(path)} because it exists on disk but is excluded from the current Git snapshot`,
|
|
722
|
+
)
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function clonePlan(plan: RestorePlan): RestorePlan {
|
|
727
|
+
return structuredClone(plan)
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function makeId(prefix: 'rp' | 'op' | 'plan'): string {
|
|
731
|
+
return `${prefix}_${Date.now().toString(36)}_${randomBytes(6).toString('hex')}`
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function normalizeLabel(label: string | undefined): string | undefined {
|
|
735
|
+
if (label === undefined) return undefined
|
|
736
|
+
const normalized = label.trim()
|
|
737
|
+
if (normalized === '') throw new ChangeLedgerError('INVALID_LABEL', 'restore-point label must not be blank')
|
|
738
|
+
if (normalized.length > 200) throw new ChangeLedgerError('INVALID_LABEL', 'restore-point label must be at most 200 characters')
|
|
739
|
+
return normalized
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function positiveInteger(value: number, name: string): number {
|
|
743
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
744
|
+
throw new ChangeLedgerError('INVALID_CONFIG', `${name} must be a positive safe integer`)
|
|
745
|
+
}
|
|
746
|
+
return value
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function requireNonEmptyString(value: string, name: string): string {
|
|
750
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
751
|
+
throw new ChangeLedgerError('INVALID_CONFIG', `${name} must be a non-empty string`)
|
|
752
|
+
}
|
|
753
|
+
return value
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
757
|
+
if (signal?.aborted === true) throw signal.reason
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function depth(path: string): number {
|
|
761
|
+
return path.split('/').length
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function compareDeepestFirst(left: string, right: string): number {
|
|
765
|
+
return depth(right) - depth(left) || Buffer.from(left).compare(Buffer.from(right))
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
function compareShallowestFirst(left: string, right: string): number {
|
|
769
|
+
return depth(left) - depth(right) || Buffer.from(left).compare(Buffer.from(right))
|
|
770
|
+
}
|