@davideasden/pi-undo 0.2.2 → 0.2.4
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/mutation-journal.ts +6 -0
- package/src/native-restore.ts +146 -0
- package/src/packed-recovery.ts +324 -0
- package/src/path-safety.ts +31 -0
- package/src/pi-runtime.ts +96 -5
- package/src/quarantine.ts +58 -32
- package/src/restore-engine.ts +448 -36
- package/src/snapshot-store.ts +110 -41
- package/src/workspace-lock.ts +5 -1
package/src/restore-engine.ts
CHANGED
|
@@ -2,10 +2,20 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
3
|
import { lstat, mkdir, mkdtemp, readFile, readlink, realpath, rm, rmdir } from "node:fs/promises";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
|
|
7
|
+
import {
|
|
8
|
+
createDurablePack,
|
|
9
|
+
publishCachedDurablePack,
|
|
10
|
+
removeDurablePack,
|
|
11
|
+
type DurableLeafInput,
|
|
12
|
+
type DurablePack,
|
|
13
|
+
type DurablePackEntryInput,
|
|
14
|
+
} from "./durable-pack.ts";
|
|
7
15
|
import { assertManifest, assertOperationId, canonicalJson, checksum } from "./encoding.ts";
|
|
8
16
|
import { MutationJournal } from "./mutation-journal.ts";
|
|
17
|
+
import { createNativeFileBatch } from "./native-restore.ts";
|
|
18
|
+
import { recoverPackedMutations } from "./packed-recovery.ts";
|
|
9
19
|
import type { ManifestId, RestorePath, SnapshotManifest, SnapshotRoot } from "./model.ts";
|
|
10
20
|
import {
|
|
11
21
|
assertNoSymlinkEscape,
|
|
@@ -22,11 +32,11 @@ import {
|
|
|
22
32
|
type DeleteLeafRequest,
|
|
23
33
|
type ReplaceFileRequest,
|
|
24
34
|
} from "./quarantine.ts";
|
|
25
|
-
import {
|
|
35
|
+
import { SnapshotStore, SnapshotStoreError } from "./snapshot-store.ts";
|
|
26
36
|
|
|
27
37
|
const PREPARED_PLAN_CACHE_LIMIT = 16;
|
|
28
|
-
const RESTORE_FILE_BATCH_MAX_ENTRIES =
|
|
29
|
-
const RESTORE_FILE_BATCH_MAX_BYTES =
|
|
38
|
+
const RESTORE_FILE_BATCH_MAX_ENTRIES = 1_024;
|
|
39
|
+
const RESTORE_FILE_BATCH_MAX_BYTES = 64 * 1024 * 1024;
|
|
30
40
|
const RESTORE_FILE_PREPARE_CONCURRENCY = 32;
|
|
31
41
|
|
|
32
42
|
export interface RestorePlan {
|
|
@@ -54,6 +64,8 @@ export interface RestoreEngine {
|
|
|
54
64
|
export interface RestoreApplyOptions {
|
|
55
65
|
readonly opId: string;
|
|
56
66
|
readonly mutationJournal: MutationJournal;
|
|
67
|
+
readonly forceTargetArtifactSync?: boolean;
|
|
68
|
+
readonly deferDurability?: boolean;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
export interface RestoreEngineOptions {
|
|
@@ -102,6 +114,17 @@ export class RestoreEngine {
|
|
|
102
114
|
private readonly discovery: RootDiscovery;
|
|
103
115
|
private readonly beforeMutation: RestoreEngineOptions["beforeMutation"];
|
|
104
116
|
private readonly preparedPlans = new Map<string, PreparedRestorePlan>();
|
|
117
|
+
private readonly durablePackCache = new Map<string, {
|
|
118
|
+
readonly currentManifestId: ManifestId;
|
|
119
|
+
readonly targetManifestId: ManifestId;
|
|
120
|
+
readonly path: string;
|
|
121
|
+
readonly packChecksum: string;
|
|
122
|
+
readonly pinReason: string;
|
|
123
|
+
}>();
|
|
124
|
+
private readonly durablePackByPair = new Map<string, {
|
|
125
|
+
readonly planDigest: string;
|
|
126
|
+
readonly pack: DurablePack;
|
|
127
|
+
}>();
|
|
105
128
|
|
|
106
129
|
constructor(options: RestoreEngineOptions) {
|
|
107
130
|
this.requestedWorkspaceRoot = resolve(options.workspaceRoot);
|
|
@@ -197,6 +220,118 @@ export class RestoreEngine {
|
|
|
197
220
|
return plan;
|
|
198
221
|
}
|
|
199
222
|
|
|
223
|
+
async canReuseDurableSource(
|
|
224
|
+
current: SnapshotManifest,
|
|
225
|
+
target: SnapshotManifest,
|
|
226
|
+
scopePaths: readonly string[],
|
|
227
|
+
): Promise<boolean> {
|
|
228
|
+
const cached = this.durablePackByPair.get(durablePairKey(current.manifestId, target.manifestId, scopePaths));
|
|
229
|
+
if (cached === undefined) return false;
|
|
230
|
+
const cacheJournal = new MutationJournal(
|
|
231
|
+
join(dirname(cached.pack.storagePath), "mutations.jsonl"),
|
|
232
|
+
`cache-${cached.planDigest}`,
|
|
233
|
+
);
|
|
234
|
+
try {
|
|
235
|
+
if (await this.assertWorkspaceRootIdentity() !== current.workspaceIdentity) return false;
|
|
236
|
+
const topology = await this.discovery.discover(this.workspaceRoot);
|
|
237
|
+
this.assertCurrentTopology(current, target, topology);
|
|
238
|
+
const native = await createNativeFileBatch({
|
|
239
|
+
workspaceRoot: this.workspaceRoot,
|
|
240
|
+
planDigest: cached.planDigest,
|
|
241
|
+
journal: cacheJournal,
|
|
242
|
+
});
|
|
243
|
+
return native !== undefined && await native.verifySource(cached.pack);
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async prepareDurableRestore(
|
|
250
|
+
current: SnapshotManifest,
|
|
251
|
+
target: SnapshotManifest,
|
|
252
|
+
scopePaths: readonly string[],
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
const plan = await this.plan(current, target, scopePaths);
|
|
255
|
+
const prepared = this.takePreparedPlan(plan);
|
|
256
|
+
if (prepared === undefined || !this.canUseNativeFilePlan(plan, prepared.targetPaths)) return;
|
|
257
|
+
const cacheRoot = await this.store.durableCacheDirectory();
|
|
258
|
+
const cacheOpId = `cache-${plan.planDigest}`;
|
|
259
|
+
const cacheJournal = new MutationJournal(
|
|
260
|
+
join(cacheRoot, plan.planDigest, "mutations.jsonl"),
|
|
261
|
+
cacheOpId,
|
|
262
|
+
);
|
|
263
|
+
await mkdir(dirname(cacheJournal.storagePath), { recursive: true });
|
|
264
|
+
const pack = await createDurablePack(cacheJournal, {
|
|
265
|
+
opId: cacheOpId,
|
|
266
|
+
planDigest: plan.planDigest,
|
|
267
|
+
entries: await this.durablePackEntries(
|
|
268
|
+
current,
|
|
269
|
+
target,
|
|
270
|
+
prepared.currentPaths,
|
|
271
|
+
prepared.targetPaths,
|
|
272
|
+
plan,
|
|
273
|
+
cacheOpId,
|
|
274
|
+
),
|
|
275
|
+
});
|
|
276
|
+
const pinReason = `durable-cache:${plan.planDigest}`;
|
|
277
|
+
const pinned: ManifestId[] = [];
|
|
278
|
+
try {
|
|
279
|
+
for (const manifestId of new Set([current.manifestId, target.manifestId])) {
|
|
280
|
+
await this.store.pin(manifestId, pinReason);
|
|
281
|
+
pinned.push(manifestId);
|
|
282
|
+
}
|
|
283
|
+
} catch (error) {
|
|
284
|
+
await Promise.all(pinned.map((manifestId) => this.store.unpin(manifestId, pinReason).catch(() => {})));
|
|
285
|
+
await rm(dirname(pack.storagePath), { recursive: true, force: true }).catch(() => {});
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
await this.rememberDurablePack(
|
|
289
|
+
plan,
|
|
290
|
+
current,
|
|
291
|
+
target,
|
|
292
|
+
scopePaths,
|
|
293
|
+
pack,
|
|
294
|
+
pinReason,
|
|
295
|
+
);
|
|
296
|
+
this.rememberPreparedPlan(plan, prepared.currentPaths, prepared.targetPaths);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async rememberDurablePack(
|
|
300
|
+
plan: RestorePlan,
|
|
301
|
+
current: SnapshotManifest,
|
|
302
|
+
target: SnapshotManifest,
|
|
303
|
+
scopePaths: readonly string[],
|
|
304
|
+
pack: DurablePack,
|
|
305
|
+
pinReason: string,
|
|
306
|
+
): Promise<void> {
|
|
307
|
+
this.durablePackCache.set(plan.planDigest, {
|
|
308
|
+
currentManifestId: current.manifestId,
|
|
309
|
+
targetManifestId: target.manifestId,
|
|
310
|
+
path: pack.storagePath,
|
|
311
|
+
packChecksum: pack.packChecksum,
|
|
312
|
+
pinReason,
|
|
313
|
+
});
|
|
314
|
+
this.durablePackByPair.set(durablePairKey(current.manifestId, target.manifestId, scopePaths), {
|
|
315
|
+
planDigest: plan.planDigest,
|
|
316
|
+
pack,
|
|
317
|
+
});
|
|
318
|
+
while (this.durablePackCache.size > PREPARED_PLAN_CACHE_LIMIT) {
|
|
319
|
+
const oldest = this.durablePackCache.entries().next().value as
|
|
320
|
+
| readonly [string, { readonly currentManifestId: ManifestId; readonly targetManifestId: ManifestId; readonly path: string; readonly packChecksum: string; readonly pinReason: string }]
|
|
321
|
+
| undefined;
|
|
322
|
+
if (oldest === undefined) break;
|
|
323
|
+
const [digest, entry] = oldest;
|
|
324
|
+
this.durablePackCache.delete(digest);
|
|
325
|
+
for (const [key, pair] of this.durablePackByPair) {
|
|
326
|
+
if (pair.planDigest === digest) this.durablePackByPair.delete(key);
|
|
327
|
+
}
|
|
328
|
+
await Promise.all([...new Set([entry.currentManifestId, entry.targetManifestId])].map(
|
|
329
|
+
(manifestId) => this.store.unpin(manifestId, entry.pinReason).catch(() => {}),
|
|
330
|
+
));
|
|
331
|
+
await rm(dirname(entry.path), { recursive: true, force: true }).catch(() => {});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
200
335
|
private rememberPreparedPlan(
|
|
201
336
|
plan: RestorePlan,
|
|
202
337
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
@@ -255,14 +390,18 @@ export class RestoreEngine {
|
|
|
255
390
|
if (!hasValidPlanDigest(plan)) {
|
|
256
391
|
throw new Error("restore plan digest 与语义字段不匹配");
|
|
257
392
|
}
|
|
393
|
+
const pinsDeferred = effectiveOptions.deferDurability === true &&
|
|
394
|
+
this.durablePackCache.has(plan.planDigest);
|
|
258
395
|
const recoveryReason = `restore:${plan.planDigest}`;
|
|
259
396
|
const attemptReason = `${recoveryReason}:attempt:${randomUUID()}`;
|
|
260
397
|
const pinned = [...new Set([plan.currentManifestId, target.manifestId])];
|
|
261
398
|
const acquired: ManifestId[] = [];
|
|
262
399
|
try {
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
400
|
+
if (!pinsDeferred) {
|
|
401
|
+
for (const manifestId of pinned) {
|
|
402
|
+
await this.store.pin(manifestId, attemptReason);
|
|
403
|
+
acquired.push(manifestId);
|
|
404
|
+
}
|
|
266
405
|
}
|
|
267
406
|
} catch (error) {
|
|
268
407
|
await Promise.all(acquired.map(
|
|
@@ -272,10 +411,16 @@ export class RestoreEngine {
|
|
|
272
411
|
}
|
|
273
412
|
|
|
274
413
|
try {
|
|
275
|
-
const result = await this.applyPinned(
|
|
414
|
+
const result = await this.applyPinned(
|
|
415
|
+
plan,
|
|
416
|
+
target,
|
|
417
|
+
effectiveOptions,
|
|
418
|
+
compatibilityMode,
|
|
419
|
+
pinsDeferred,
|
|
420
|
+
);
|
|
276
421
|
if (result.code === "partial_restore" || result.code === "recovery_required") {
|
|
277
|
-
let recoveryPinned =
|
|
278
|
-
for (const manifestId of pinned) {
|
|
422
|
+
let recoveryPinned = !pinsDeferred;
|
|
423
|
+
for (const manifestId of recoveryPinned ? pinned : []) {
|
|
279
424
|
try {
|
|
280
425
|
await this.store.pin(manifestId, recoveryReason);
|
|
281
426
|
} catch {
|
|
@@ -284,24 +429,24 @@ export class RestoreEngine {
|
|
|
284
429
|
}
|
|
285
430
|
}
|
|
286
431
|
if (recoveryPinned) {
|
|
287
|
-
await Promise.all(
|
|
432
|
+
await Promise.all(acquired.map(
|
|
288
433
|
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
289
434
|
));
|
|
290
435
|
}
|
|
291
436
|
return result;
|
|
292
437
|
}
|
|
293
438
|
|
|
294
|
-
await Promise.all(
|
|
439
|
+
await Promise.all(acquired.map(
|
|
295
440
|
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
296
441
|
));
|
|
297
|
-
if (result.code === "ok" || result.postFingerprint !== undefined) {
|
|
442
|
+
if (!pinsDeferred && (result.code === "ok" || result.postFingerprint !== undefined)) {
|
|
298
443
|
await Promise.all(pinned.map(
|
|
299
444
|
(manifestId) => this.store.unpin(manifestId, recoveryReason).catch(() => {}),
|
|
300
445
|
));
|
|
301
446
|
}
|
|
302
447
|
return result;
|
|
303
448
|
} catch (error) {
|
|
304
|
-
await Promise.all(
|
|
449
|
+
await Promise.all(acquired.map(
|
|
305
450
|
(manifestId) => this.store.unpin(manifestId, attemptReason).catch(() => {}),
|
|
306
451
|
));
|
|
307
452
|
throw error;
|
|
@@ -313,6 +458,7 @@ export class RestoreEngine {
|
|
|
313
458
|
target: SnapshotManifest,
|
|
314
459
|
options: RestoreApplyOptions,
|
|
315
460
|
compatibilityMode: boolean,
|
|
461
|
+
pinsDeferred: boolean,
|
|
316
462
|
): Promise<RestoreResult> {
|
|
317
463
|
const [current, storedTarget] = await Promise.all([
|
|
318
464
|
this.store.loadManifest(plan.currentManifestId),
|
|
@@ -327,10 +473,14 @@ export class RestoreEngine {
|
|
|
327
473
|
plan.scopePaths === undefined ? undefined : this.canonicalScope(plan.scopePaths),
|
|
328
474
|
);
|
|
329
475
|
let expectedPlan: RestorePlan;
|
|
330
|
-
let prepared
|
|
476
|
+
let prepared = options.deferDurability === true ? this.takePreparedPlan(plan) : undefined;
|
|
331
477
|
try {
|
|
332
|
-
|
|
333
|
-
|
|
478
|
+
if (prepared === undefined) {
|
|
479
|
+
expectedPlan = await this.plan(current, target, plan.scopePaths);
|
|
480
|
+
prepared = this.takePreparedPlan(expectedPlan);
|
|
481
|
+
} else {
|
|
482
|
+
expectedPlan = prepared.plan;
|
|
483
|
+
}
|
|
334
484
|
} catch (error) {
|
|
335
485
|
if (error instanceof SnapshotStoreError && error.code === "object_missing") {
|
|
336
486
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
@@ -357,15 +507,15 @@ export class RestoreEngine {
|
|
|
357
507
|
this.readOwnedPaths(target, plan.scopePaths),
|
|
358
508
|
])
|
|
359
509
|
: [prepared.currentPaths, prepared.targetPaths];
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
) {
|
|
510
|
+
if (compatibilityMode) {
|
|
511
|
+
const compatibilityQuarantine = new QuarantineManager({
|
|
512
|
+
workspaceRoot: this.requestedWorkspaceRoot,
|
|
513
|
+
journal: options.mutationJournal,
|
|
514
|
+
});
|
|
515
|
+
if (!await this.restorePendingMutations(compatibilityQuarantine, options.mutationJournal)) {
|
|
516
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
517
|
+
}
|
|
518
|
+
} else if ((await options.mutationJournal.activeArtifacts()).size > 0) {
|
|
369
519
|
return { code: "recovery_required", verifiedPaths: 0, totalPaths: currentPaths.size };
|
|
370
520
|
}
|
|
371
521
|
try {
|
|
@@ -373,6 +523,71 @@ export class RestoreEngine {
|
|
|
373
523
|
} catch {
|
|
374
524
|
return { code: "restore_failed_safe", verifiedPaths: 0, totalPaths: 0 };
|
|
375
525
|
}
|
|
526
|
+
let durablePack: DurablePack | undefined;
|
|
527
|
+
if (
|
|
528
|
+
!compatibilityMode &&
|
|
529
|
+
options.deferDurability === true &&
|
|
530
|
+
options.forceTargetArtifactSync !== true &&
|
|
531
|
+
this.canUseNativeFilePlan(plan, targetPaths)
|
|
532
|
+
) {
|
|
533
|
+
try {
|
|
534
|
+
const cached = this.durablePackCache.get(plan.planDigest);
|
|
535
|
+
if (
|
|
536
|
+
cached !== undefined &&
|
|
537
|
+
cached.currentManifestId === current.manifestId &&
|
|
538
|
+
cached.targetManifestId === target.manifestId
|
|
539
|
+
) {
|
|
540
|
+
durablePack = await publishCachedDurablePack(
|
|
541
|
+
cached.path,
|
|
542
|
+
options.mutationJournal,
|
|
543
|
+
plan.planDigest,
|
|
544
|
+
cached.packChecksum,
|
|
545
|
+
);
|
|
546
|
+
} else {
|
|
547
|
+
durablePack = await createDurablePack(options.mutationJournal, {
|
|
548
|
+
opId: options.opId,
|
|
549
|
+
planDigest: plan.planDigest,
|
|
550
|
+
entries: await this.durablePackEntries(current, target, currentPaths, targetPaths, plan, options.opId),
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
} catch {
|
|
554
|
+
await removeDurablePack(options.mutationJournal).catch(() => {});
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
const nativeFileBatch = durablePack !== undefined && this.beforeMutation === undefined
|
|
558
|
+
? await createNativeFileBatch({
|
|
559
|
+
workspaceRoot: this.workspaceRoot,
|
|
560
|
+
planDigest: plan.planDigest,
|
|
561
|
+
journal: options.mutationJournal,
|
|
562
|
+
})
|
|
563
|
+
: undefined;
|
|
564
|
+
if (durablePack !== undefined && nativeFileBatch === undefined) {
|
|
565
|
+
await removeDurablePack(options.mutationJournal).catch(() => {});
|
|
566
|
+
durablePack = undefined;
|
|
567
|
+
}
|
|
568
|
+
if (pinsDeferred && durablePack === undefined) {
|
|
569
|
+
return this.applyWithOptions(
|
|
570
|
+
plan,
|
|
571
|
+
target,
|
|
572
|
+
{ ...options, deferDurability: false },
|
|
573
|
+
compatibilityMode,
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
const durablePackEnabled = durablePack !== undefined;
|
|
577
|
+
if (nativeFileBatch !== undefined && durablePack !== undefined && this.canUseNativeFilePlan(plan, targetPaths)) {
|
|
578
|
+
const result = await this.applyNativeFilePlan(
|
|
579
|
+
plan,
|
|
580
|
+
current,
|
|
581
|
+
target,
|
|
582
|
+
currentPaths,
|
|
583
|
+
targetPaths,
|
|
584
|
+
topologyBefore,
|
|
585
|
+
options,
|
|
586
|
+
durablePack,
|
|
587
|
+
nativeFileBatch.run,
|
|
588
|
+
);
|
|
589
|
+
return result;
|
|
590
|
+
}
|
|
376
591
|
const preflight = await this.verifyKnownState(current, target, currentPaths, targetPaths, plan.scopePaths);
|
|
377
592
|
if (!preflight.ok) {
|
|
378
593
|
return {
|
|
@@ -381,6 +596,11 @@ export class RestoreEngine {
|
|
|
381
596
|
totalPaths: preflight.totalPaths,
|
|
382
597
|
};
|
|
383
598
|
}
|
|
599
|
+
const quarantine = new QuarantineManager({
|
|
600
|
+
workspaceRoot: this.requestedWorkspaceRoot,
|
|
601
|
+
journal: options.mutationJournal,
|
|
602
|
+
syncTargetArtifacts: !durablePackEnabled,
|
|
603
|
+
});
|
|
384
604
|
|
|
385
605
|
const mutationContext: MutationContext = {
|
|
386
606
|
phase: "apply",
|
|
@@ -434,10 +654,184 @@ export class RestoreEngine {
|
|
|
434
654
|
targetPaths,
|
|
435
655
|
options,
|
|
436
656
|
plan.scopePaths,
|
|
657
|
+
!durablePackEnabled,
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
private canUseNativeFilePlan(
|
|
663
|
+
plan: RestorePlan,
|
|
664
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
665
|
+
): boolean {
|
|
666
|
+
return plan.deletePaths.length === 0 &&
|
|
667
|
+
plan.writePaths.length > 0 &&
|
|
668
|
+
plan.writePaths.every((path) => targetPaths.get(path)?.entry.kind === "file");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private async applyNativeFilePlan(
|
|
672
|
+
plan: RestorePlan,
|
|
673
|
+
current: SnapshotManifest,
|
|
674
|
+
target: SnapshotManifest,
|
|
675
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
676
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
677
|
+
topologyBefore: RootTopology,
|
|
678
|
+
options: RestoreApplyOptions,
|
|
679
|
+
pack: DurablePack,
|
|
680
|
+
nativeRun: (pack: DurablePack) => Promise<void>,
|
|
681
|
+
): Promise<RestoreResult> {
|
|
682
|
+
try {
|
|
683
|
+
await nativeRun(pack);
|
|
684
|
+
const topologyAfter = await this.discovery.discover(this.workspaceRoot);
|
|
685
|
+
assertUnchangedTopology(topologyBefore, topologyAfter);
|
|
686
|
+
await this.assertCompleteVisibleSubset(
|
|
687
|
+
topologyAfter,
|
|
688
|
+
[target],
|
|
689
|
+
options.mutationJournal,
|
|
690
|
+
pack.paths().flatMap((path) => {
|
|
691
|
+
const artifacts = pack.artifacts(path);
|
|
692
|
+
return artifacts === undefined
|
|
693
|
+
? []
|
|
694
|
+
: [artifacts.source, ...(artifacts.target === null ? [] : [artifacts.target])];
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
if ((await options.mutationJournal.load()).length !== 0) {
|
|
698
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.writePaths.length };
|
|
699
|
+
}
|
|
700
|
+
return { code: "ok", verifiedPaths: plan.writePaths.length, totalPaths: plan.writePaths.length };
|
|
701
|
+
} catch {
|
|
702
|
+
const packedRecovery = await recoverPackedMutations({
|
|
703
|
+
workspaceRoot: this.workspaceRoot,
|
|
704
|
+
journal: options.mutationJournal,
|
|
705
|
+
planDigest: plan.planDigest,
|
|
706
|
+
decision: "rollback",
|
|
707
|
+
});
|
|
708
|
+
if (packedRecovery.kind !== "clean") {
|
|
709
|
+
return { code: "recovery_required", verifiedPaths: 0, totalPaths: plan.writePaths.length };
|
|
710
|
+
}
|
|
711
|
+
return this.rollback(
|
|
712
|
+
current,
|
|
713
|
+
target,
|
|
714
|
+
topologyBefore,
|
|
715
|
+
currentPaths,
|
|
716
|
+
targetPaths,
|
|
717
|
+
options,
|
|
718
|
+
plan.scopePaths,
|
|
719
|
+
false,
|
|
437
720
|
);
|
|
438
721
|
}
|
|
439
722
|
}
|
|
440
723
|
|
|
724
|
+
private async durablePackEntries(
|
|
725
|
+
current: SnapshotManifest,
|
|
726
|
+
target: SnapshotManifest,
|
|
727
|
+
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
728
|
+
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
729
|
+
plan: RestorePlan,
|
|
730
|
+
opId: string,
|
|
731
|
+
): Promise<DurablePackEntryInput[]> {
|
|
732
|
+
const paths = [...new Set([...plan.deletePaths, ...plan.writePaths])].sort(comparePaths);
|
|
733
|
+
const useValidatedBatch = SnapshotStore.supportsValidatedBlobBatch(this.store);
|
|
734
|
+
const [currentBlobBytes, targetBlobBytes] = useValidatedBatch
|
|
735
|
+
? await Promise.all([
|
|
736
|
+
this.readDurableBlobBytes(current.manifestId, paths, currentPaths),
|
|
737
|
+
this.readDurableBlobBytes(target.manifestId, paths, targetPaths),
|
|
738
|
+
])
|
|
739
|
+
: [new Map<string, Uint8Array>(), new Map<string, Uint8Array>()];
|
|
740
|
+
const result: DurablePackEntryInput[] = [];
|
|
741
|
+
for (const path of paths) {
|
|
742
|
+
const variants = new Map<string, DurableLeafInput>();
|
|
743
|
+
const absent: DurableLeafInput = { kind: "absent", fingerprint: fingerprintAbsent(path) };
|
|
744
|
+
variants.set(absent.fingerprint, absent);
|
|
745
|
+
const currentLeaf = useValidatedBatch
|
|
746
|
+
? this.durableLeaf(currentPaths.get(path), currentBlobBytes.get(path))
|
|
747
|
+
: await this.durableLeafCompatible(current.manifestId, currentPaths.get(path));
|
|
748
|
+
if (currentLeaf !== undefined) variants.set(currentLeaf.fingerprint, currentLeaf);
|
|
749
|
+
const targetLeaf = useValidatedBatch
|
|
750
|
+
? this.durableLeaf(targetPaths.get(path), targetBlobBytes.get(path))
|
|
751
|
+
: await this.durableLeafCompatible(target.manifestId, targetPaths.get(path));
|
|
752
|
+
if (targetLeaf !== undefined) variants.set(targetLeaf.fingerprint, targetLeaf);
|
|
753
|
+
if (currentLeaf === undefined && targetLeaf === undefined) continue;
|
|
754
|
+
const artifactId = checksum(canonicalJson({ opId, path })).slice(0, 32);
|
|
755
|
+
const parts = path.split("/");
|
|
756
|
+
const parent = parts.slice(0, -1).join("/");
|
|
757
|
+
const artifact = (role: "source" | "target"): string =>
|
|
758
|
+
`${parent === "" ? "" : `${parent}/`}.pi-undo-q2-${artifactId}-${role}`;
|
|
759
|
+
result.push({
|
|
760
|
+
path,
|
|
761
|
+
sourceArtifact: artifact("source"),
|
|
762
|
+
targetArtifact: targetLeaf?.kind === "file" ? artifact("target") : null,
|
|
763
|
+
sourceFingerprint: currentLeaf?.fingerprint ?? absent.fingerprint,
|
|
764
|
+
targetFingerprint: targetLeaf?.fingerprint ?? null,
|
|
765
|
+
variants: [...variants.values()],
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return result;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
private async readDurableBlobBytes(
|
|
772
|
+
manifestId: ManifestId,
|
|
773
|
+
paths: readonly string[],
|
|
774
|
+
ownedPaths: ReadonlyMap<string, OwnedPath>,
|
|
775
|
+
): Promise<ReadonlyMap<string, Uint8Array>> {
|
|
776
|
+
const files = paths.flatMap((path) => {
|
|
777
|
+
const owned = ownedPaths.get(path);
|
|
778
|
+
if (owned?.entry.kind !== "file") return [];
|
|
779
|
+
if (owned.entry.blobId === null) {
|
|
780
|
+
throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
|
|
781
|
+
}
|
|
782
|
+
return [{ path, owned, blobId: owned.entry.blobId }];
|
|
783
|
+
});
|
|
784
|
+
if (files.length === 0) return new Map();
|
|
785
|
+
const requests = files.map(({ owned, blobId }) => ({
|
|
786
|
+
rootPath: owned.root.relativeRoot,
|
|
787
|
+
blobId,
|
|
788
|
+
relativePath: owned.entry.relativePath,
|
|
789
|
+
}));
|
|
790
|
+
const blobs = await SnapshotStore.readBlobs(this.store, manifestId, requests);
|
|
791
|
+
if (blobs.length !== files.length) throw new Error("durable pack blob batch 数量不匹配");
|
|
792
|
+
return new Map(files.map(({ path }, index) => [path, blobs[index]!]));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
private async durableLeafCompatible(
|
|
796
|
+
manifestId: ManifestId,
|
|
797
|
+
owned: OwnedPath | undefined,
|
|
798
|
+
): Promise<DurableLeafInput | undefined> {
|
|
799
|
+
if (owned?.entry.kind !== "file") return this.durableLeaf(owned, undefined);
|
|
800
|
+
if (owned.entry.blobId === null) {
|
|
801
|
+
throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
|
|
802
|
+
}
|
|
803
|
+
return this.durableLeaf(owned, await this.store.readBlob(
|
|
804
|
+
manifestId,
|
|
805
|
+
owned.root.relativeRoot,
|
|
806
|
+
owned.entry.blobId,
|
|
807
|
+
owned.entry.relativePath,
|
|
808
|
+
));
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
private durableLeaf(
|
|
812
|
+
owned: OwnedPath | undefined,
|
|
813
|
+
bytes: Uint8Array | undefined,
|
|
814
|
+
): DurableLeafInput | undefined {
|
|
815
|
+
if (owned === undefined || owned.entry.kind === "directory") return undefined;
|
|
816
|
+
if (owned.entry.kind === "symlink") {
|
|
817
|
+
return {
|
|
818
|
+
kind: "symlink",
|
|
819
|
+
fingerprint: fingerprintSymlink(owned.absolutePath, owned.entry.linkText!),
|
|
820
|
+
linkText: owned.entry.linkText!,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (owned.entry.blobId === null || bytes === undefined) {
|
|
824
|
+
throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
|
|
825
|
+
}
|
|
826
|
+
const mode = owned.entry.mode & 0o777;
|
|
827
|
+
return {
|
|
828
|
+
kind: "file",
|
|
829
|
+
fingerprint: fingerprintBytes(owned.absolutePath, bytes, mode),
|
|
830
|
+
mode,
|
|
831
|
+
bytes,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
441
835
|
private async readOwnedPaths(
|
|
442
836
|
manifest: SnapshotManifest,
|
|
443
837
|
scopePaths?: readonly string[],
|
|
@@ -729,7 +1123,8 @@ export class RestoreEngine {
|
|
|
729
1123
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
730
1124
|
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
731
1125
|
options: RestoreApplyOptions,
|
|
732
|
-
scopePaths
|
|
1126
|
+
scopePaths: readonly string[] | undefined,
|
|
1127
|
+
syncTargetArtifacts: boolean,
|
|
733
1128
|
): Promise<RestoreResult> {
|
|
734
1129
|
let rollbackPlan: RestorePlan | undefined;
|
|
735
1130
|
try {
|
|
@@ -747,6 +1142,7 @@ export class RestoreEngine {
|
|
|
747
1142
|
quarantine: new QuarantineManager({
|
|
748
1143
|
workspaceRoot: this.requestedWorkspaceRoot,
|
|
749
1144
|
journal: options.mutationJournal,
|
|
1145
|
+
syncTargetArtifacts,
|
|
750
1146
|
}),
|
|
751
1147
|
};
|
|
752
1148
|
await this.deletePlannedPaths(
|
|
@@ -784,6 +1180,7 @@ export class RestoreEngine {
|
|
|
784
1180
|
new QuarantineManager({
|
|
785
1181
|
workspaceRoot: this.requestedWorkspaceRoot,
|
|
786
1182
|
journal: options.mutationJournal,
|
|
1183
|
+
syncTargetArtifacts,
|
|
787
1184
|
}),
|
|
788
1185
|
options.mutationJournal,
|
|
789
1186
|
);
|
|
@@ -940,14 +1337,16 @@ export class RestoreEngine {
|
|
|
940
1337
|
targetMode: target.entry.mode & 0o777,
|
|
941
1338
|
sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
|
|
942
1339
|
targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1340
|
+
...(this.beforeMutation === undefined ? {} : {
|
|
1341
|
+
beforeInstall: async () => {
|
|
1342
|
+
await this.beforeMutation?.({
|
|
1343
|
+
phase: context.phase,
|
|
1344
|
+
ordinal,
|
|
1345
|
+
kind: "write",
|
|
1346
|
+
path: target.absolutePath,
|
|
1347
|
+
});
|
|
1348
|
+
},
|
|
1349
|
+
}),
|
|
951
1350
|
};
|
|
952
1351
|
}
|
|
953
1352
|
|
|
@@ -983,6 +1382,7 @@ export class RestoreEngine {
|
|
|
983
1382
|
topology: RootTopology,
|
|
984
1383
|
allowedManifests: readonly SnapshotManifest[],
|
|
985
1384
|
mutationJournal?: MutationJournal,
|
|
1385
|
+
extraExclusions: readonly string[] = [],
|
|
986
1386
|
): Promise<void> {
|
|
987
1387
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
988
1388
|
return;
|
|
@@ -1000,8 +1400,12 @@ export class RestoreEngine {
|
|
|
1000
1400
|
}
|
|
1001
1401
|
}
|
|
1002
1402
|
|
|
1403
|
+
const exclusions = new Set(extraExclusions);
|
|
1404
|
+
if (mutationJournal !== undefined) {
|
|
1405
|
+
for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
|
|
1406
|
+
}
|
|
1003
1407
|
const livePaths = await this.store.listVisibleLeafPaths(topology, {
|
|
1004
|
-
excludePaths:
|
|
1408
|
+
excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
|
|
1005
1409
|
});
|
|
1006
1410
|
for (const path of livePaths) {
|
|
1007
1411
|
if (!allowedPaths.has(path)) {
|
|
@@ -1447,6 +1851,14 @@ async function mapConcurrentOrdered<T, R>(
|
|
|
1447
1851
|
return results;
|
|
1448
1852
|
}
|
|
1449
1853
|
|
|
1854
|
+
function durablePairKey(
|
|
1855
|
+
currentManifestId: ManifestId,
|
|
1856
|
+
targetManifestId: ManifestId,
|
|
1857
|
+
scopePaths: readonly string[],
|
|
1858
|
+
): string {
|
|
1859
|
+
return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1450
1862
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1451
1863
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1452
1864
|
}
|