@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/snapshot.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import type { BigIntStats } from 'node:fs'
|
|
3
|
+
import { lstat, readFile, readlink } from 'node:fs/promises'
|
|
4
|
+
import { ChangeLedgerError } from './errors.js'
|
|
5
|
+
import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
|
|
6
|
+
import { isNodeError, resolveWorkspacePath } from './path-utils.js'
|
|
7
|
+
import type { LedgerStore } from './store.js'
|
|
8
|
+
import type {
|
|
9
|
+
ResolvedChangeLedgerConfig,
|
|
10
|
+
SnapshotEntry,
|
|
11
|
+
WorkspaceChange,
|
|
12
|
+
} from './types.js'
|
|
13
|
+
|
|
14
|
+
/** One captured tree, optionally persisted into the blob store. */
|
|
15
|
+
export interface CapturedTree {
|
|
16
|
+
readonly source: RepositorySnapshotSource
|
|
17
|
+
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
18
|
+
readonly treeHash: string
|
|
19
|
+
readonly fileCount: number
|
|
20
|
+
readonly totalBytes: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Capture the current tracked and non-ignored Git working tree. */
|
|
24
|
+
export async function captureTree(options: {
|
|
25
|
+
readonly cwd: string
|
|
26
|
+
readonly config: ResolvedChangeLedgerConfig
|
|
27
|
+
readonly store?: LedgerStore
|
|
28
|
+
readonly signal?: AbortSignal
|
|
29
|
+
}): Promise<CapturedTree> {
|
|
30
|
+
throwIfAborted(options.signal)
|
|
31
|
+
const source = await discoverRepository(options.cwd, options.signal)
|
|
32
|
+
if (source.paths.length > options.config.maxFiles) {
|
|
33
|
+
throw new ChangeLedgerError(
|
|
34
|
+
'TOO_MANY_FILES',
|
|
35
|
+
`workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
40
|
+
let totalBytes = 0
|
|
41
|
+
for (const path of source.paths) {
|
|
42
|
+
throwIfAborted(options.signal)
|
|
43
|
+
const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
|
|
44
|
+
if (entry === undefined) continue
|
|
45
|
+
if (entry.kind === 'file') {
|
|
46
|
+
totalBytes += entry.content.length
|
|
47
|
+
if (totalBytes > options.config.maxSnapshotBytes) {
|
|
48
|
+
throw new ChangeLedgerError(
|
|
49
|
+
'SNAPSHOT_TOO_LARGE',
|
|
50
|
+
`eligible files exceed configured aggregate limit of ${options.config.maxSnapshotBytes} bytes`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
if (options.store !== undefined) {
|
|
54
|
+
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content)
|
|
55
|
+
}
|
|
56
|
+
entries[path] = entry.snapshot
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
entries[path] = entry.snapshot
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
source,
|
|
64
|
+
entries,
|
|
65
|
+
treeHash: hashTree(entries),
|
|
66
|
+
fileCount: Object.keys(entries).length,
|
|
67
|
+
totalBytes,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Capture the complete tree twice and accept it only when both path/content and
|
|
73
|
+
* repository fences agree. This prevents a point from silently mixing files
|
|
74
|
+
* observed at incompatible moments while another process is editing the tree.
|
|
75
|
+
*/
|
|
76
|
+
export async function captureStableTree(options: {
|
|
77
|
+
readonly cwd: string
|
|
78
|
+
readonly config: ResolvedChangeLedgerConfig
|
|
79
|
+
readonly store?: LedgerStore
|
|
80
|
+
readonly signal?: AbortSignal
|
|
81
|
+
}): Promise<CapturedTree> {
|
|
82
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
83
|
+
const first = await captureTree({
|
|
84
|
+
cwd: options.cwd,
|
|
85
|
+
config: options.config,
|
|
86
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
87
|
+
})
|
|
88
|
+
const second = await captureTree(options)
|
|
89
|
+
if (first.treeHash === second.treeHash
|
|
90
|
+
&& sameRepositoryFence(first.source.state, second.source.state)
|
|
91
|
+
&& arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
|
|
92
|
+
&& arraysEqual(first.source.paths, second.source.paths)) {
|
|
93
|
+
return second
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Compute stable path-level differences between two captured trees. */
|
|
100
|
+
export function diffTrees(
|
|
101
|
+
before: Readonly<Record<string, SnapshotEntry>>,
|
|
102
|
+
after: Readonly<Record<string, SnapshotEntry>>,
|
|
103
|
+
): WorkspaceChange[] {
|
|
104
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths)
|
|
105
|
+
const changes: WorkspaceChange[] = []
|
|
106
|
+
for (const path of paths) {
|
|
107
|
+
const left = before[path]
|
|
108
|
+
const right = after[path]
|
|
109
|
+
if (left === undefined && right !== undefined) {
|
|
110
|
+
changes.push({ path, kind: 'added', after: right })
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (left !== undefined && right === undefined) {
|
|
114
|
+
changes.push({ path, kind: 'deleted', before: left })
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (left === undefined || right === undefined || entriesEqual(left, right)) continue
|
|
118
|
+
if (left.kind !== right.kind) {
|
|
119
|
+
changes.push({ path, kind: 'type-changed', before: left, after: right })
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (left.kind === 'file' && right.kind === 'file' && left.blob === right.blob && left.mode !== right.mode) {
|
|
123
|
+
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
if (left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target && left.mode !== right.mode) {
|
|
127
|
+
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
changes.push({ path, kind: 'modified', before: left, after: right })
|
|
131
|
+
}
|
|
132
|
+
return changes
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Return whether two snapshot entries are byte/type/mode equivalent. */
|
|
136
|
+
export function entriesEqual(left: SnapshotEntry | undefined, right: SnapshotEntry | undefined): boolean {
|
|
137
|
+
if (left === undefined || right === undefined) return left === right
|
|
138
|
+
if (left.kind !== right.kind || left.mode !== right.mode) return false
|
|
139
|
+
if (left.kind === 'file' && right.kind === 'file') {
|
|
140
|
+
return left.blob === right.blob && left.size === right.size
|
|
141
|
+
}
|
|
142
|
+
return left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Hash a complete path map into a deterministic tree identity. */
|
|
146
|
+
export function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): string {
|
|
147
|
+
const hash = createHash('sha256')
|
|
148
|
+
for (const path of Object.keys(entries).sort(comparePaths)) {
|
|
149
|
+
const entry = entries[path]
|
|
150
|
+
if (entry === undefined) continue
|
|
151
|
+
hash.update(path)
|
|
152
|
+
hash.update('\0')
|
|
153
|
+
if (entry.kind === 'file') {
|
|
154
|
+
hash.update(`file\0${entry.blob}\0${entry.size}\0${entry.mode}\0`)
|
|
155
|
+
} else {
|
|
156
|
+
hash.update(`symlink\0${entry.target}\0${entry.mode}\0`)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return hash.digest('hex')
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function captureEntry(
|
|
163
|
+
root: string,
|
|
164
|
+
path: string,
|
|
165
|
+
maxFileBytes: number,
|
|
166
|
+
signal?: AbortSignal,
|
|
167
|
+
): Promise<{ readonly kind: 'file'; readonly snapshot: SnapshotEntry & { readonly kind: 'file' }; readonly content: Buffer }
|
|
168
|
+
| { readonly kind: 'symlink'; readonly snapshot: SnapshotEntry & { readonly kind: 'symlink' } }
|
|
169
|
+
| undefined> {
|
|
170
|
+
const target = resolveWorkspacePath(root, path)
|
|
171
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
172
|
+
throwIfAborted(signal)
|
|
173
|
+
let before
|
|
174
|
+
try {
|
|
175
|
+
before = await lstat(target, { bigint: true })
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (isNodeError(error, 'ENOENT')) return undefined
|
|
178
|
+
throw error
|
|
179
|
+
}
|
|
180
|
+
const mode = Number(before.mode & 0o777n)
|
|
181
|
+
if (before.isSymbolicLink()) {
|
|
182
|
+
const linkTarget = await readlink(target)
|
|
183
|
+
const after = await lstat(target, { bigint: true })
|
|
184
|
+
if (!sameStat(before, after)) continue
|
|
185
|
+
return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode } }
|
|
186
|
+
}
|
|
187
|
+
if (!before.isFile()) {
|
|
188
|
+
throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`)
|
|
189
|
+
}
|
|
190
|
+
if (before.size > BigInt(maxFileBytes)) {
|
|
191
|
+
throw new ChangeLedgerError(
|
|
192
|
+
'FILE_TOO_LARGE',
|
|
193
|
+
`${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
const content = await readFile(target)
|
|
197
|
+
throwIfAborted(signal)
|
|
198
|
+
const after = await lstat(target, { bigint: true })
|
|
199
|
+
if (!sameStat(before, after) || BigInt(content.length) !== after.size) continue
|
|
200
|
+
const blob = createHash('sha256').update(content).digest('hex')
|
|
201
|
+
return {
|
|
202
|
+
kind: 'file',
|
|
203
|
+
snapshot: { kind: 'file', blob, size: content.length, mode },
|
|
204
|
+
content,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sameStat(left: BigIntStats, right: BigIntStats): boolean {
|
|
211
|
+
return left.dev === right.dev
|
|
212
|
+
&& left.ino === right.ino
|
|
213
|
+
&& left.mode === right.mode
|
|
214
|
+
&& left.size === right.size
|
|
215
|
+
&& left.mtimeNs === right.mtimeNs
|
|
216
|
+
&& left.ctimeNs === right.ctimeNs
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
220
|
+
if (signal?.aborted === true) throw signal.reason
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function comparePaths(left: string, right: string): number {
|
|
224
|
+
return Buffer.from(left).compare(Buffer.from(right))
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function arraysEqual(left: readonly string[], right: readonly string[]): boolean {
|
|
228
|
+
return left.length === right.length && left.every((value, index) => value === right[index])
|
|
229
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
+
import { link, mkdir, open, readFile, readdir, rmdir, stat, unlink } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
|
+
import { isNodeError, openExclusive, processExists, readJson, syncDirectory, writeJsonAtomic } from './path-utils.js'
|
|
6
|
+
import type { ResolvedChangeLedgerConfig, RestoreOperation, RestorePointManifest } from './types.js'
|
|
7
|
+
import { parseManifest, parseOperation, validateBlobHash, validateOperationId, validateRestorePointId } from './validate.js'
|
|
8
|
+
|
|
9
|
+
interface LockRecord {
|
|
10
|
+
readonly pid: number
|
|
11
|
+
readonly createdAt: number
|
|
12
|
+
readonly nonce: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Durable content-addressed storage and per-workspace locking. */
|
|
16
|
+
export class LedgerStore {
|
|
17
|
+
constructor(readonly config: ResolvedChangeLedgerConfig) {}
|
|
18
|
+
|
|
19
|
+
/** Create the state root and reconcile crash-interrupted operations. */
|
|
20
|
+
async initialize(): Promise<number> {
|
|
21
|
+
await mkdir(join(this.config.storageDir, 'workspaces'), { recursive: true, mode: 0o700 })
|
|
22
|
+
let reconciled = 0
|
|
23
|
+
for (const workspaceKey of await safeDirectoryNames(join(this.config.storageDir, 'workspaces'))) {
|
|
24
|
+
const workspaceDir = join(this.config.storageDir, 'workspaces', workspaceKey)
|
|
25
|
+
if (await this.workspaceAppearsActive(workspaceDir)) continue
|
|
26
|
+
const operationDir = join(workspaceDir, 'operations')
|
|
27
|
+
for (const filename of await safeJsonNames(operationDir)) {
|
|
28
|
+
const path = join(operationDir, filename)
|
|
29
|
+
const operation = parseOperation(await readJson(path))
|
|
30
|
+
if (filename !== `${operation.id}.json`) {
|
|
31
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`)
|
|
32
|
+
}
|
|
33
|
+
if (this.workspaceDir(operation.workspace) !== workspaceDir) {
|
|
34
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} is stored under the wrong workspace key`)
|
|
35
|
+
}
|
|
36
|
+
if (operation.state !== 'running' && operation.state !== 'rollback-running') continue
|
|
37
|
+
await this.writeOperation({
|
|
38
|
+
...operation,
|
|
39
|
+
state: 'interrupted',
|
|
40
|
+
error: operation.error ?? 'DSH stopped before the restore operation reached a terminal state',
|
|
41
|
+
})
|
|
42
|
+
reconciled += 1
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return reconciled
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Acquire the exclusive lock for one canonical workspace. */
|
|
49
|
+
async acquire(workspace: string): Promise<() => Promise<void>> {
|
|
50
|
+
const lockPath = join(this.workspaceDir(workspace), 'lock.json')
|
|
51
|
+
await mkdir(this.workspaceDir(workspace), { recursive: true, mode: 0o700 })
|
|
52
|
+
const nonce = randomUUID()
|
|
53
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
54
|
+
let handle
|
|
55
|
+
try {
|
|
56
|
+
handle = await openExclusive(lockPath)
|
|
57
|
+
} catch (error) {
|
|
58
|
+
if (!isNodeError(error, 'EEXIST')) throw error
|
|
59
|
+
const reclaimed = await this.reclaimStaleLock(lockPath)
|
|
60
|
+
if (reclaimed) continue
|
|
61
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `another change-ledger operation owns ${JSON.stringify(workspace)}`)
|
|
62
|
+
}
|
|
63
|
+
const record: LockRecord = { pid: process.pid, createdAt: Date.now(), nonce }
|
|
64
|
+
try {
|
|
65
|
+
await handle.writeFile(`${JSON.stringify(record)}\n`, 'utf8')
|
|
66
|
+
await handle.sync()
|
|
67
|
+
} finally {
|
|
68
|
+
await handle.close()
|
|
69
|
+
}
|
|
70
|
+
await syncDirectory(this.workspaceDir(workspace))
|
|
71
|
+
let released = false
|
|
72
|
+
return async () => {
|
|
73
|
+
if (released) return
|
|
74
|
+
released = true
|
|
75
|
+
try {
|
|
76
|
+
const current = parseLock(await readJson(lockPath))
|
|
77
|
+
if (current.nonce !== nonce) return
|
|
78
|
+
await unlink(lockPath)
|
|
79
|
+
await syncDirectory(this.workspaceDir(workspace))
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (!isNodeError(error, 'ENOENT') && !isMissingStateRead(error)) throw error
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new ChangeLedgerError('WORKSPACE_LOCKED', `could not acquire change-ledger lock for ${JSON.stringify(workspace)}`)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Persist a blob if it is not already present, and verify existing content. */
|
|
89
|
+
async putBlob(workspace: string, hash: string, content: Buffer): Promise<void> {
|
|
90
|
+
validateBlobHash(hash)
|
|
91
|
+
const contentHash = createHash('sha256').update(content).digest('hex')
|
|
92
|
+
if (contentHash !== hash) {
|
|
93
|
+
throw new ChangeLedgerError('BLOB_HASH_MISMATCH', `refusing to store content whose SHA-256 does not match ${hash}`)
|
|
94
|
+
}
|
|
95
|
+
const path = this.blobPath(workspace, hash)
|
|
96
|
+
const directory = join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2))
|
|
97
|
+
await mkdir(directory, { recursive: true, mode: 0o700 })
|
|
98
|
+
const temporary = join(directory, `.${randomUUID()}.tmp`)
|
|
99
|
+
try {
|
|
100
|
+
const handle = await open(temporary, 'wx', 0o600)
|
|
101
|
+
try {
|
|
102
|
+
await handle.writeFile(content)
|
|
103
|
+
await handle.sync()
|
|
104
|
+
} finally {
|
|
105
|
+
await handle.close()
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await link(temporary, path)
|
|
109
|
+
await syncDirectory(directory)
|
|
110
|
+
return
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (!isNodeError(error, 'EEXIST')) throw error
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(temporary)
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!isNodeError(error, 'ENOENT')) throw error
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const existing = await readFile(path)
|
|
122
|
+
const existingHash = createHash('sha256').update(existing).digest('hex')
|
|
123
|
+
if (existingHash !== hash || !existing.equals(content)) {
|
|
124
|
+
throw new ChangeLedgerError('BLOB_COLLISION', `stored blob ${hash} does not match its content hash`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Read and verify one content-addressed blob. */
|
|
129
|
+
async readBlob(workspace: string, hash: string): Promise<Buffer> {
|
|
130
|
+
validateBlobHash(hash)
|
|
131
|
+
const content = await readFile(this.blobPath(workspace, hash))
|
|
132
|
+
const actual = createHash('sha256').update(content).digest('hex')
|
|
133
|
+
if (actual !== hash) throw new ChangeLedgerError('BLOB_CORRUPT', `blob ${hash} failed SHA-256 verification`)
|
|
134
|
+
return content
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Write one restore-point manifest atomically. */
|
|
138
|
+
async writeManifest(manifest: RestorePointManifest): Promise<void> {
|
|
139
|
+
parseManifest(manifest)
|
|
140
|
+
await writeJsonAtomic(this.manifestPath(manifest.workspace, manifest.id), manifest)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Load and validate one restore-point manifest. */
|
|
144
|
+
async readManifest(workspace: string, id: string): Promise<RestorePointManifest> {
|
|
145
|
+
validateRestorePointId(id)
|
|
146
|
+
let raw: unknown
|
|
147
|
+
try {
|
|
148
|
+
raw = await readJson(this.manifestPath(workspace, id))
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (isMissingStateRead(error)) {
|
|
151
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`, { cause: error })
|
|
152
|
+
}
|
|
153
|
+
throw error
|
|
154
|
+
}
|
|
155
|
+
const manifest = parseManifest(raw)
|
|
156
|
+
if (manifest.id !== id) {
|
|
157
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `restore point file ${id}.json contains id ${manifest.id}`)
|
|
158
|
+
}
|
|
159
|
+
if (manifest.workspace !== workspace) {
|
|
160
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `restore point ${id} belongs to a different workspace`)
|
|
161
|
+
}
|
|
162
|
+
return manifest
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** List all validated restore points for one workspace, newest first. */
|
|
166
|
+
async listManifests(workspace: string): Promise<RestorePointManifest[]> {
|
|
167
|
+
const manifests: RestorePointManifest[] = []
|
|
168
|
+
for (const filename of await safeJsonNames(join(this.workspaceDir(workspace), 'manifests'))) {
|
|
169
|
+
const manifest = parseManifest(await readJson(join(this.workspaceDir(workspace), 'manifests', filename)))
|
|
170
|
+
if (filename !== `${manifest.id}.json`) {
|
|
171
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} does not match its persisted id ${manifest.id}`)
|
|
172
|
+
}
|
|
173
|
+
if (manifest.workspace !== workspace) {
|
|
174
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `manifest ${filename} belongs to a different workspace`)
|
|
175
|
+
}
|
|
176
|
+
manifests.push(manifest)
|
|
177
|
+
}
|
|
178
|
+
return manifests.sort((left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Delete one restore-point manifest. Blobs remain until garbage collection succeeds. */
|
|
182
|
+
async deleteManifest(workspace: string, id: string): Promise<void> {
|
|
183
|
+
validateRestorePointId(id)
|
|
184
|
+
const directory = join(this.workspaceDir(workspace), 'manifests')
|
|
185
|
+
try {
|
|
186
|
+
await unlink(this.manifestPath(workspace, id))
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (isNodeError(error, 'ENOENT')) {
|
|
189
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `restore point ${id} does not exist`)
|
|
190
|
+
}
|
|
191
|
+
throw error
|
|
192
|
+
}
|
|
193
|
+
await syncDirectory(directory)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Persist one restore-operation journal. */
|
|
197
|
+
async writeOperation(operation: RestoreOperation): Promise<void> {
|
|
198
|
+
parseOperation(operation)
|
|
199
|
+
await writeJsonAtomic(this.operationPath(operation.workspace, operation.id), operation)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** List validated restore operations for one workspace. */
|
|
203
|
+
async listOperations(workspace: string): Promise<RestoreOperation[]> {
|
|
204
|
+
const operations: RestoreOperation[] = []
|
|
205
|
+
for (const filename of await safeJsonNames(join(this.workspaceDir(workspace), 'operations'))) {
|
|
206
|
+
const operation = parseOperation(await readJson(join(this.workspaceDir(workspace), 'operations', filename)))
|
|
207
|
+
if (filename !== `${operation.id}.json`) {
|
|
208
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} does not match its persisted id ${operation.id}`)
|
|
209
|
+
}
|
|
210
|
+
if (operation.workspace !== workspace) {
|
|
211
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `operation ${filename} belongs to a different workspace`)
|
|
212
|
+
}
|
|
213
|
+
operations.push(operation)
|
|
214
|
+
}
|
|
215
|
+
return operations.sort((left, right) => right.startedAt - left.startedAt || right.id.localeCompare(left.id))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Return whether an incomplete operation still references a restore point. */
|
|
219
|
+
async isReferencedByRecovery(workspace: string, restorePointId: string): Promise<boolean> {
|
|
220
|
+
return (await this.listOperations(workspace)).some((operation) =>
|
|
221
|
+
(operation.state === 'interrupted' || operation.state === 'recovery-required')
|
|
222
|
+
&& (operation.restorePointId === restorePointId || operation.rescuePointId === restorePointId))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Delete blobs not referenced by any remaining manifest. */
|
|
226
|
+
async collectGarbage(
|
|
227
|
+
workspace: string,
|
|
228
|
+
additionalReferenced: Iterable<string> = [],
|
|
229
|
+
): Promise<{ deletedBlobs: number; retainedBlobs: number }> {
|
|
230
|
+
const referenced = new Set<string>(additionalReferenced)
|
|
231
|
+
for (const hash of referenced) validateBlobHash(hash)
|
|
232
|
+
for (const manifest of await this.listManifests(workspace)) {
|
|
233
|
+
for (const entry of Object.values(manifest.entries)) {
|
|
234
|
+
if (entry.kind === 'file') referenced.add(entry.blob)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
let deletedBlobs = 0
|
|
238
|
+
let retainedBlobs = 0
|
|
239
|
+
const blobsRoot = join(this.workspaceDir(workspace), 'blobs')
|
|
240
|
+
for (const prefix of await safeDirectoryNames(blobsRoot)) {
|
|
241
|
+
const prefixPath = join(blobsRoot, prefix)
|
|
242
|
+
for (const filename of await safeFileNames(prefixPath)) {
|
|
243
|
+
if (filename.startsWith('.') && filename.endsWith('.tmp')) {
|
|
244
|
+
await unlink(join(prefixPath, filename))
|
|
245
|
+
deletedBlobs += 1
|
|
246
|
+
continue
|
|
247
|
+
}
|
|
248
|
+
validateBlobHash(filename)
|
|
249
|
+
if (filename.slice(0, 2) !== prefix) {
|
|
250
|
+
throw new ChangeLedgerError('STATE_CORRUPT', `blob ${filename} is stored under the wrong prefix directory ${prefix}`)
|
|
251
|
+
}
|
|
252
|
+
if (referenced.has(filename)) {
|
|
253
|
+
retainedBlobs += 1
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
await unlink(join(prefixPath, filename))
|
|
257
|
+
deletedBlobs += 1
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
await rmdir(prefixPath)
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!isNodeError(error, 'ENOTEMPTY') && !isNodeError(error, 'EEXIST') && !isNodeError(error, 'ENOENT')) throw error
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { deletedBlobs, retainedBlobs }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private workspaceDir(workspace: string): string {
|
|
269
|
+
const key = createHash('sha256').update(workspace).digest('hex')
|
|
270
|
+
return join(this.config.storageDir, 'workspaces', key)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private manifestPath(workspace: string, id: string): string {
|
|
274
|
+
return join(this.workspaceDir(workspace), 'manifests', `${validateRestorePointId(id)}.json`)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private operationPath(workspace: string, id: string): string {
|
|
278
|
+
return join(this.workspaceDir(workspace), 'operations', `${validateOperationId(id)}.json`)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private blobPath(workspace: string, hash: string): string {
|
|
282
|
+
validateBlobHash(hash)
|
|
283
|
+
return join(this.workspaceDir(workspace), 'blobs', hash.slice(0, 2), hash)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private async reclaimStaleLock(lockPath: string): Promise<boolean> {
|
|
287
|
+
let lock: LockRecord
|
|
288
|
+
try {
|
|
289
|
+
lock = parseLock(await readJson(lockPath))
|
|
290
|
+
} catch (error) {
|
|
291
|
+
let age = 0
|
|
292
|
+
try {
|
|
293
|
+
age = Date.now() - (await stat(lockPath)).mtimeMs
|
|
294
|
+
} catch (statError) {
|
|
295
|
+
if (isNodeError(statError, 'ENOENT')) return true
|
|
296
|
+
throw statError
|
|
297
|
+
}
|
|
298
|
+
if (age < this.config.staleLockMs) {
|
|
299
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', `active lock is unreadable: ${errorMessage(error)}`)
|
|
300
|
+
}
|
|
301
|
+
await unlink(lockPath)
|
|
302
|
+
return true
|
|
303
|
+
}
|
|
304
|
+
if (processExists(lock.pid)) return false
|
|
305
|
+
if (Date.now() - lock.createdAt < this.config.staleLockMs) return false
|
|
306
|
+
try {
|
|
307
|
+
const current = parseLock(await readJson(lockPath))
|
|
308
|
+
if (current.nonce !== lock.nonce) return false
|
|
309
|
+
await unlink(lockPath)
|
|
310
|
+
return true
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (isNodeError(error, 'ENOENT')) return true
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private async workspaceAppearsActive(workspaceDir: string): Promise<boolean> {
|
|
318
|
+
const lockPath = join(workspaceDir, 'lock.json')
|
|
319
|
+
try {
|
|
320
|
+
const lock = parseLock(await readJson(lockPath))
|
|
321
|
+
if (processExists(lock.pid)) return true
|
|
322
|
+
return Date.now() - lock.createdAt < this.config.staleLockMs
|
|
323
|
+
} catch (error) {
|
|
324
|
+
if (error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error.cause)) return false
|
|
325
|
+
if (isNodeError(error, 'ENOENT')) return false
|
|
326
|
+
try {
|
|
327
|
+
return Date.now() - (await stat(lockPath)).mtimeMs < this.config.staleLockMs
|
|
328
|
+
} catch (statError) {
|
|
329
|
+
if (isNodeError(statError, 'ENOENT')) return false
|
|
330
|
+
throw statError
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function parseLock(value: unknown): LockRecord {
|
|
337
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
338
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock must be an object')
|
|
339
|
+
}
|
|
340
|
+
const record = value as Record<string, unknown>
|
|
341
|
+
if (!Number.isSafeInteger(record.pid) || (record.pid as number) <= 0) {
|
|
342
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock pid is invalid')
|
|
343
|
+
}
|
|
344
|
+
if (!Number.isSafeInteger(record.createdAt) || (record.createdAt as number) < 0) {
|
|
345
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock createdAt is invalid')
|
|
346
|
+
}
|
|
347
|
+
if (typeof record.nonce !== 'string' || record.nonce === '') {
|
|
348
|
+
throw new ChangeLedgerError('WORKSPACE_LOCK_CORRUPT', 'workspace lock nonce is invalid')
|
|
349
|
+
}
|
|
350
|
+
return { pid: record.pid as number, createdAt: record.createdAt as number, nonce: record.nonce }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function safeJsonNames(path: string): Promise<string[]> {
|
|
354
|
+
return (await safeFileNames(path)).filter((name) => name.endsWith('.json')).sort()
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function safeDirectoryNames(path: string): Promise<string[]> {
|
|
358
|
+
try {
|
|
359
|
+
const entries = await readdir(path, { withFileTypes: true })
|
|
360
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort()
|
|
361
|
+
} catch (error) {
|
|
362
|
+
if (isNodeError(error, 'ENOENT')) return []
|
|
363
|
+
throw error
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function safeFileNames(path: string): Promise<string[]> {
|
|
368
|
+
try {
|
|
369
|
+
const entries = await readdir(path, { withFileTypes: true })
|
|
370
|
+
return entries.filter((entry) => entry.isFile()).map((entry) => entry.name).sort()
|
|
371
|
+
} catch (error) {
|
|
372
|
+
if (isNodeError(error, 'ENOENT')) return []
|
|
373
|
+
throw error
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function isMissingCause(error: unknown): boolean {
|
|
378
|
+
return error instanceof Error && 'cause' in error && isNodeError(error.cause, 'ENOENT')
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function isMissingStateRead(error: unknown): boolean {
|
|
382
|
+
return error instanceof ChangeLedgerError && error.code === 'STATE_READ_FAILED' && isMissingCause(error)
|
|
383
|
+
}
|