@davideasden/pi-undo 0.2.2 → 0.2.3
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 +17 -8
- package/native/bin/pi-undo-fs-darwin-arm64 +0 -0
- package/package.json +5 -2
- package/src/atomic-fs.ts +2 -2
- package/src/controller.ts +71 -21
- package/src/durable-pack.ts +573 -0
- package/src/journal.ts +33 -6
- package/src/native-restore.ts +146 -0
- package/src/packed-recovery.ts +324 -0
- package/src/pi-runtime.ts +95 -4
- package/src/quarantine.ts +57 -31
- package/src/restore-engine.ts +400 -35
- package/src/snapshot-store.ts +7 -0
- package/src/workspace-lock.ts +5 -1
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { copyFile, link, lstat, open, readFile, rename, rm, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { fsyncDirectory, fsyncFile } from "./atomic-fs.ts";
|
|
6
|
+
import { canonicalJson, checksum } from "./encoding.ts";
|
|
7
|
+
import type { MutationJournal } from "./mutation-journal.ts";
|
|
8
|
+
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
9
|
+
import { fingerprintAbsent, fingerprintBytes, fingerprintFile, fingerprintSymlink } from "./quarantine.ts";
|
|
10
|
+
|
|
11
|
+
const PACK_FILE = "durable-pack-v1.bin";
|
|
12
|
+
const MAGIC = Buffer.from("PIUNDO-PACK-V1\0", "ascii");
|
|
13
|
+
const MAX_HEADER_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const FINALIZE_CONCURRENCY = 32;
|
|
15
|
+
|
|
16
|
+
export type DurableLeafInput =
|
|
17
|
+
| { readonly kind: "absent"; readonly fingerprint: string }
|
|
18
|
+
| { readonly kind: "file"; readonly fingerprint: string; readonly mode: number; readonly bytes: Uint8Array }
|
|
19
|
+
| { readonly kind: "symlink"; readonly fingerprint: string; readonly linkText: string };
|
|
20
|
+
|
|
21
|
+
export interface DurablePackEntryInput {
|
|
22
|
+
readonly path: string;
|
|
23
|
+
readonly sourceArtifact: string;
|
|
24
|
+
readonly targetArtifact: string | null;
|
|
25
|
+
readonly sourceFingerprint: string;
|
|
26
|
+
readonly targetFingerprint: string | null;
|
|
27
|
+
readonly variants: readonly DurableLeafInput[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface DurablePackInput {
|
|
31
|
+
readonly opId: string;
|
|
32
|
+
readonly planDigest: string;
|
|
33
|
+
readonly entries: readonly DurablePackEntryInput[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface PackedVariantHeader {
|
|
37
|
+
readonly kind: DurableLeafInput["kind"];
|
|
38
|
+
readonly fingerprint: string;
|
|
39
|
+
readonly mode?: number;
|
|
40
|
+
readonly linkText?: string;
|
|
41
|
+
readonly offset?: number;
|
|
42
|
+
readonly length?: number;
|
|
43
|
+
readonly dataChecksum?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface PackedEntryHeader {
|
|
47
|
+
readonly path: string;
|
|
48
|
+
readonly sourceArtifact: string;
|
|
49
|
+
readonly targetArtifact: string | null;
|
|
50
|
+
readonly sourceFingerprint: string;
|
|
51
|
+
readonly targetFingerprint: string | null;
|
|
52
|
+
readonly variants: readonly PackedVariantHeader[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface PackHeaderPayload {
|
|
56
|
+
readonly schemaVersion: 1;
|
|
57
|
+
readonly opId: string;
|
|
58
|
+
readonly planDigest: string;
|
|
59
|
+
readonly entries: readonly PackedEntryHeader[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface PackHeader extends PackHeaderPayload {
|
|
63
|
+
readonly checksum: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type DurableLeaf =
|
|
67
|
+
| { readonly kind: "absent"; readonly fingerprint: string }
|
|
68
|
+
| { readonly kind: "file"; readonly fingerprint: string; readonly mode: number; readonly bytes: Uint8Array }
|
|
69
|
+
| { readonly kind: "symlink"; readonly fingerprint: string; readonly linkText: string };
|
|
70
|
+
|
|
71
|
+
export class DurablePack {
|
|
72
|
+
readonly opId: string;
|
|
73
|
+
readonly planDigest: string;
|
|
74
|
+
readonly storagePath: string;
|
|
75
|
+
readonly packChecksum: string;
|
|
76
|
+
private readonly entries: ReadonlyMap<string, {
|
|
77
|
+
readonly sourceArtifact: string;
|
|
78
|
+
readonly targetArtifact: string | null;
|
|
79
|
+
readonly sourceFingerprint: string;
|
|
80
|
+
readonly targetFingerprint: string | null;
|
|
81
|
+
readonly variants: ReadonlyMap<string, DurableLeaf>;
|
|
82
|
+
}>;
|
|
83
|
+
|
|
84
|
+
constructor(
|
|
85
|
+
opId: string,
|
|
86
|
+
planDigest: string,
|
|
87
|
+
storagePath: string,
|
|
88
|
+
packChecksum: string,
|
|
89
|
+
entries: ReadonlyMap<string, {
|
|
90
|
+
readonly sourceArtifact: string;
|
|
91
|
+
readonly targetArtifact: string | null;
|
|
92
|
+
readonly sourceFingerprint: string;
|
|
93
|
+
readonly targetFingerprint: string | null;
|
|
94
|
+
readonly variants: ReadonlyMap<string, DurableLeaf>;
|
|
95
|
+
}>,
|
|
96
|
+
) {
|
|
97
|
+
this.opId = opId;
|
|
98
|
+
this.planDigest = planDigest;
|
|
99
|
+
this.storagePath = storagePath;
|
|
100
|
+
this.packChecksum = packChecksum;
|
|
101
|
+
this.entries = entries;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
paths(): readonly string[] {
|
|
105
|
+
return [...this.entries.keys()];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
artifacts(path: string): { readonly source: string; readonly target: string | null } | undefined {
|
|
109
|
+
const entry = this.entries.get(path);
|
|
110
|
+
return entry === undefined ? undefined : { source: entry.sourceArtifact, target: entry.targetArtifact };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
sourceFingerprint(path: string): string | undefined {
|
|
114
|
+
return this.entries.get(path)?.sourceFingerprint;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
targetFingerprint(path: string): string | null | undefined {
|
|
118
|
+
return this.entries.get(path)?.targetFingerprint;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
leaf(path: string, fingerprint: string): DurableLeaf | undefined {
|
|
122
|
+
const leaf = this.entries.get(path)?.variants.get(fingerprint);
|
|
123
|
+
if (leaf === undefined) return undefined;
|
|
124
|
+
return leaf.kind === "file" ? { ...leaf, bytes: new Uint8Array(leaf.bytes) } : leaf;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function durablePackPath(journal: MutationJournal): string {
|
|
129
|
+
return join(dirname(journal.storagePath), PACK_FILE);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function createDurablePack(
|
|
133
|
+
journal: MutationJournal,
|
|
134
|
+
input: DurablePackInput,
|
|
135
|
+
): Promise<DurablePack> {
|
|
136
|
+
if (input.opId !== journal.operationId) throw new Error("durable pack opId 与 mutation journal 不匹配");
|
|
137
|
+
assertDigest(input.planDigest, "planDigest");
|
|
138
|
+
const entries = canonicalEntries(input.entries);
|
|
139
|
+
const payloads: Buffer[] = [];
|
|
140
|
+
let offset = 0;
|
|
141
|
+
const headerEntries: PackedEntryHeader[] = entries.map((entry) => ({
|
|
142
|
+
path: entry.path,
|
|
143
|
+
sourceArtifact: entry.sourceArtifact,
|
|
144
|
+
targetArtifact: entry.targetArtifact,
|
|
145
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
146
|
+
targetFingerprint: entry.targetFingerprint,
|
|
147
|
+
variants: entry.variants.map((variant): PackedVariantHeader => {
|
|
148
|
+
if (variant.kind === "absent") return variant;
|
|
149
|
+
if (variant.kind === "symlink") return variant;
|
|
150
|
+
const bytes = Buffer.from(variant.bytes);
|
|
151
|
+
payloads.push(bytes);
|
|
152
|
+
const header = {
|
|
153
|
+
kind: "file" as const,
|
|
154
|
+
fingerprint: variant.fingerprint,
|
|
155
|
+
mode: variant.mode,
|
|
156
|
+
offset,
|
|
157
|
+
length: bytes.length,
|
|
158
|
+
dataChecksum: checksum(bytes),
|
|
159
|
+
};
|
|
160
|
+
offset += bytes.length;
|
|
161
|
+
return header;
|
|
162
|
+
}),
|
|
163
|
+
}));
|
|
164
|
+
const headerPayload: PackHeaderPayload = {
|
|
165
|
+
schemaVersion: 1,
|
|
166
|
+
opId: input.opId,
|
|
167
|
+
planDigest: input.planDigest,
|
|
168
|
+
entries: headerEntries,
|
|
169
|
+
};
|
|
170
|
+
const header: PackHeader = {
|
|
171
|
+
...headerPayload,
|
|
172
|
+
checksum: checksum(canonicalJson(headerPayload)),
|
|
173
|
+
};
|
|
174
|
+
const headerBytes = Buffer.from(canonicalJson(header), "utf8");
|
|
175
|
+
if (headerBytes.length > MAX_HEADER_BYTES) throw new Error("durable pack header 过大");
|
|
176
|
+
const lengthBytes = Buffer.allocUnsafe(4);
|
|
177
|
+
lengthBytes.writeUInt32BE(headerBytes.length);
|
|
178
|
+
const packPath = durablePackPath(journal);
|
|
179
|
+
const temporary = join(dirname(packPath), `.${PACK_FILE}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
|
|
180
|
+
let handle;
|
|
181
|
+
try {
|
|
182
|
+
handle = await open(temporary, "wx", 0o600);
|
|
183
|
+
await writeAll(handle, MAGIC);
|
|
184
|
+
await writeAll(handle, lengthBytes);
|
|
185
|
+
await writeAll(handle, headerBytes);
|
|
186
|
+
for (const payload of payloads) await writeAll(handle, payload);
|
|
187
|
+
await handle.sync();
|
|
188
|
+
await handle.close();
|
|
189
|
+
handle = undefined;
|
|
190
|
+
await rename(temporary, packPath);
|
|
191
|
+
await fsyncDirectory(dirname(packPath));
|
|
192
|
+
} catch (error) {
|
|
193
|
+
await handle?.close().catch(() => {});
|
|
194
|
+
await rm(temporary, { force: true }).catch(() => {});
|
|
195
|
+
throw error;
|
|
196
|
+
}
|
|
197
|
+
const packChecksum = checksum(Buffer.concat([MAGIC, lengthBytes, headerBytes, ...payloads]));
|
|
198
|
+
return durablePackFromInput(input.opId, input.planDigest, packPath, packChecksum, entries);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export async function loadDurablePack(
|
|
202
|
+
journal: MutationJournal,
|
|
203
|
+
expectedPlanDigest?: string,
|
|
204
|
+
allowForeignOperation = false,
|
|
205
|
+
): Promise<DurablePack> {
|
|
206
|
+
const bytes = await readFile(durablePackPath(journal));
|
|
207
|
+
if (bytes.length < MAGIC.length + 4 || !bytes.subarray(0, MAGIC.length).equals(MAGIC)) {
|
|
208
|
+
throw new Error("durable pack magic 无效");
|
|
209
|
+
}
|
|
210
|
+
const headerLength = bytes.readUInt32BE(MAGIC.length);
|
|
211
|
+
if (headerLength <= 0 || headerLength > MAX_HEADER_BYTES) throw new Error("durable pack header 长度无效");
|
|
212
|
+
const headerStart = MAGIC.length + 4;
|
|
213
|
+
const payloadStart = headerStart + headerLength;
|
|
214
|
+
if (payloadStart > bytes.length) throw new Error("durable pack header 被截断");
|
|
215
|
+
const parsed: unknown = JSON.parse(bytes.subarray(headerStart, payloadStart).toString("utf8"));
|
|
216
|
+
const header = assertPackHeader(parsed);
|
|
217
|
+
if (!allowForeignOperation && header.opId !== journal.operationId) throw new Error("durable pack opId 不匹配");
|
|
218
|
+
if (expectedPlanDigest !== undefined && header.planDigest !== expectedPlanDigest) {
|
|
219
|
+
throw new Error("durable pack planDigest 不匹配");
|
|
220
|
+
}
|
|
221
|
+
const result = new Map<string, {
|
|
222
|
+
readonly sourceArtifact: string;
|
|
223
|
+
readonly targetArtifact: string | null;
|
|
224
|
+
readonly sourceFingerprint: string;
|
|
225
|
+
readonly targetFingerprint: string | null;
|
|
226
|
+
readonly variants: ReadonlyMap<string, DurableLeaf>;
|
|
227
|
+
}>();
|
|
228
|
+
for (const entry of header.entries) {
|
|
229
|
+
const variants = new Map<string, DurableLeaf>();
|
|
230
|
+
for (const variant of entry.variants) {
|
|
231
|
+
let leaf: DurableLeaf;
|
|
232
|
+
if (variant.kind === "absent") {
|
|
233
|
+
leaf = { kind: "absent", fingerprint: variant.fingerprint };
|
|
234
|
+
} else if (variant.kind === "symlink") {
|
|
235
|
+
leaf = { kind: "symlink", fingerprint: variant.fingerprint, linkText: variant.linkText! };
|
|
236
|
+
} else {
|
|
237
|
+
const start = payloadStart + variant.offset!;
|
|
238
|
+
const end = start + variant.length!;
|
|
239
|
+
if (start < payloadStart || end > bytes.length) throw new Error("durable pack payload 越界");
|
|
240
|
+
const content = bytes.subarray(start, end);
|
|
241
|
+
if (checksum(content) !== variant.dataChecksum) throw new Error("durable pack payload checksum 不匹配");
|
|
242
|
+
leaf = {
|
|
243
|
+
kind: "file",
|
|
244
|
+
fingerprint: variant.fingerprint,
|
|
245
|
+
mode: variant.mode!,
|
|
246
|
+
bytes: new Uint8Array(content),
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
const semanticFingerprint = leaf.kind === "absent"
|
|
250
|
+
? fingerprintAbsent(entry.path)
|
|
251
|
+
: leaf.kind === "file"
|
|
252
|
+
? fingerprintBytes(entry.path, leaf.bytes, leaf.mode)
|
|
253
|
+
: fingerprintSymlink(entry.path, leaf.linkText);
|
|
254
|
+
if (semanticFingerprint !== leaf.fingerprint) {
|
|
255
|
+
throw new Error(`durable pack semantic fingerprint 不匹配:${entry.path}`);
|
|
256
|
+
}
|
|
257
|
+
variants.set(variant.fingerprint, leaf);
|
|
258
|
+
}
|
|
259
|
+
result.set(entry.path, {
|
|
260
|
+
sourceArtifact: entry.sourceArtifact,
|
|
261
|
+
targetArtifact: entry.targetArtifact,
|
|
262
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
263
|
+
targetFingerprint: entry.targetFingerprint,
|
|
264
|
+
variants,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return new DurablePack(header.opId, header.planDigest, durablePackPath(journal), checksum(bytes), result);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function hasDurablePack(journal: MutationJournal): Promise<boolean> {
|
|
271
|
+
try {
|
|
272
|
+
const metadata = await lstat(durablePackPath(journal));
|
|
273
|
+
return metadata.isFile() && !metadata.isSymbolicLink();
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
276
|
+
throw error;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export async function publishCachedDurablePack(
|
|
281
|
+
cachedPath: string,
|
|
282
|
+
journal: MutationJournal,
|
|
283
|
+
expectedPlanDigest: string,
|
|
284
|
+
expectedPackChecksum: string,
|
|
285
|
+
): Promise<DurablePack> {
|
|
286
|
+
const target = durablePackPath(journal);
|
|
287
|
+
if (checksum(await readFile(cachedPath)) !== expectedPackChecksum) {
|
|
288
|
+
throw new Error("durable cache pack checksum 不匹配");
|
|
289
|
+
}
|
|
290
|
+
await rm(target, { force: true });
|
|
291
|
+
try {
|
|
292
|
+
await link(cachedPath, target);
|
|
293
|
+
await fsyncDirectory(dirname(target));
|
|
294
|
+
} catch (error) {
|
|
295
|
+
if (!hasErrorCode(error, "EXDEV")) throw error;
|
|
296
|
+
await copyFile(cachedPath, target);
|
|
297
|
+
await fsyncFile(target);
|
|
298
|
+
await fsyncDirectory(dirname(target));
|
|
299
|
+
}
|
|
300
|
+
const pack = await loadDurablePack(journal, expectedPlanDigest, true);
|
|
301
|
+
if (pack.packChecksum !== expectedPackChecksum) throw new Error("durable published pack checksum 不匹配");
|
|
302
|
+
return pack;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function finalizeDurablePack(
|
|
306
|
+
journal: MutationJournal,
|
|
307
|
+
workspaceRoot: string,
|
|
308
|
+
options: { readonly allowCleanedOwnershipWithoutMarker?: boolean } = {},
|
|
309
|
+
): Promise<void> {
|
|
310
|
+
const pack = await loadDurablePack(journal, undefined, true);
|
|
311
|
+
const mutationStates = options.allowCleanedOwnershipWithoutMarker === false
|
|
312
|
+
? undefined
|
|
313
|
+
: new Map((await journal.load()).map((record) => [record.path, record.state]));
|
|
314
|
+
const canonicalRoot = resolve(workspaceRoot);
|
|
315
|
+
const directories = new Set<string>();
|
|
316
|
+
await mapConcurrent(pack.paths(), FINALIZE_CONCURRENCY, async (path) => {
|
|
317
|
+
const targetFingerprint = pack.targetFingerprint(path);
|
|
318
|
+
if (targetFingerprint === undefined || targetFingerprint === null) return;
|
|
319
|
+
const leaf = pack.leaf(path, targetFingerprint);
|
|
320
|
+
if (leaf === undefined || leaf.kind === "absent") throw new Error(`durable pack target leaf 缺失:${path}`);
|
|
321
|
+
relativeSafePath(canonicalRoot, path);
|
|
322
|
+
await assertNoSymlinkEscape(canonicalRoot, path);
|
|
323
|
+
const absolute = join(canonicalRoot, ...path.split("/"));
|
|
324
|
+
directories.add(dirname(absolute));
|
|
325
|
+
if (leaf.kind === "file") {
|
|
326
|
+
const artifacts = pack.artifacts(path);
|
|
327
|
+
if (artifacts?.target === null || artifacts?.target === undefined) {
|
|
328
|
+
throw new Error(`durable finalization target artifact 缺失:${path}`);
|
|
329
|
+
}
|
|
330
|
+
const targetArtifact = join(canonicalRoot, ...artifacts.target.split("/"));
|
|
331
|
+
const targetArtifactExists = await lstat(targetArtifact).then(() => true, (error) => {
|
|
332
|
+
if (hasErrorCode(error, "ENOENT")) return false;
|
|
333
|
+
throw error;
|
|
334
|
+
});
|
|
335
|
+
if (targetArtifactExists) {
|
|
336
|
+
await assertSameFileIdentity(absolute, targetArtifact, path);
|
|
337
|
+
if (await fingerprintFile(absolute, path) !== leaf.fingerprint) {
|
|
338
|
+
throw new Error(`durable finalization 文件 fingerprint 冲突:${path}`);
|
|
339
|
+
}
|
|
340
|
+
await fsyncFile(targetArtifact);
|
|
341
|
+
await assertSameFileIdentity(absolute, targetArtifact, path);
|
|
342
|
+
} else {
|
|
343
|
+
if (mutationStates?.get(path) !== "CLEANED") {
|
|
344
|
+
throw new Error(`durable finalization target ownership 缺失:${path}`);
|
|
345
|
+
}
|
|
346
|
+
if (await fingerprintFile(absolute, path) !== leaf.fingerprint) {
|
|
347
|
+
throw new Error(`durable finalization 文件 fingerprint 冲突:${path}`);
|
|
348
|
+
}
|
|
349
|
+
await fsyncFile(absolute);
|
|
350
|
+
}
|
|
351
|
+
if (await fingerprintFile(absolute, path) !== leaf.fingerprint) {
|
|
352
|
+
throw new Error(`durable finalization 后文件 fingerprint 冲突:${path}`);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (await fingerprintSymlink(absolute, path) !== leaf.fingerprint) {
|
|
357
|
+
throw new Error(`durable finalization symlink fingerprint 冲突:${path}`);
|
|
358
|
+
}
|
|
359
|
+
if (await fingerprintSymlink(absolute, path) !== leaf.fingerprint) {
|
|
360
|
+
throw new Error(`durable finalization 后 symlink fingerprint 冲突:${path}`);
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
for (const directory of directories) await fsyncDirectory(directory);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export async function removeDurablePack(journal: MutationJournal): Promise<void> {
|
|
367
|
+
await rm(durablePackPath(journal), { force: true });
|
|
368
|
+
await fsyncDirectory(dirname(journal.storagePath));
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
async function assertSameFileIdentity(original: string, artifact: string, logicalPath: string): Promise<void> {
|
|
372
|
+
const [originalMetadata, artifactMetadata] = await Promise.all([lstat(original), lstat(artifact)]);
|
|
373
|
+
if (
|
|
374
|
+
!originalMetadata.isFile() ||
|
|
375
|
+
!artifactMetadata.isFile() ||
|
|
376
|
+
originalMetadata.dev !== artifactMetadata.dev ||
|
|
377
|
+
originalMetadata.ino !== artifactMetadata.ino
|
|
378
|
+
) {
|
|
379
|
+
throw new Error(`durable finalization target ownership 冲突:${logicalPath}`);
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function canonicalEntries(
|
|
384
|
+
entries: readonly DurablePackEntryInput[],
|
|
385
|
+
validateSemanticFingerprints = true,
|
|
386
|
+
): DurablePackEntryInput[] {
|
|
387
|
+
const sorted = [...entries].sort((left, right) => left.path.localeCompare(right.path));
|
|
388
|
+
let previous: string | undefined;
|
|
389
|
+
return sorted.map((entry) => {
|
|
390
|
+
if (entry.path.length === 0 || entry.path.startsWith("/") || entry.path.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
391
|
+
throw new Error("durable pack path 无效");
|
|
392
|
+
}
|
|
393
|
+
if (entry.path === previous) throw new Error("durable pack path 重复");
|
|
394
|
+
previous = entry.path;
|
|
395
|
+
assertArtifact(entry.path, entry.sourceArtifact, "source");
|
|
396
|
+
if (entry.targetArtifact !== null) assertArtifact(entry.path, entry.targetArtifact, "target");
|
|
397
|
+
assertDigest(entry.sourceFingerprint, "sourceFingerprint");
|
|
398
|
+
if (entry.targetFingerprint !== null) assertDigest(entry.targetFingerprint, "targetFingerprint");
|
|
399
|
+
const variants = [...entry.variants].sort((left, right) => left.fingerprint.localeCompare(right.fingerprint));
|
|
400
|
+
const fingerprints = new Set<string>();
|
|
401
|
+
for (const variant of variants) {
|
|
402
|
+
assertDigest(variant.fingerprint, "fingerprint");
|
|
403
|
+
if (fingerprints.has(variant.fingerprint)) throw new Error("durable pack variant fingerprint 重复");
|
|
404
|
+
fingerprints.add(variant.fingerprint);
|
|
405
|
+
if (variant.kind === "file" && (variant.mode !== 0o644 && variant.mode !== 0o755)) {
|
|
406
|
+
throw new Error("durable pack file mode 无效");
|
|
407
|
+
}
|
|
408
|
+
const semanticFingerprint = variant.kind === "absent"
|
|
409
|
+
? fingerprintAbsent(entry.path)
|
|
410
|
+
: variant.kind === "file"
|
|
411
|
+
? fingerprintBytes(entry.path, variant.bytes, variant.mode)
|
|
412
|
+
: fingerprintSymlink(entry.path, variant.linkText);
|
|
413
|
+
if (validateSemanticFingerprints && semanticFingerprint !== variant.fingerprint) {
|
|
414
|
+
throw new Error(`durable pack semantic fingerprint 不匹配:${entry.path}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (!fingerprints.has(entry.sourceFingerprint)) throw new Error("durable pack 缺少 source variant");
|
|
418
|
+
if (entry.targetFingerprint !== null && !fingerprints.has(entry.targetFingerprint)) {
|
|
419
|
+
throw new Error("durable pack 缺少 target variant");
|
|
420
|
+
}
|
|
421
|
+
return { ...entry, variants };
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function assertPackHeader(value: unknown): PackHeader {
|
|
426
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("durable pack header 无效");
|
|
427
|
+
const record = value as Record<string, unknown>;
|
|
428
|
+
if (record.schemaVersion !== 1 || typeof record.opId !== "string" || typeof record.planDigest !== "string" || typeof record.checksum !== "string" || !Array.isArray(record.entries)) {
|
|
429
|
+
throw new Error("durable pack header 字段无效");
|
|
430
|
+
}
|
|
431
|
+
assertDigest(record.planDigest, "planDigest");
|
|
432
|
+
assertDigest(record.checksum, "checksum");
|
|
433
|
+
const entries: PackedEntryHeader[] = record.entries.map((entry): PackedEntryHeader => {
|
|
434
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error("durable pack entry 无效");
|
|
435
|
+
const item = entry as Record<string, unknown>;
|
|
436
|
+
if (
|
|
437
|
+
typeof item.path !== "string" ||
|
|
438
|
+
typeof item.sourceArtifact !== "string" ||
|
|
439
|
+
(item.targetArtifact !== null && typeof item.targetArtifact !== "string") ||
|
|
440
|
+
typeof item.sourceFingerprint !== "string" ||
|
|
441
|
+
(item.targetFingerprint !== null && typeof item.targetFingerprint !== "string") ||
|
|
442
|
+
!Array.isArray(item.variants)
|
|
443
|
+
) {
|
|
444
|
+
throw new Error("durable pack entry 字段无效");
|
|
445
|
+
}
|
|
446
|
+
assertDigest(item.sourceFingerprint, "sourceFingerprint");
|
|
447
|
+
if (item.targetFingerprint !== null) assertDigest(item.targetFingerprint, "targetFingerprint");
|
|
448
|
+
const variants = item.variants.map((variant): PackedVariantHeader => {
|
|
449
|
+
if (typeof variant !== "object" || variant === null || Array.isArray(variant)) throw new Error("durable pack variant 无效");
|
|
450
|
+
const candidate = variant as Record<string, unknown>;
|
|
451
|
+
if ((candidate.kind !== "absent" && candidate.kind !== "file" && candidate.kind !== "symlink") || typeof candidate.fingerprint !== "string") {
|
|
452
|
+
throw new Error("durable pack variant 字段无效");
|
|
453
|
+
}
|
|
454
|
+
assertDigest(candidate.fingerprint, "fingerprint");
|
|
455
|
+
if (candidate.kind === "file") {
|
|
456
|
+
if ((candidate.mode !== 0o644 && candidate.mode !== 0o755) || !Number.isSafeInteger(candidate.offset) || !Number.isSafeInteger(candidate.length) || typeof candidate.dataChecksum !== "string") {
|
|
457
|
+
throw new Error("durable pack file variant 无效");
|
|
458
|
+
}
|
|
459
|
+
assertDigest(candidate.dataChecksum, "dataChecksum");
|
|
460
|
+
return { kind: "file", fingerprint: candidate.fingerprint, mode: candidate.mode, offset: candidate.offset as number, length: candidate.length as number, dataChecksum: candidate.dataChecksum };
|
|
461
|
+
}
|
|
462
|
+
if (candidate.kind === "symlink") {
|
|
463
|
+
if (typeof candidate.linkText !== "string") throw new Error("durable pack symlink variant 无效");
|
|
464
|
+
return { kind: "symlink", fingerprint: candidate.fingerprint, linkText: candidate.linkText };
|
|
465
|
+
}
|
|
466
|
+
return { kind: "absent", fingerprint: candidate.fingerprint };
|
|
467
|
+
});
|
|
468
|
+
return {
|
|
469
|
+
path: item.path,
|
|
470
|
+
sourceArtifact: item.sourceArtifact,
|
|
471
|
+
targetArtifact: item.targetArtifact as string | null,
|
|
472
|
+
sourceFingerprint: item.sourceFingerprint,
|
|
473
|
+
targetFingerprint: item.targetFingerprint as string | null,
|
|
474
|
+
variants,
|
|
475
|
+
};
|
|
476
|
+
});
|
|
477
|
+
const payload: PackHeaderPayload = { schemaVersion: 1, opId: record.opId, planDigest: record.planDigest, entries };
|
|
478
|
+
if (checksum(canonicalJson(payload)) !== record.checksum) throw new Error("durable pack header checksum 不匹配");
|
|
479
|
+
canonicalEntries(entries.map((entry) => ({
|
|
480
|
+
path: entry.path,
|
|
481
|
+
sourceArtifact: entry.sourceArtifact,
|
|
482
|
+
targetArtifact: entry.targetArtifact,
|
|
483
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
484
|
+
targetFingerprint: entry.targetFingerprint,
|
|
485
|
+
variants: entry.variants.map((variant): DurableLeafInput => {
|
|
486
|
+
if (variant.kind === "file") return { kind: "file", fingerprint: variant.fingerprint, mode: variant.mode!, bytes: new Uint8Array() };
|
|
487
|
+
if (variant.kind === "symlink") return { kind: "symlink", fingerprint: variant.fingerprint, linkText: variant.linkText! };
|
|
488
|
+
return { kind: "absent", fingerprint: variant.fingerprint };
|
|
489
|
+
}),
|
|
490
|
+
})), false);
|
|
491
|
+
return { ...payload, checksum: record.checksum };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function durablePackFromInput(
|
|
495
|
+
opId: string,
|
|
496
|
+
planDigest: string,
|
|
497
|
+
storagePath: string,
|
|
498
|
+
packChecksum: string,
|
|
499
|
+
entries: readonly DurablePackEntryInput[],
|
|
500
|
+
): DurablePack {
|
|
501
|
+
return new DurablePack(
|
|
502
|
+
opId,
|
|
503
|
+
planDigest,
|
|
504
|
+
storagePath,
|
|
505
|
+
packChecksum,
|
|
506
|
+
new Map(entries.map((entry) => [
|
|
507
|
+
entry.path,
|
|
508
|
+
{
|
|
509
|
+
sourceArtifact: entry.sourceArtifact,
|
|
510
|
+
targetArtifact: entry.targetArtifact,
|
|
511
|
+
sourceFingerprint: entry.sourceFingerprint,
|
|
512
|
+
targetFingerprint: entry.targetFingerprint,
|
|
513
|
+
variants: new Map(entry.variants.map((variant) => [
|
|
514
|
+
variant.fingerprint,
|
|
515
|
+
variant.kind === "file"
|
|
516
|
+
? { ...variant, bytes: new Uint8Array(variant.bytes) }
|
|
517
|
+
: variant,
|
|
518
|
+
])),
|
|
519
|
+
},
|
|
520
|
+
])),
|
|
521
|
+
);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function writeAll(handle: FileHandle, bytes: Uint8Array): Promise<void> {
|
|
525
|
+
let offset = 0;
|
|
526
|
+
while (offset < bytes.byteLength) {
|
|
527
|
+
const { bytesWritten } = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
528
|
+
if (bytesWritten <= 0) throw new Error("durable pack 写入未推进");
|
|
529
|
+
offset += bytesWritten;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function assertArtifact(path: string, artifact: string, role: "source" | "target"): void {
|
|
534
|
+
const pathParts = path.split("/");
|
|
535
|
+
const artifactParts = artifact.split("/");
|
|
536
|
+
if (
|
|
537
|
+
pathParts.slice(0, -1).join("/") !== artifactParts.slice(0, -1).join("/") ||
|
|
538
|
+
!new RegExp(`^\\.pi-undo-q2-[0-9a-f]{32}-${role}$`).test(artifactParts.at(-1)!)
|
|
539
|
+
) {
|
|
540
|
+
throw new Error(`durable pack ${role} artifact 无效`);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function assertDigest(value: string, name: string): void {
|
|
545
|
+
if (!/^[0-9a-f]{64}$/.test(value)) throw new Error(`durable pack ${name} 无效`);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function mapConcurrent<T>(
|
|
549
|
+
values: readonly T[],
|
|
550
|
+
concurrency: number,
|
|
551
|
+
worker: (value: T) => Promise<void>,
|
|
552
|
+
): Promise<void> {
|
|
553
|
+
let next = 0;
|
|
554
|
+
let firstFailure: unknown;
|
|
555
|
+
const runners = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
|
|
556
|
+
while (firstFailure === undefined) {
|
|
557
|
+
const index = next;
|
|
558
|
+
next += 1;
|
|
559
|
+
if (index >= values.length) return;
|
|
560
|
+
try {
|
|
561
|
+
await worker(values[index]!);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
firstFailure = error;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
await Promise.all(runners);
|
|
568
|
+
if (firstFailure !== undefined) throw firstFailure;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
572
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
573
|
+
}
|
package/src/journal.ts
CHANGED
|
@@ -31,6 +31,10 @@ export interface JournalPhaseOptions {
|
|
|
31
31
|
readonly observedLogicalLeaf?: string | null;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
export interface JournalPhaseTransition extends JournalPhaseOptions {
|
|
35
|
+
readonly phase: JournalPhase;
|
|
36
|
+
}
|
|
37
|
+
|
|
34
38
|
export type CursorMarkerInspection =
|
|
35
39
|
| { readonly kind: "absent" }
|
|
36
40
|
| { readonly kind: "match"; readonly needsTrailingNewline: boolean }
|
|
@@ -75,16 +79,31 @@ export class JournalStore {
|
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
async setPhase(opId: string, phase: JournalPhase, options: JournalPhaseOptions = {}): Promise<void> {
|
|
82
|
+
await this.setPhases(opId, [{ phase, ...options }]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async setPhases(opId: string, transitions: readonly JournalPhaseTransition[]): Promise<void> {
|
|
86
|
+
if (transitions.length === 0) throw new Error("journal phase group 不能为空");
|
|
78
87
|
const pending = await this.load(opId);
|
|
79
|
-
|
|
80
|
-
|
|
88
|
+
let phase = pending.state.phase;
|
|
89
|
+
let observedLogicalLeaf = pending.state.observedLogicalLeaf;
|
|
90
|
+
for (const transition of transitions) {
|
|
91
|
+
if (!canTransition(phase, transition.phase)) {
|
|
92
|
+
throw new Error(`journal phase 不能回退或跳跃:${phase} -> ${transition.phase}`);
|
|
93
|
+
}
|
|
94
|
+
phase = transition.phase;
|
|
95
|
+
if (transition.observedLogicalLeaf !== undefined) {
|
|
96
|
+
observedLogicalLeaf = transition.observedLogicalLeaf;
|
|
97
|
+
}
|
|
81
98
|
}
|
|
82
|
-
const observedLogicalLeaf = options.observedLogicalLeaf === undefined
|
|
83
|
-
? pending.state.observedLogicalLeaf
|
|
84
|
-
: options.observedLogicalLeaf;
|
|
85
99
|
await writeJsonAtomic(
|
|
86
100
|
join(this.operationDirectory(opId), "state.json"),
|
|
87
|
-
makeState(
|
|
101
|
+
makeState(
|
|
102
|
+
pending.descriptor,
|
|
103
|
+
phase,
|
|
104
|
+
pending.state.revision + transitions.length,
|
|
105
|
+
observedLogicalLeaf,
|
|
106
|
+
),
|
|
88
107
|
);
|
|
89
108
|
}
|
|
90
109
|
|
|
@@ -114,6 +133,14 @@ export class JournalStore {
|
|
|
114
133
|
return result;
|
|
115
134
|
}
|
|
116
135
|
|
|
136
|
+
async assertLogicalCommitReady(opId: string, allowPendingMutations = false): Promise<void> {
|
|
137
|
+
if (!allowPendingMutations) await this.mutationJournal(opId).assertCleaned();
|
|
138
|
+
const pending = await this.load(opId);
|
|
139
|
+
if (pending.state.phase !== "CURSOR_COMMITTED") {
|
|
140
|
+
throw new Error("durable transaction 只能从 CURSOR_COMMITTED 开始 finalization");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
117
144
|
async markCommitted(opId: string): Promise<void> {
|
|
118
145
|
await this.mutationJournal(opId).assertCleaned();
|
|
119
146
|
await this.setPhase(opId, "COMMITTED");
|