@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/snapshot-store.ts
CHANGED
|
@@ -20,7 +20,7 @@ import type {
|
|
|
20
20
|
SnapshotManifest,
|
|
21
21
|
SnapshotRoot,
|
|
22
22
|
} from "./model.ts";
|
|
23
|
-
import { assertNoSymlinkEscape, relativeSafePath } from "./path-safety.ts";
|
|
23
|
+
import { assertNoSymlinkEscape, pathSetsOverlap, relativeSafePath } from "./path-safety.ts";
|
|
24
24
|
import { RootDiscovery, type RootTopology } from "./root-discovery.ts";
|
|
25
25
|
import { WorkspaceLock } from "./workspace-lock.ts";
|
|
26
26
|
|
|
@@ -96,6 +96,12 @@ export interface CaptureOptions {
|
|
|
96
96
|
readonly excludePaths?: readonly string[];
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
export interface SnapshotBlobRequest {
|
|
100
|
+
readonly rootPath: string;
|
|
101
|
+
readonly blobId: string;
|
|
102
|
+
readonly relativePath?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
99
105
|
export type SnapshotStoreErrorCode =
|
|
100
106
|
| "capture_failed"
|
|
101
107
|
| "invalid_manifest_id"
|
|
@@ -125,6 +131,7 @@ export interface SnapshotStore {
|
|
|
125
131
|
pin(id: ManifestId, reason: string): Promise<void>;
|
|
126
132
|
unpin(id: ManifestId, reason: string): Promise<void>;
|
|
127
133
|
collectGarbage(): Promise<number>;
|
|
134
|
+
durableCacheDirectory(): Promise<string>;
|
|
128
135
|
}
|
|
129
136
|
|
|
130
137
|
export class SnapshotStore {
|
|
@@ -150,6 +157,39 @@ export class SnapshotStore {
|
|
|
150
157
|
this.clock = options.clock ?? Date.now;
|
|
151
158
|
}
|
|
152
159
|
|
|
160
|
+
static supportsValidatedBlobBatch(store: SnapshotStore): boolean {
|
|
161
|
+
return store instanceof SnapshotStore &&
|
|
162
|
+
store.readBlob === SnapshotStore.prototype.readBlob &&
|
|
163
|
+
store.loadManifest === SnapshotStore.prototype.loadManifest;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
static async readBlobs(
|
|
167
|
+
store: SnapshotStore,
|
|
168
|
+
id: ManifestId,
|
|
169
|
+
requests: readonly SnapshotBlobRequest[],
|
|
170
|
+
): Promise<readonly Uint8Array[]> {
|
|
171
|
+
if (requests.length === 0) return [];
|
|
172
|
+
if (SnapshotStore.supportsValidatedBlobBatch(store)) {
|
|
173
|
+
return (store as SnapshotStore).readBlobsValidated(id, requests);
|
|
174
|
+
}
|
|
175
|
+
const result: Uint8Array[] = [];
|
|
176
|
+
for (const request of requests) {
|
|
177
|
+
result.push(await store.readBlob(
|
|
178
|
+
id,
|
|
179
|
+
request.rootPath,
|
|
180
|
+
request.blobId,
|
|
181
|
+
request.relativePath,
|
|
182
|
+
));
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async durableCacheDirectory(): Promise<string> {
|
|
188
|
+
const directory = join(this.storeRoot, "durable-cache");
|
|
189
|
+
await mkdir(directory, { recursive: true });
|
|
190
|
+
return directory;
|
|
191
|
+
}
|
|
192
|
+
|
|
153
193
|
async capture(
|
|
154
194
|
topology: RootTopology,
|
|
155
195
|
scope?: readonly string[],
|
|
@@ -302,7 +342,10 @@ export class SnapshotStore {
|
|
|
302
342
|
}
|
|
303
343
|
|
|
304
344
|
async loadManifest(id: ManifestId): Promise<SnapshotManifest> {
|
|
305
|
-
|
|
345
|
+
return this.loadManifestFromPath(id, await this.findManifestPath(id));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private async loadManifestFromPath(id: ManifestId, manifestPath: string): Promise<SnapshotManifest> {
|
|
306
349
|
try {
|
|
307
350
|
const value: unknown = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
308
351
|
const manifest = assertManifest(value);
|
|
@@ -342,10 +385,10 @@ export class SnapshotStore {
|
|
|
342
385
|
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
343
386
|
await this.assertNoAlternates(gitDirectory);
|
|
344
387
|
const entries = await this.readTreeEntries(gitDirectory, root.treeId, rootScope);
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
))
|
|
388
|
+
if (pathSetsOverlap(
|
|
389
|
+
root.ignoredPresentPaths,
|
|
390
|
+
entries.map((entry) => entry.relativePath),
|
|
391
|
+
)) {
|
|
349
392
|
throw new SnapshotStoreError("object_missing", "ignored-present proof 与 root tree 冲突");
|
|
350
393
|
}
|
|
351
394
|
await this.assertObjectsComplete(gitDirectory, root.treeId, entries);
|
|
@@ -425,41 +468,67 @@ export class SnapshotStore {
|
|
|
425
468
|
blobId: string,
|
|
426
469
|
relativePath?: string,
|
|
427
470
|
): Promise<Uint8Array> {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
471
|
+
return (await this.readBlobOperation(id, [{ rootPath, blobId, relativePath }], false))[0]!;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private readBlobsValidated(
|
|
475
|
+
id: ManifestId,
|
|
476
|
+
requests: readonly SnapshotBlobRequest[],
|
|
477
|
+
): Promise<readonly Uint8Array[]> {
|
|
478
|
+
return this.readBlobOperation(id, requests, true);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private async readBlobOperation(
|
|
482
|
+
id: ManifestId,
|
|
483
|
+
requests: readonly SnapshotBlobRequest[],
|
|
484
|
+
revalidateManifest: boolean,
|
|
485
|
+
): Promise<readonly Uint8Array[]> {
|
|
486
|
+
for (const request of requests) {
|
|
487
|
+
relativeSafePath("/", request.rootPath);
|
|
488
|
+
if (!isObjectId(request.blobId)) {
|
|
489
|
+
throw new SnapshotStoreError("object_missing", "blob ID 无效");
|
|
490
|
+
}
|
|
431
491
|
}
|
|
432
492
|
const manifestPath = await this.findManifestPath(id);
|
|
433
|
-
const manifest =
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
}
|
|
438
|
-
if (root.state !== "active" || root.treeId === null) {
|
|
439
|
-
throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
|
|
440
|
-
}
|
|
441
|
-
|
|
493
|
+
const manifest = revalidateManifest
|
|
494
|
+
? await this.loadManifestFromPath(id, manifestPath)
|
|
495
|
+
: await this.loadManifest(id);
|
|
496
|
+
const roots = new Map(manifest.roots.map((root) => [root.relativeRoot, root]));
|
|
442
497
|
const storeDirectory = dirname(dirname(manifestPath));
|
|
443
|
-
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
444
498
|
try {
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
const
|
|
448
|
-
if (
|
|
449
|
-
throw new SnapshotStoreError("
|
|
499
|
+
const result: Uint8Array[] = [];
|
|
500
|
+
for (const request of requests) {
|
|
501
|
+
const root = roots.get(request.rootPath);
|
|
502
|
+
if (root === undefined) {
|
|
503
|
+
throw new SnapshotStoreError("root_not_found", "manifest 中不存在指定 root");
|
|
450
504
|
}
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
let ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
454
|
-
if (ownedBlobId === undefined) {
|
|
455
|
-
await this.readTreeEntries(gitDirectory, root.treeId, [safeRelativePath]);
|
|
456
|
-
ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
505
|
+
if (root.state !== "active" || root.treeId === null) {
|
|
506
|
+
throw new SnapshotStoreError("object_missing", "指定 root 没有可读取的 tree");
|
|
457
507
|
}
|
|
458
|
-
|
|
459
|
-
|
|
508
|
+
const gitDirectory = this.rootGitDirectory(storeDirectory, root);
|
|
509
|
+
const safeRelativePath = request.relativePath === undefined
|
|
510
|
+
? undefined
|
|
511
|
+
: relativeSafePath("/", request.relativePath);
|
|
512
|
+
if (safeRelativePath === undefined) {
|
|
513
|
+
const entries = await this.readTreeEntries(gitDirectory, root.treeId);
|
|
514
|
+
if (!entries.some((entry) => entry.objectId === request.blobId)) {
|
|
515
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree");
|
|
516
|
+
}
|
|
517
|
+
} else {
|
|
518
|
+
const membershipKey = treeBlobMembershipKey(gitDirectory, root.treeId, safeRelativePath);
|
|
519
|
+
let ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
520
|
+
if (ownedBlobId === undefined) {
|
|
521
|
+
await this.readTreeEntries(gitDirectory, root.treeId, [safeRelativePath]);
|
|
522
|
+
ownedBlobId = this.treeBlobMembership.get(membershipKey);
|
|
523
|
+
}
|
|
524
|
+
if (ownedBlobId !== request.blobId) {
|
|
525
|
+
throw new SnapshotStoreError("object_missing", "blob 不属于指定 root tree path");
|
|
526
|
+
}
|
|
460
527
|
}
|
|
528
|
+
result.push(new Uint8Array(await this.readBlobBytes(gitDirectory, request.blobId)));
|
|
461
529
|
}
|
|
462
|
-
|
|
530
|
+
if (revalidateManifest) await this.loadManifestFromPath(id, manifestPath);
|
|
531
|
+
return result;
|
|
463
532
|
} catch (error) {
|
|
464
533
|
if (error instanceof SnapshotStoreError) {
|
|
465
534
|
throw error;
|
|
@@ -627,7 +696,7 @@ export class SnapshotStore {
|
|
|
627
696
|
gitBacked: boolean,
|
|
628
697
|
inclusions: readonly string[] | null,
|
|
629
698
|
exclusions: readonly string[],
|
|
630
|
-
exactExclusions:
|
|
699
|
+
exactExclusions: ReadonlySet<string>,
|
|
631
700
|
): Promise<string[]> {
|
|
632
701
|
if (inclusions === null) {
|
|
633
702
|
return [];
|
|
@@ -650,7 +719,7 @@ export class SnapshotStore {
|
|
|
650
719
|
for (const relativePath of parseNulPaths(output)) {
|
|
651
720
|
if (
|
|
652
721
|
exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) ||
|
|
653
|
-
exactExclusions.
|
|
722
|
+
exactExclusions.has(relativePath)
|
|
654
723
|
) {
|
|
655
724
|
continue;
|
|
656
725
|
}
|
|
@@ -679,7 +748,7 @@ export class SnapshotStore {
|
|
|
679
748
|
gitBacked: boolean,
|
|
680
749
|
inclusions: readonly string[] | null,
|
|
681
750
|
exclusions: readonly string[],
|
|
682
|
-
exactExclusions:
|
|
751
|
+
exactExclusions: ReadonlySet<string>,
|
|
683
752
|
): Promise<void> {
|
|
684
753
|
const leaves = await this.collectVisibleLeaves(
|
|
685
754
|
cwd,
|
|
@@ -751,7 +820,7 @@ export class SnapshotStore {
|
|
|
751
820
|
gitBacked: boolean,
|
|
752
821
|
inclusions: readonly string[] | null,
|
|
753
822
|
exclusions: readonly string[],
|
|
754
|
-
exactExclusions:
|
|
823
|
+
exactExclusions: ReadonlySet<string>,
|
|
755
824
|
excludeDeleted = false,
|
|
756
825
|
): Promise<string[]> {
|
|
757
826
|
if (inclusions === null) return [];
|
|
@@ -785,7 +854,7 @@ export class SnapshotStore {
|
|
|
785
854
|
return parseNulPaths(output).filter((relativePath) =>
|
|
786
855
|
!deletedPaths.has(relativePath) &&
|
|
787
856
|
!exclusions.some((excluded) => isPathAtOrBelow(excluded, relativePath)) &&
|
|
788
|
-
!exactExclusions.
|
|
857
|
+
!exactExclusions.has(relativePath));
|
|
789
858
|
}
|
|
790
859
|
|
|
791
860
|
private async collectVisibleLeaves(
|
|
@@ -794,7 +863,7 @@ export class SnapshotStore {
|
|
|
794
863
|
gitBacked: boolean,
|
|
795
864
|
inclusions: readonly string[] | null,
|
|
796
865
|
exclusions: readonly string[],
|
|
797
|
-
exactExclusions:
|
|
866
|
+
exactExclusions: ReadonlySet<string>,
|
|
798
867
|
): Promise<VisibleLeaf[]> {
|
|
799
868
|
const paths = await this.queryVisibleLeafPaths(
|
|
800
869
|
cwd,
|
|
@@ -1189,7 +1258,7 @@ function ownedArtifactExclusions(
|
|
|
1189
1258
|
roots: readonly DiscoveryRoot[],
|
|
1190
1259
|
rootPath: string,
|
|
1191
1260
|
exclusions: readonly string[],
|
|
1192
|
-
): string
|
|
1261
|
+
): ReadonlySet<string> {
|
|
1193
1262
|
const result: string[] = [];
|
|
1194
1263
|
for (const exclusion of exclusions) {
|
|
1195
1264
|
const owner = roots
|
|
@@ -1204,7 +1273,7 @@ function ownedArtifactExclusions(
|
|
|
1204
1273
|
}
|
|
1205
1274
|
result.push(rootRelativePath(rootPath, exclusion));
|
|
1206
1275
|
}
|
|
1207
|
-
return result;
|
|
1276
|
+
return new Set(result);
|
|
1208
1277
|
}
|
|
1209
1278
|
|
|
1210
1279
|
function rootRelativeScope(rootPath: string, scope: readonly string[] | undefined): string[] | undefined {
|
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
|
}
|