@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
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,
|
|
@@ -25,8 +35,8 @@ import {
|
|
|
25
35
|
import { SnapshotStoreError, type SnapshotStore } 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,137 @@ 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 result: DurablePackEntryInput[] = [];
|
|
734
|
+
for (const path of paths) {
|
|
735
|
+
const variants = new Map<string, DurableLeafInput>();
|
|
736
|
+
const absent: DurableLeafInput = { kind: "absent", fingerprint: fingerprintAbsent(path) };
|
|
737
|
+
variants.set(absent.fingerprint, absent);
|
|
738
|
+
const currentLeaf = await this.durableLeaf(current.manifestId, currentPaths.get(path));
|
|
739
|
+
if (currentLeaf !== undefined) variants.set(currentLeaf.fingerprint, currentLeaf);
|
|
740
|
+
const targetLeaf = await this.durableLeaf(target.manifestId, targetPaths.get(path));
|
|
741
|
+
if (targetLeaf !== undefined) variants.set(targetLeaf.fingerprint, targetLeaf);
|
|
742
|
+
if (currentLeaf === undefined && targetLeaf === undefined) continue;
|
|
743
|
+
const artifactId = checksum(canonicalJson({ opId, path })).slice(0, 32);
|
|
744
|
+
const parts = path.split("/");
|
|
745
|
+
const parent = parts.slice(0, -1).join("/");
|
|
746
|
+
const artifact = (role: "source" | "target"): string =>
|
|
747
|
+
`${parent === "" ? "" : `${parent}/`}.pi-undo-q2-${artifactId}-${role}`;
|
|
748
|
+
result.push({
|
|
749
|
+
path,
|
|
750
|
+
sourceArtifact: artifact("source"),
|
|
751
|
+
targetArtifact: targetLeaf?.kind === "file" ? artifact("target") : null,
|
|
752
|
+
sourceFingerprint: currentLeaf?.fingerprint ?? absent.fingerprint,
|
|
753
|
+
targetFingerprint: targetLeaf?.fingerprint ?? null,
|
|
754
|
+
variants: [...variants.values()],
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private async durableLeaf(
|
|
761
|
+
manifestId: ManifestId,
|
|
762
|
+
owned: OwnedPath | undefined,
|
|
763
|
+
): Promise<DurableLeafInput | undefined> {
|
|
764
|
+
if (owned === undefined || owned.entry.kind === "directory") return undefined;
|
|
765
|
+
if (owned.entry.kind === "symlink") {
|
|
766
|
+
return {
|
|
767
|
+
kind: "symlink",
|
|
768
|
+
fingerprint: fingerprintSymlink(owned.absolutePath, owned.entry.linkText!),
|
|
769
|
+
linkText: owned.entry.linkText!,
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (owned.entry.blobId === null) throw new Error(`durable pack 普通文件缺少 blob:${owned.absolutePath}`);
|
|
773
|
+
const bytes = await this.store.readBlob(
|
|
774
|
+
manifestId,
|
|
775
|
+
owned.root.relativeRoot,
|
|
776
|
+
owned.entry.blobId,
|
|
777
|
+
owned.entry.relativePath,
|
|
778
|
+
);
|
|
779
|
+
const mode = owned.entry.mode & 0o777;
|
|
780
|
+
return {
|
|
781
|
+
kind: "file",
|
|
782
|
+
fingerprint: fingerprintBytes(owned.absolutePath, bytes, mode),
|
|
783
|
+
mode,
|
|
784
|
+
bytes,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
|
|
441
788
|
private async readOwnedPaths(
|
|
442
789
|
manifest: SnapshotManifest,
|
|
443
790
|
scopePaths?: readonly string[],
|
|
@@ -729,7 +1076,8 @@ export class RestoreEngine {
|
|
|
729
1076
|
currentPaths: ReadonlyMap<string, OwnedPath>,
|
|
730
1077
|
targetPaths: ReadonlyMap<string, OwnedPath>,
|
|
731
1078
|
options: RestoreApplyOptions,
|
|
732
|
-
scopePaths
|
|
1079
|
+
scopePaths: readonly string[] | undefined,
|
|
1080
|
+
syncTargetArtifacts: boolean,
|
|
733
1081
|
): Promise<RestoreResult> {
|
|
734
1082
|
let rollbackPlan: RestorePlan | undefined;
|
|
735
1083
|
try {
|
|
@@ -747,6 +1095,7 @@ export class RestoreEngine {
|
|
|
747
1095
|
quarantine: new QuarantineManager({
|
|
748
1096
|
workspaceRoot: this.requestedWorkspaceRoot,
|
|
749
1097
|
journal: options.mutationJournal,
|
|
1098
|
+
syncTargetArtifacts,
|
|
750
1099
|
}),
|
|
751
1100
|
};
|
|
752
1101
|
await this.deletePlannedPaths(
|
|
@@ -784,6 +1133,7 @@ export class RestoreEngine {
|
|
|
784
1133
|
new QuarantineManager({
|
|
785
1134
|
workspaceRoot: this.requestedWorkspaceRoot,
|
|
786
1135
|
journal: options.mutationJournal,
|
|
1136
|
+
syncTargetArtifacts,
|
|
787
1137
|
}),
|
|
788
1138
|
options.mutationJournal,
|
|
789
1139
|
);
|
|
@@ -940,14 +1290,16 @@ export class RestoreEngine {
|
|
|
940
1290
|
targetMode: target.entry.mode & 0o777,
|
|
941
1291
|
sourceFingerprint: await this.expectedMutationFingerprint(context, target.absolutePath),
|
|
942
1292
|
targetFingerprint: fingerprintBytes(target.absolutePath, bytes, target.entry.mode),
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1293
|
+
...(this.beforeMutation === undefined ? {} : {
|
|
1294
|
+
beforeInstall: async () => {
|
|
1295
|
+
await this.beforeMutation?.({
|
|
1296
|
+
phase: context.phase,
|
|
1297
|
+
ordinal,
|
|
1298
|
+
kind: "write",
|
|
1299
|
+
path: target.absolutePath,
|
|
1300
|
+
});
|
|
1301
|
+
},
|
|
1302
|
+
}),
|
|
951
1303
|
};
|
|
952
1304
|
}
|
|
953
1305
|
|
|
@@ -983,6 +1335,7 @@ export class RestoreEngine {
|
|
|
983
1335
|
topology: RootTopology,
|
|
984
1336
|
allowedManifests: readonly SnapshotManifest[],
|
|
985
1337
|
mutationJournal?: MutationJournal,
|
|
1338
|
+
extraExclusions: readonly string[] = [],
|
|
986
1339
|
): Promise<void> {
|
|
987
1340
|
if (allowedManifests.some((manifest) => manifest.coverage !== "complete")) {
|
|
988
1341
|
return;
|
|
@@ -1000,8 +1353,12 @@ export class RestoreEngine {
|
|
|
1000
1353
|
}
|
|
1001
1354
|
}
|
|
1002
1355
|
|
|
1356
|
+
const exclusions = new Set(extraExclusions);
|
|
1357
|
+
if (mutationJournal !== undefined) {
|
|
1358
|
+
for (const path of await mutationJournal.activeArtifacts()) exclusions.add(path);
|
|
1359
|
+
}
|
|
1003
1360
|
const livePaths = await this.store.listVisibleLeafPaths(topology, {
|
|
1004
|
-
excludePaths:
|
|
1361
|
+
excludePaths: exclusions.size === 0 ? undefined : [...exclusions],
|
|
1005
1362
|
});
|
|
1006
1363
|
for (const path of livePaths) {
|
|
1007
1364
|
if (!allowedPaths.has(path)) {
|
|
@@ -1447,6 +1804,14 @@ async function mapConcurrentOrdered<T, R>(
|
|
|
1447
1804
|
return results;
|
|
1448
1805
|
}
|
|
1449
1806
|
|
|
1807
|
+
function durablePairKey(
|
|
1808
|
+
currentManifestId: ManifestId,
|
|
1809
|
+
targetManifestId: ManifestId,
|
|
1810
|
+
scopePaths: readonly string[],
|
|
1811
|
+
): string {
|
|
1812
|
+
return `${currentManifestId}\0${targetManifestId}\0${checksum(canonicalJson([...scopePaths].sort(comparePaths)))}`;
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1450
1815
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
1451
1816
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
1452
1817
|
}
|
package/src/snapshot-store.ts
CHANGED
|
@@ -125,6 +125,7 @@ export interface SnapshotStore {
|
|
|
125
125
|
pin(id: ManifestId, reason: string): Promise<void>;
|
|
126
126
|
unpin(id: ManifestId, reason: string): Promise<void>;
|
|
127
127
|
collectGarbage(): Promise<number>;
|
|
128
|
+
durableCacheDirectory(): Promise<string>;
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
export class SnapshotStore {
|
|
@@ -150,6 +151,12 @@ export class SnapshotStore {
|
|
|
150
151
|
this.clock = options.clock ?? Date.now;
|
|
151
152
|
}
|
|
152
153
|
|
|
154
|
+
async durableCacheDirectory(): Promise<string> {
|
|
155
|
+
const directory = join(this.storeRoot, "durable-cache");
|
|
156
|
+
await mkdir(directory, { recursive: true });
|
|
157
|
+
return directory;
|
|
158
|
+
}
|
|
159
|
+
|
|
153
160
|
async capture(
|
|
154
161
|
topology: RootTopology,
|
|
155
162
|
scope?: readonly string[],
|
package/src/workspace-lock.ts
CHANGED
|
@@ -116,7 +116,7 @@ export class WorkspaceLock {
|
|
|
116
116
|
await rename(candidateDirectory, lockDirectory);
|
|
117
117
|
published = true;
|
|
118
118
|
} catch (error) {
|
|
119
|
-
if (!await pathExists(lockDirectory)) {
|
|
119
|
+
if (!isPublishCollision(error) && !await pathExists(lockDirectory)) {
|
|
120
120
|
throw error;
|
|
121
121
|
}
|
|
122
122
|
} finally {
|
|
@@ -402,6 +402,10 @@ function assertPositive(value: number, name: string): void {
|
|
|
402
402
|
}
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
function isPublishCollision(error: unknown): boolean {
|
|
406
|
+
return hasErrorCode(error, "EEXIST") || hasErrorCode(error, "ENOTEMPTY");
|
|
407
|
+
}
|
|
408
|
+
|
|
405
409
|
function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException {
|
|
406
410
|
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
407
411
|
}
|