@anionex/dsh-turn-rewind 0.1.1 → 0.2.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/README.md +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/LARGE_REPOSITORY_CHECKPOINT_DESIGN.md +340 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +291 -22
- package/lib/client.js.map +1 -1
- package/lib/deadline.js +13 -0
- package/lib/engine.js +622 -121
- package/lib/git-checkpoint.js +739 -0
- package/lib/git.js +295 -22
- package/lib/index.js +18 -2
- package/lib/path-utils.js +9 -0
- package/lib/rewind-host.js +250 -48
- package/lib/settings.js +58 -0
- package/lib/snapshot.js +113 -13
- package/lib/store.js +1361 -119
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/deadline.d.ts +6 -0
- package/lib/types/engine.d.ts +48 -2
- package/lib/types/git-checkpoint.d.ts +46 -0
- package/lib/types/git.d.ts +31 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/path-utils.d.ts +2 -0
- package/lib/types/rewind-host.d.ts +8 -1
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/snapshot.d.ts +6 -1
- package/lib/types/store.d.ts +90 -6
- package/lib/types/types.d.ts +63 -1
- package/lib/types.js +2 -0
- package/lib/validate.js +68 -9
- package/package.json +21 -3
- package/scripts/benchmark-git-checkpoint.mjs +149 -0
- package/scripts/wrap-client.mjs +18 -0
- package/src/client/index.tsx +536 -41
- package/src/deadline.ts +18 -0
- package/src/engine.ts +695 -126
- package/src/git-checkpoint.ts +853 -0
- package/src/git.ts +337 -22
- package/src/index.ts +21 -2
- package/src/path-utils.ts +10 -0
- package/src/rewind-host.ts +278 -50
- package/src/settings.ts +93 -0
- package/src/snapshot.ts +122 -12
- package/src/store.ts +1502 -104
- package/src/types.ts +69 -1
- package/src/validate.ts +67 -7
package/src/snapshot.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto'
|
|
2
|
-
import type
|
|
3
|
-
import { lstat,
|
|
2
|
+
import { constants, type BigIntStats } from 'node:fs'
|
|
3
|
+
import { lstat, open, readlink } from 'node:fs/promises'
|
|
4
4
|
import { ChangeLedgerError } from './errors.js'
|
|
5
5
|
import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
|
|
6
6
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js'
|
|
@@ -11,10 +11,13 @@ import type {
|
|
|
11
11
|
WorkspaceChange,
|
|
12
12
|
} from './types.js'
|
|
13
13
|
|
|
14
|
+
const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024
|
|
15
|
+
|
|
14
16
|
/** One captured tree, optionally persisted into the blob store. */
|
|
15
17
|
export interface CapturedTree {
|
|
16
18
|
readonly source: RepositorySnapshotSource
|
|
17
19
|
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
20
|
+
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>
|
|
18
21
|
readonly treeHash: string
|
|
19
22
|
readonly fileCount: number
|
|
20
23
|
readonly totalBytes: number
|
|
@@ -25,6 +28,7 @@ export async function captureTree(options: {
|
|
|
25
28
|
readonly cwd: string
|
|
26
29
|
readonly config: ResolvedChangeLedgerConfig
|
|
27
30
|
readonly store?: LedgerStore
|
|
31
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256'
|
|
28
32
|
readonly signal?: AbortSignal
|
|
29
33
|
}): Promise<CapturedTree> {
|
|
30
34
|
throwIfAborted(options.signal)
|
|
@@ -37,11 +41,18 @@ export async function captureTree(options: {
|
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
44
|
+
const gitObjectFormat = options.gitObjectFormat
|
|
45
|
+
const gitCapture = gitObjectFormat === undefined
|
|
46
|
+
? undefined
|
|
47
|
+
: {
|
|
48
|
+
objectFormat: gitObjectFormat,
|
|
49
|
+
entries: Object.create(null) as Record<string, SnapshotEntry>,
|
|
50
|
+
}
|
|
40
51
|
let totalBytes = 0
|
|
41
|
-
|
|
52
|
+
const capturePath = async (path: string): Promise<void> => {
|
|
42
53
|
throwIfAborted(options.signal)
|
|
43
54
|
const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal)
|
|
44
|
-
if (entry === undefined)
|
|
55
|
+
if (entry === undefined) return
|
|
45
56
|
if (entry.kind === 'file') {
|
|
46
57
|
totalBytes += entry.content.length
|
|
47
58
|
if (totalBytes > options.config.maxSnapshotBytes) {
|
|
@@ -54,14 +65,49 @@ export async function captureTree(options: {
|
|
|
54
65
|
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content)
|
|
55
66
|
}
|
|
56
67
|
entries[path] = entry.snapshot
|
|
57
|
-
|
|
68
|
+
if (gitCapture !== undefined) {
|
|
69
|
+
gitCapture.entries[path] = gitFileSnapshot(entry.snapshot, entry.content, gitCapture.objectFormat)
|
|
70
|
+
}
|
|
71
|
+
return
|
|
58
72
|
}
|
|
59
73
|
entries[path] = entry.snapshot
|
|
74
|
+
if (gitCapture !== undefined) gitCapture.entries[path] = entry.snapshot
|
|
75
|
+
}
|
|
76
|
+
if (gitCapture === undefined) {
|
|
77
|
+
for (const path of source.paths) await capturePath(path)
|
|
78
|
+
} else {
|
|
79
|
+
let next = 0
|
|
80
|
+
let failed = false
|
|
81
|
+
let firstError: unknown = new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'parallel comparison capture failed')
|
|
82
|
+
const concurrency = Math.min(
|
|
83
|
+
32,
|
|
84
|
+
source.paths.length,
|
|
85
|
+
Math.max(1, Math.floor(COMPARISON_READ_BUDGET_BYTES / options.config.maxFileBytes)),
|
|
86
|
+
)
|
|
87
|
+
const workers = Array.from({ length: concurrency }, async () => {
|
|
88
|
+
while (!failed && next < source.paths.length) {
|
|
89
|
+
const index = next
|
|
90
|
+
next += 1
|
|
91
|
+
const path = source.paths[index]
|
|
92
|
+
if (path === undefined) continue
|
|
93
|
+
try {
|
|
94
|
+
await capturePath(path)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (!failed) {
|
|
97
|
+
failed = true
|
|
98
|
+
firstError = error
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
await Promise.all(workers)
|
|
104
|
+
if (failed) throw firstError
|
|
60
105
|
}
|
|
61
106
|
|
|
62
107
|
return {
|
|
63
108
|
source,
|
|
64
109
|
entries,
|
|
110
|
+
...(gitCapture === undefined ? {} : { gitEntries: gitCapture.entries }),
|
|
65
111
|
treeHash: hashTree(entries),
|
|
66
112
|
fileCount: Object.keys(entries).length,
|
|
67
113
|
totalBytes,
|
|
@@ -77,12 +123,14 @@ export async function captureStableTree(options: {
|
|
|
77
123
|
readonly cwd: string
|
|
78
124
|
readonly config: ResolvedChangeLedgerConfig
|
|
79
125
|
readonly store?: LedgerStore
|
|
126
|
+
readonly gitObjectFormat?: 'sha1' | 'sha256'
|
|
80
127
|
readonly signal?: AbortSignal
|
|
81
128
|
}): Promise<CapturedTree> {
|
|
82
129
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
83
130
|
const first = await captureTree({
|
|
84
131
|
cwd: options.cwd,
|
|
85
132
|
config: options.config,
|
|
133
|
+
...(options.gitObjectFormat === undefined ? {} : { gitObjectFormat: options.gitObjectFormat }),
|
|
86
134
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
87
135
|
})
|
|
88
136
|
const second = await captureTree(options)
|
|
@@ -100,12 +148,16 @@ export async function captureStableTree(options: {
|
|
|
100
148
|
export function diffTrees(
|
|
101
149
|
before: Readonly<Record<string, SnapshotEntry>>,
|
|
102
150
|
after: Readonly<Record<string, SnapshotEntry>>,
|
|
151
|
+
comparisonBefore: Readonly<Record<string, SnapshotEntry>> = before,
|
|
152
|
+
comparisonAfter: Readonly<Record<string, SnapshotEntry>> = after,
|
|
103
153
|
): WorkspaceChange[] {
|
|
104
154
|
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths)
|
|
105
155
|
const changes: WorkspaceChange[] = []
|
|
106
156
|
for (const path of paths) {
|
|
107
157
|
const left = before[path]
|
|
108
158
|
const right = after[path]
|
|
159
|
+
const comparisonLeft = comparisonBefore[path]
|
|
160
|
+
const comparisonRight = comparisonAfter[path]
|
|
109
161
|
if (left === undefined && right !== undefined) {
|
|
110
162
|
changes.push({ path, kind: 'added', after: right })
|
|
111
163
|
continue
|
|
@@ -114,16 +166,26 @@ export function diffTrees(
|
|
|
114
166
|
changes.push({ path, kind: 'deleted', before: left })
|
|
115
167
|
continue
|
|
116
168
|
}
|
|
117
|
-
if (left === undefined || right === undefined || entriesEqual(
|
|
169
|
+
if (left === undefined || right === undefined || entriesEqual(comparisonLeft, comparisonRight)) continue
|
|
118
170
|
if (left.kind !== right.kind) {
|
|
119
171
|
changes.push({ path, kind: 'type-changed', before: left, after: right })
|
|
120
172
|
continue
|
|
121
173
|
}
|
|
122
|
-
if (left.kind === 'file'
|
|
174
|
+
if (left.kind === 'file'
|
|
175
|
+
&& right.kind === 'file'
|
|
176
|
+
&& comparisonLeft?.kind === 'file'
|
|
177
|
+
&& comparisonRight?.kind === 'file'
|
|
178
|
+
&& comparisonLeft.blob === comparisonRight.blob
|
|
179
|
+
&& left.mode !== right.mode) {
|
|
123
180
|
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
124
181
|
continue
|
|
125
182
|
}
|
|
126
|
-
if (left.kind === 'symlink'
|
|
183
|
+
if (left.kind === 'symlink'
|
|
184
|
+
&& right.kind === 'symlink'
|
|
185
|
+
&& comparisonLeft?.kind === 'symlink'
|
|
186
|
+
&& comparisonRight?.kind === 'symlink'
|
|
187
|
+
&& comparisonLeft.target === comparisonRight.target
|
|
188
|
+
&& left.mode !== right.mode) {
|
|
127
189
|
changes.push({ path, kind: 'mode-changed', before: left, after: right })
|
|
128
190
|
continue
|
|
129
191
|
}
|
|
@@ -159,6 +221,29 @@ export function hashTree(entries: Readonly<Record<string, SnapshotEntry>>): stri
|
|
|
159
221
|
return hash.digest('hex')
|
|
160
222
|
}
|
|
161
223
|
|
|
224
|
+
/** Byte-verify one workspace-relative path without following a final symlink. */
|
|
225
|
+
export async function captureSnapshotEntry(
|
|
226
|
+
root: string,
|
|
227
|
+
path: string,
|
|
228
|
+
maxFileBytes: number,
|
|
229
|
+
signal?: AbortSignal,
|
|
230
|
+
gitObjectFormat?: 'sha1' | 'sha256',
|
|
231
|
+
): Promise<SnapshotEntry | undefined> {
|
|
232
|
+
const entry = await captureEntry(root, path, maxFileBytes, signal)
|
|
233
|
+
if (entry === undefined || entry.kind === 'symlink' || gitObjectFormat === undefined) return entry?.snapshot
|
|
234
|
+
return gitFileSnapshot(entry.snapshot, entry.content, gitObjectFormat)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function gitFileSnapshot(
|
|
238
|
+
snapshot: SnapshotEntry & { readonly kind: 'file' },
|
|
239
|
+
content: Buffer,
|
|
240
|
+
objectFormat: 'sha1' | 'sha256',
|
|
241
|
+
): SnapshotEntry {
|
|
242
|
+
const header = Buffer.from(`blob ${String(content.length)}\0`)
|
|
243
|
+
const blob = createHash(objectFormat).update(header).update(content).digest('hex')
|
|
244
|
+
return { ...snapshot, blob, provider: 'git' }
|
|
245
|
+
}
|
|
246
|
+
|
|
162
247
|
async function captureEntry(
|
|
163
248
|
root: string,
|
|
164
249
|
path: string,
|
|
@@ -193,10 +278,24 @@ async function captureEntry(
|
|
|
193
278
|
`${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
|
|
194
279
|
)
|
|
195
280
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
281
|
+
let handle
|
|
282
|
+
try {
|
|
283
|
+
handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (isNodeError(error, 'ELOOP')) continue
|
|
286
|
+
throw error
|
|
287
|
+
}
|
|
288
|
+
let content: Buffer
|
|
289
|
+
try {
|
|
290
|
+
const opened = await handle.stat({ bigint: true })
|
|
291
|
+
if (!opened.isFile() || !sameStat(before, opened)) continue
|
|
292
|
+
content = await readBoundedFile(handle, Number(opened.size))
|
|
293
|
+
throwIfAborted(signal)
|
|
294
|
+
const after = await handle.stat({ bigint: true })
|
|
295
|
+
if (!sameStat(opened, after) || BigInt(content.length) !== after.size) continue
|
|
296
|
+
} finally {
|
|
297
|
+
await handle.close()
|
|
298
|
+
}
|
|
200
299
|
const blob = createHash('sha256').update(content).digest('hex')
|
|
201
300
|
return {
|
|
202
301
|
kind: 'file',
|
|
@@ -207,6 +306,17 @@ async function captureEntry(
|
|
|
207
306
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`)
|
|
208
307
|
}
|
|
209
308
|
|
|
309
|
+
async function readBoundedFile(handle: Awaited<ReturnType<typeof open>>, expectedSize: number): Promise<Buffer> {
|
|
310
|
+
const buffer = Buffer.allocUnsafe(expectedSize + 1)
|
|
311
|
+
let offset = 0
|
|
312
|
+
while (offset < buffer.length) {
|
|
313
|
+
const { bytesRead } = await handle.read(buffer, offset, buffer.length - offset, offset)
|
|
314
|
+
if (bytesRead === 0) break
|
|
315
|
+
offset += bytesRead
|
|
316
|
+
}
|
|
317
|
+
return buffer.subarray(0, offset)
|
|
318
|
+
}
|
|
319
|
+
|
|
210
320
|
function sameStat(left: BigIntStats, right: BigIntStats): boolean {
|
|
211
321
|
return left.dev === right.dev
|
|
212
322
|
&& left.ino === right.ino
|