run402 4.47.0 → 4.48.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/git-remote-run402.mjs +84 -13
- package/gitvault-surface.json +1 -1
- package/lib/repos.mjs +8 -0
- package/package.json +1 -1
- package/sdk/dist/namespaces/gitvault.d.ts +18 -6
- package/sdk/dist/namespaces/gitvault.d.ts.map +1 -1
- package/sdk/dist/namespaces/gitvault.js +23 -6
- package/sdk/dist/namespaces/gitvault.js.map +1 -1
- package/sdk/dist/node/gitvault-address.d.ts +18 -5
- package/sdk/dist/node/gitvault-address.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-address.js +57 -9
- package/sdk/dist/node/gitvault-address.js.map +1 -1
- package/sdk/dist/node/gitvault-keystore.d.ts +42 -0
- package/sdk/dist/node/gitvault-keystore.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-keystore.js +130 -0
- package/sdk/dist/node/gitvault-keystore.js.map +1 -1
- package/sdk/dist/node/gitvault-publication.d.ts +130 -8
- package/sdk/dist/node/gitvault-publication.d.ts.map +1 -1
- package/sdk/dist/node/gitvault-publication.js +418 -39
- package/sdk/dist/node/gitvault-publication.js.map +1 -1
|
@@ -559,6 +559,33 @@ export function gitvaultLedgerId(read) {
|
|
|
559
559
|
}
|
|
560
560
|
function b64(bytes) { return Buffer.from(bytes).toString("base64"); }
|
|
561
561
|
function b64u(bytes) { return Buffer.from(bytes).toString("base64url"); }
|
|
562
|
+
/**
|
|
563
|
+
* Independent reads/PUTs within one gitvault step run at this concurrency
|
|
564
|
+
* (design D2) — "browser-era origin etiquette", well within what S3/the
|
|
565
|
+
* gateway tolerate, and it keeps in-flight memory bounded by ~6×frame size.
|
|
566
|
+
* Chain-ordered steps (the head-chain walk, admission, readback) never call
|
|
567
|
+
* this — they stay strictly sequential.
|
|
568
|
+
*/
|
|
569
|
+
export const GITVAULT_TRANSPORT_CONCURRENCY = 6;
|
|
570
|
+
/**
|
|
571
|
+
* Run `fn` over `items` with at most `limit` in flight at once, preserving
|
|
572
|
+
* result order regardless of completion order. A plain worker-pool: `limit`
|
|
573
|
+
* workers each pull the next unclaimed index until the queue is empty.
|
|
574
|
+
*/
|
|
575
|
+
export async function mapBounded(items, limit, fn) {
|
|
576
|
+
const results = new Array(items.length);
|
|
577
|
+
let next = 0;
|
|
578
|
+
async function worker() {
|
|
579
|
+
for (;;) {
|
|
580
|
+
const i = next++;
|
|
581
|
+
if (i >= items.length)
|
|
582
|
+
return;
|
|
583
|
+
results[i] = await fn(items[i], i);
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
await Promise.all(Array.from({ length: Math.max(0, Math.min(limit, items.length)) }, () => worker()));
|
|
587
|
+
return results;
|
|
588
|
+
}
|
|
562
589
|
/**
|
|
563
590
|
* The `fetch`-backed transport over the SDK kernel. Presigned PUTs carry
|
|
564
591
|
* `If-None-Match: *` (create-only — the bucket policy demands it) and the
|
|
@@ -645,6 +672,49 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
645
672
|
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${path}`, "reading gitvault object", { path, status: r.status });
|
|
646
673
|
return new Uint8Array(await r.arrayBuffer());
|
|
647
674
|
}
|
|
675
|
+
/**
|
|
676
|
+
* Presign + fetch N independent objects (gitvault-client-round-trips
|
|
677
|
+
* design D2): ONE `object-reads` POST naming every path, then the GETs
|
|
678
|
+
* with bounded concurrency ({@link GITVAULT_TRANSPORT_CONCURRENCY}).
|
|
679
|
+
* Every path must be `object-reads`-addressed (a "carrier": ref_state,
|
|
680
|
+
* retention_roots, a WAL/checkpoint pack, …) — a generation-addressed
|
|
681
|
+
* head/admission path has no place in a batch built for materialize/
|
|
682
|
+
* restore's carrier and pack reads, so it fails closed rather than
|
|
683
|
+
* silently costing an extra round trip through `getGenerationBytes`.
|
|
684
|
+
*/
|
|
685
|
+
async function getObjectsBytes(repoId, paths) {
|
|
686
|
+
if (paths.length === 0)
|
|
687
|
+
return [];
|
|
688
|
+
const refs = paths.map((path) => {
|
|
689
|
+
const ref = gitvaultWireRefForPath(path);
|
|
690
|
+
if (!ref || ref.kind !== "object")
|
|
691
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `${path} is not a batch-readable carrier object`, "reading gitvault objects", { path });
|
|
692
|
+
return ref.read;
|
|
693
|
+
});
|
|
694
|
+
let presigned;
|
|
695
|
+
try {
|
|
696
|
+
presigned = await client.request(`${base(repoId)}/object-reads`, { method: "POST", body: { objects: refs }, context: "resolving gitvault objects" });
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
if (isRun402Error(e) && e.status === 404)
|
|
700
|
+
return paths.map(() => null);
|
|
701
|
+
if (isRun402Error(e) && e.code === "RESOURCE_NOT_FOUND")
|
|
702
|
+
return paths.map(() => null);
|
|
703
|
+
throw e;
|
|
704
|
+
}
|
|
705
|
+
const byLedgerId = new Map(presigned.reads.map((r) => [gitvaultLedgerId(r), r]));
|
|
706
|
+
const targets = refs.map((r) => byLedgerId.get(gitvaultLedgerId(r)) ?? null);
|
|
707
|
+
return mapBounded(targets, GITVAULT_TRANSPORT_CONCURRENCY, async (target, i) => {
|
|
708
|
+
if (!target)
|
|
709
|
+
return null;
|
|
710
|
+
const r = await client.fetch(target.url, { method: "GET" });
|
|
711
|
+
if (r.status === 404)
|
|
712
|
+
return null;
|
|
713
|
+
if (!r.ok)
|
|
714
|
+
fail("GITVAULT_OBJECT_READ_FAILED", `object GET failed (HTTP ${r.status}) for ${paths[i]}`, "reading gitvault object", { path: paths[i], status: r.status });
|
|
715
|
+
return new Uint8Array(await r.arrayBuffer());
|
|
716
|
+
});
|
|
717
|
+
}
|
|
648
718
|
async function upload(repoId, objects, resourceBinding) {
|
|
649
719
|
if (objects.length === 0)
|
|
650
720
|
return [];
|
|
@@ -658,8 +728,9 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
658
728
|
context: "opening gitvault upload session",
|
|
659
729
|
});
|
|
660
730
|
const issued = new Map(session.objects.map((u) => [gitvaultLedgerId(u), u]));
|
|
661
|
-
|
|
662
|
-
|
|
731
|
+
// Independent create-only PUTs within one session (design D2) — bounded
|
|
732
|
+
// concurrency, same limit as the batched object reads below.
|
|
733
|
+
await mapBounded(objects, GITVAULT_TRANSPORT_CONCURRENCY, async (o, i) => {
|
|
663
734
|
const id = gitvaultLedgerId(entries[i]);
|
|
664
735
|
const target = issued.get(id);
|
|
665
736
|
if (!target)
|
|
@@ -680,11 +751,11 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
680
751
|
const existing = await getObjectBytes(repoId, o.path);
|
|
681
752
|
if (!existing || sha256Hex(existing) !== o.sha256)
|
|
682
753
|
fail("GITVAULT_OBJECT_EXISTS_DIFFERENT", `${o.path} already exists with different bytes`, "uploading gitvault objects", { path: o.path });
|
|
683
|
-
|
|
754
|
+
return;
|
|
684
755
|
}
|
|
685
756
|
if (!r.ok)
|
|
686
757
|
fail("GITVAULT_UPLOAD_FAILED", `presigned PUT failed (HTTP ${r.status}) for ${o.path}`, "uploading gitvault objects", { path: o.path, status: r.status });
|
|
687
|
-
}
|
|
758
|
+
});
|
|
688
759
|
const fin = await client.request(`${base(repoId)}/upload-sessions/${encodeURIComponent(session.upload_session_id)}/finalize`, { method: "POST", body: {}, context: "finalizing gitvault upload session" });
|
|
689
760
|
const receipts = new Map(fin.receipts.map((r) => [gitvaultLedgerId(r), r]));
|
|
690
761
|
return objects.map((o, i) => {
|
|
@@ -713,7 +784,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
713
784
|
throw e;
|
|
714
785
|
}
|
|
715
786
|
}
|
|
716
|
-
|
|
787
|
+
const transport = {
|
|
717
788
|
// ── creation (5.3) ──
|
|
718
789
|
async allocate(request) {
|
|
719
790
|
// The route wraps the signed allocation object under `allocation` and
|
|
@@ -727,6 +798,7 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
727
798
|
return { stored_bytes_sha256: r.sha256, size_bytes: r.size_bytes };
|
|
728
799
|
},
|
|
729
800
|
getObject: ({ repo_id, path }) => getObjectBytes(repo_id, path),
|
|
801
|
+
getObjects: ({ repo_id, paths }) => getObjectsBytes(repo_id, paths),
|
|
730
802
|
async admitGenesis(request) {
|
|
731
803
|
try {
|
|
732
804
|
const r = await admit(request.repo_id, GITVAULT_GENESIS_GENERATION, request.stored_bytes, request.stored_bytes_sha256, { allocation_generation: request.allocation_generation });
|
|
@@ -848,6 +920,80 @@ export function createGitvaultHttpTransport(client, options = {}) {
|
|
|
848
920
|
}
|
|
849
921
|
},
|
|
850
922
|
};
|
|
923
|
+
return process.env.RUN402_GITVAULT_TRACE === "1" ? traceGitvaultTransport(transport) : transport;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* `RUN402_GITVAULT_TRACE=1` (design D7) — one stderr line per real
|
|
927
|
+
* transport operation (op kind, a path/object-count shape when the request
|
|
928
|
+
* carries one, byte count when the result carries one, duration) plus a
|
|
929
|
+
* session summary at process exit (total ops, total time spent in this
|
|
930
|
+
* transport, wall-clock since the transport was created). Debug-only:
|
|
931
|
+
* stderr only — never stdout, so it can never contaminate the
|
|
932
|
+
* `git-remote-run402` protocol stream, the same discipline the helper's
|
|
933
|
+
* own `note()` follows — and not a canonical surface: it is NOT what the
|
|
934
|
+
* client-surface spec's counted budgets measure (that is
|
|
935
|
+
* `GitvaultOpCounter`, a test-only instrument over the SAME operation
|
|
936
|
+
* shapes). The client-side env var carries no gateway env-registry policy.
|
|
937
|
+
*/
|
|
938
|
+
function traceGitvaultTransport(inner) {
|
|
939
|
+
let opCount = 0;
|
|
940
|
+
let totalMs = 0;
|
|
941
|
+
const sessionStart = Date.now();
|
|
942
|
+
process.on("exit", () => {
|
|
943
|
+
if (opCount === 0)
|
|
944
|
+
return;
|
|
945
|
+
process.stderr.write(`gitvault-trace: session summary — ${opCount} op(s), ${totalMs.toFixed(1)}ms in transport, ${Date.now() - sessionStart}ms wall-clock\n`);
|
|
946
|
+
});
|
|
947
|
+
const describeRequest = (arg) => {
|
|
948
|
+
if (!arg || typeof arg !== "object")
|
|
949
|
+
return "";
|
|
950
|
+
const a = arg;
|
|
951
|
+
if (typeof a.path === "string")
|
|
952
|
+
return ` path=${a.path}`;
|
|
953
|
+
if (Array.isArray(a.paths))
|
|
954
|
+
return ` paths=${a.paths.length}`;
|
|
955
|
+
if (Array.isArray(a.objects))
|
|
956
|
+
return ` objects=${a.objects.length}`;
|
|
957
|
+
if (typeof a.generation === "string")
|
|
958
|
+
return ` gen=${a.generation}`;
|
|
959
|
+
if (typeof a.repo_id === "string")
|
|
960
|
+
return ` repo=${a.repo_id}`;
|
|
961
|
+
return "";
|
|
962
|
+
};
|
|
963
|
+
const describeResult = (result) => {
|
|
964
|
+
if (result instanceof Uint8Array)
|
|
965
|
+
return ` bytes=${result.length}`;
|
|
966
|
+
if (Array.isArray(result)) {
|
|
967
|
+
const total = result.reduce((sum, v) => sum + (v instanceof Uint8Array ? v.length : 0), 0);
|
|
968
|
+
return total > 0 ? ` bytes=${total}` : "";
|
|
969
|
+
}
|
|
970
|
+
return "";
|
|
971
|
+
};
|
|
972
|
+
return new Proxy(inner, {
|
|
973
|
+
get(target, prop, receiver) {
|
|
974
|
+
const value = Reflect.get(target, prop, receiver);
|
|
975
|
+
if (typeof value !== "function" || typeof prop !== "string")
|
|
976
|
+
return value;
|
|
977
|
+
return async (...args) => {
|
|
978
|
+
const start = Date.now();
|
|
979
|
+
try {
|
|
980
|
+
const result = await Reflect.apply(value, target, args);
|
|
981
|
+
const elapsed = Date.now() - start;
|
|
982
|
+
opCount += 1;
|
|
983
|
+
totalMs += elapsed;
|
|
984
|
+
process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])}${describeResult(result)} ${elapsed}ms\n`);
|
|
985
|
+
return result;
|
|
986
|
+
}
|
|
987
|
+
catch (e) {
|
|
988
|
+
const elapsed = Date.now() - start;
|
|
989
|
+
opCount += 1;
|
|
990
|
+
totalMs += elapsed;
|
|
991
|
+
process.stderr.write(`gitvault-trace: ${prop}${describeRequest(args[0])} FAILED ${elapsed}ms\n`);
|
|
992
|
+
throw e;
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
},
|
|
996
|
+
});
|
|
851
997
|
}
|
|
852
998
|
// ─── Storage paths (§3) ──────────────────────────────────────────────────────
|
|
853
999
|
export const gitvaultPaths = {
|
|
@@ -875,6 +1021,39 @@ export const gitvaultPaths = {
|
|
|
875
1021
|
/** `recipient-pins/<pin_manifest_version>.json` (D197, rev 42) — version-addressed, plaintext-structured, writer-signed. */
|
|
876
1022
|
pinManifest: (pinManifestVersion) => `recipient-pins/${pinManifestVersion}.json`,
|
|
877
1023
|
};
|
|
1024
|
+
// ─── Incremental restore marker (gitvault-client-round-trips design D5) ──────
|
|
1025
|
+
//
|
|
1026
|
+
// `restored_through` — the newest generation whose WAL packs this TARGET
|
|
1027
|
+
// DIRECTORY has fully applied — is local git state, same store as the
|
|
1028
|
+
// id-pin (`git config --local`), scoped to the directory `restoreObjectsInto`
|
|
1029
|
+
// actually wrote into (never `process.cwd()`, and never scoped to `repo_id`:
|
|
1030
|
+
// a re-pointed remote's marker simply fails its `head_sha256` comparison and
|
|
1031
|
+
// falls back to wholesale, safely, since that comparison is a content hash).
|
|
1032
|
+
const GITVAULT_RESTORE_MARKER_GENERATION_KEY = "r402.restoredThrough";
|
|
1033
|
+
const GITVAULT_RESTORE_MARKER_SHA256_KEY = "r402.restoredThroughSha256";
|
|
1034
|
+
async function readLocalGitConfigValue(dir, key) {
|
|
1035
|
+
try {
|
|
1036
|
+
const out = (await hardenedGit(dir, ["config", "--local", "--get", key])).text().trim();
|
|
1037
|
+
return out.length > 0 ? out : null;
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
// Absent key or not a repository at all — either way, "nothing marked" is the correct read.
|
|
1041
|
+
return null;
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
/** Read this target directory's `restored_through` marker, or `null` when nothing was ever restored into it. */
|
|
1045
|
+
export async function readGitvaultRestoreMarker(targetRepoDir) {
|
|
1046
|
+
const generation = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_GENERATION_KEY);
|
|
1047
|
+
const head_sha256 = await readLocalGitConfigValue(targetRepoDir, GITVAULT_RESTORE_MARKER_SHA256_KEY);
|
|
1048
|
+
if (!generation || !head_sha256)
|
|
1049
|
+
return null;
|
|
1050
|
+
return { generation, head_sha256 };
|
|
1051
|
+
}
|
|
1052
|
+
/** Advance the marker — called only after a restore's coverage verification succeeds. */
|
|
1053
|
+
async function writeGitvaultRestoreMarker(targetRepoDir, generation, headSha256) {
|
|
1054
|
+
await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_GENERATION_KEY, generation]);
|
|
1055
|
+
await hardenedGit(targetRepoDir, ["config", "--local", GITVAULT_RESTORE_MARKER_SHA256_KEY, headSha256]);
|
|
1056
|
+
}
|
|
878
1057
|
/** A transport-agnostic view of git ops the publication needs (the local repository). */
|
|
879
1058
|
export class GitvaultVault {
|
|
880
1059
|
keystore;
|
|
@@ -932,7 +1111,16 @@ export class GitvaultVault {
|
|
|
932
1111
|
if (this.genesisCache)
|
|
933
1112
|
return this.genesisCache;
|
|
934
1113
|
const repo = this.repoFile();
|
|
935
|
-
|
|
1114
|
+
// Design D3: genesis is one small, immutable object per vault — cached
|
|
1115
|
+
// forever beside the keystore's per-repo state, re-verified against the
|
|
1116
|
+
// pinned `genesis_sha256` on every use exactly like a network read.
|
|
1117
|
+
const cached = this.keystore.readCachedGenesis(this.repoId);
|
|
1118
|
+
let bytes = cached && sha256Hex(cached.bytes) === repo.genesis_sha256 ? cached.bytes : null;
|
|
1119
|
+
if (!bytes) {
|
|
1120
|
+
bytes = await this.transport.getGenesis({ repo_id: this.repoId });
|
|
1121
|
+
if (bytes && sha256Hex(bytes) === repo.genesis_sha256)
|
|
1122
|
+
this.keystore.writeCachedGenesis(this.repoId, repo.genesis_sha256, bytes);
|
|
1123
|
+
}
|
|
936
1124
|
if (!bytes)
|
|
937
1125
|
fail("CHAIN_BROKEN", "the vault has no admitted genesis", "reading gitvault genesis", { repo_id: this.repoId });
|
|
938
1126
|
const sha256 = sha256Hex(bytes);
|
|
@@ -1063,8 +1251,10 @@ export class GitvaultVault {
|
|
|
1063
1251
|
fail("GITVAULT_EPOCH_NOT_OPENABLE", `no locally known key for epoch ${head.epoch} at generation ${head.generation}`, "materializing gitvault head", { epoch: head.epoch, generation: head.generation });
|
|
1064
1252
|
}
|
|
1065
1253
|
const kRepo = hexToBytes(kRepoHex);
|
|
1066
|
-
|
|
1067
|
-
|
|
1254
|
+
// Design D2: ref_state + retention_roots both ride `head`, so one
|
|
1255
|
+
// batched presign (or a cache hit) serves both instead of two
|
|
1256
|
+
// independent presign-then-GET round trips.
|
|
1257
|
+
const { refState, roots } = await this.openMaterializeCarriers(head.ref_state, gitvaultPaths.refState(head.ref_state.object_id), head.retention_roots, gitvaultPaths.retentionRoots(head.retention_roots.object_id), writerKey, { epoch: head.epoch, k_repo: kRepo });
|
|
1068
1258
|
if (refState.generation !== head.generation || roots.generation !== head.generation)
|
|
1069
1259
|
fail("CHAIN_UNUSABLE", "carrier generation does not match the head", "materializing gitvault head");
|
|
1070
1260
|
decryptedRefState = refState;
|
|
@@ -1091,7 +1281,7 @@ export class GitvaultVault {
|
|
|
1091
1281
|
? `${verified} heads verified this call; the verified prefix (generation ${pin.generation}) is persisted — call again to continue`
|
|
1092
1282
|
: `${verified} heads verified this call in no-write mode; nothing was persisted — a retry restarts from the original pin, not generation ${pin.generation}`, "verifying gitvault chain", { verified_through: pin.generation, persisted: persist }, [{ action: persist ? "resume verification from the persisted verified prefix" : "re-run without --no-write to persist and resume incrementally, or re-run this same audit call again from the start" }]);
|
|
1093
1283
|
}
|
|
1094
|
-
const bytes = await this.
|
|
1284
|
+
const bytes = await this.readCachedHeadBytes(entry.generation, entry.stored_bytes_sha256);
|
|
1095
1285
|
if (!bytes)
|
|
1096
1286
|
fail("CHAIN_BROKEN", `listed head ${entry.generation} is absent from storage`, "verifying gitvault chain", { generation: entry.generation });
|
|
1097
1287
|
const head = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
@@ -1167,7 +1357,11 @@ export class GitvaultVault {
|
|
|
1167
1357
|
break;
|
|
1168
1358
|
}
|
|
1169
1359
|
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
|
|
1170
|
-
|
|
1360
|
+
// Design D3: this downward walk is rooted in `lastHead`, itself just
|
|
1361
|
+
// chain-verified above (or trusted from an earlier call's own
|
|
1362
|
+
// verification) — the same "freshness already established" argument
|
|
1363
|
+
// {@link readCachedHeadBytes} documents, so a cache hit is safe here too.
|
|
1364
|
+
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
1171
1365
|
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
1172
1366
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during epoch-key catch-up`, "materializing gitvault head", { generation: prevGen });
|
|
1173
1367
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
@@ -1214,6 +1408,41 @@ export class GitvaultVault {
|
|
|
1214
1408
|
: null,
|
|
1215
1409
|
};
|
|
1216
1410
|
}
|
|
1411
|
+
/**
|
|
1412
|
+
* Read one NEWLY-LISTED head's raw bytes, trying the local cache first
|
|
1413
|
+
* (design D3), re-verified against `expectedSha256` — the SAME check
|
|
1414
|
+
* network bytes get. Safe to cache-serve ONLY because a caller here
|
|
1415
|
+
* always supplies a hash a FRESH `listHeads` call just reported as
|
|
1416
|
+
* current — the cache never substitutes for that freshness check, it just
|
|
1417
|
+
* avoids re-downloading bytes a live listing already vouched for. A cache
|
|
1418
|
+
* miss or a hash mismatch (never trusted, always falls through) fetches
|
|
1419
|
+
* from the network and, on a match, refreshes the cache entry. Returns
|
|
1420
|
+
* whatever the network returned on a final miss too (including `null`) —
|
|
1421
|
+
* callers keep their own existing absent/mismatch handling unchanged.
|
|
1422
|
+
*
|
|
1423
|
+
* Deliberately NOT used by {@link readHead}: that call verifies the
|
|
1424
|
+
* PINNED generation is STILL held by the server, with no fresh listing
|
|
1425
|
+
* involved — its entire purpose is detecting server-side loss/rollback,
|
|
1426
|
+
* which a cache read can never observe. That call always goes live.
|
|
1427
|
+
*/
|
|
1428
|
+
async readCachedHeadBytes(generation, expectedSha256) {
|
|
1429
|
+
const cached = this.keystore.readCachedHead(this.repoId, generation);
|
|
1430
|
+
if (cached && sha256Hex(cached.bytes) === expectedSha256)
|
|
1431
|
+
return cached.bytes;
|
|
1432
|
+
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
1433
|
+
if (bytes && sha256Hex(bytes) === expectedSha256)
|
|
1434
|
+
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
|
|
1435
|
+
return bytes;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Confirm the server STILL holds the pinned generation, unchanged —
|
|
1439
|
+
* ALWAYS a live network read (see {@link readCachedHeadBytes}'s doc
|
|
1440
|
+
* comment for why this specific check is not cacheable: it exists to
|
|
1441
|
+
* detect server-side loss, which a cache can never observe). A
|
|
1442
|
+
* successful read still WARMS the cache afterward — later reads of this
|
|
1443
|
+
* SAME generation via the chain walk or restore benefit from it; only
|
|
1444
|
+
* THIS call's own read is exempt.
|
|
1445
|
+
*/
|
|
1217
1446
|
async readHead(generation, expectedSha256) {
|
|
1218
1447
|
const bytes = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(generation) });
|
|
1219
1448
|
// Absent ⇒ the vault no longer holds a generation this client authenticated: a ROLLBACK, not a
|
|
@@ -1225,21 +1454,25 @@ export class GitvaultVault {
|
|
|
1225
1454
|
}
|
|
1226
1455
|
if (sha256Hex(bytes) !== expectedSha256)
|
|
1227
1456
|
fail("CHAIN_BROKEN", `pinned head ${generation} no longer hashes to the pin`, "reading pinned gitvault head", { generation });
|
|
1457
|
+
this.keystore.writeCachedHead(this.repoId, generation, expectedSha256, bytes);
|
|
1228
1458
|
return parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
1229
1459
|
}
|
|
1230
1460
|
/**
|
|
1231
|
-
* Decrypt one
|
|
1232
|
-
* `CHAIN_UNUSABLE`.
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
1237
|
-
*
|
|
1461
|
+
* Decrypt + verify one carrier's already-fetched ciphertext frame; any
|
|
1462
|
+
* failure is `CHAIN_UNUSABLE`. Split out of {@link openCarrier} so a
|
|
1463
|
+
* caller that fetched the frame itself (a cache hit, or one leg of a
|
|
1464
|
+
* batched read) can reuse the same decode + identity/signature checks.
|
|
1465
|
+
*
|
|
1466
|
+
* `keyOverride` supplies the exact `(epoch, k_repo)` this carrier was
|
|
1467
|
+
* sealed under — REQUIRED for any generation that is not necessarily
|
|
1468
|
+
* under `this.epoch()`/`this.kRepo()` (this principal's CURRENT
|
|
1469
|
+
* pointer), which is exactly the case across an epoch rotation; omitted
|
|
1470
|
+
* call sites (checkpoint/prune paths untouched by this fold) keep the
|
|
1471
|
+
* prior CURRENT-pointer behavior unchanged.
|
|
1238
1472
|
*/
|
|
1239
|
-
|
|
1473
|
+
decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride) {
|
|
1240
1474
|
const epoch = keyOverride?.epoch ?? this.epoch();
|
|
1241
1475
|
const kRepo = keyOverride?.k_repo ?? this.kRepo();
|
|
1242
|
-
const frame = await this.transport.getObject({ repo_id: this.repoId, path });
|
|
1243
1476
|
if (!frame)
|
|
1244
1477
|
fail("CHAIN_UNUSABLE", `${kind} ${receipt.object_id} is absent from storage`, "materializing gitvault head", { object_id: receipt.object_id }, [{ action: "stay read-only at the materialized pin; run the repair path" }]);
|
|
1245
1478
|
let plaintext;
|
|
@@ -1255,6 +1488,59 @@ export class GitvaultVault {
|
|
|
1255
1488
|
}
|
|
1256
1489
|
return object;
|
|
1257
1490
|
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Fetch (network) + decrypt one carrier object by its receipt; any
|
|
1493
|
+
* failure is `CHAIN_UNUSABLE`. Design D3: `ref_state`/`retention_roots`
|
|
1494
|
+
* ciphertext is cached beside the keystore's per-repo state, re-verified
|
|
1495
|
+
* against `receipt.ciphertext_sha256` on every use — a hit skips the
|
|
1496
|
+
* network read entirely. `checkpoint_manifest` is never cached (outside
|
|
1497
|
+
* D3's table). The cache write derives its generation tag from the
|
|
1498
|
+
* DECODED object's own `generation` field, so no caller needs to thread
|
|
1499
|
+
* one through by hand.
|
|
1500
|
+
*/
|
|
1501
|
+
async openCarrier(kind, receipt, path, writerKey, keyOverride) {
|
|
1502
|
+
const cacheable = kind === "ref_state" || kind === "retention_roots";
|
|
1503
|
+
const cached = cacheable ? this.keystore.readCachedCarrier(this.repoId, receipt.object_id) : null;
|
|
1504
|
+
const cacheHit = cached && sha256Hex(cached.bytes) === receipt.ciphertext_sha256;
|
|
1505
|
+
const frame = cacheHit ? cached.bytes : await this.transport.getObject({ repo_id: this.repoId, path });
|
|
1506
|
+
const object = this.decodeCarrierFrame(kind, receipt, frame, writerKey, keyOverride);
|
|
1507
|
+
if (cacheable && !cacheHit && frame && typeof object.generation === "string") {
|
|
1508
|
+
this.keystore.writeCachedCarrier(this.repoId, receipt.object_id, object.generation, receipt.ciphertext_sha256, frame);
|
|
1509
|
+
}
|
|
1510
|
+
return object;
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* `materialize`'s ref_state + retention_roots read, batched (design D2):
|
|
1514
|
+
* cache-check both first, then ONE `getObjects` call (one presigned batch
|
|
1515
|
+
* + concurrent GETs) for whichever missed, instead of two independent
|
|
1516
|
+
* presign-then-GET round trips. Falls through to zero network calls when
|
|
1517
|
+
* both are cache-warm. `keyOverride` is the epoch/k_repo the CARRYING
|
|
1518
|
+
* HEAD sealed both carriers under (D194) — both always share one head, so
|
|
1519
|
+
* one override serves both, unlike the WAL/checkpoint pack loops in
|
|
1520
|
+
* {@link restoreObjectsInto} which span many heads and many epochs.
|
|
1521
|
+
*/
|
|
1522
|
+
async openMaterializeCarriers(refStateReceipt, refStatePath, rootsReceipt, rootsPath, writerKey, keyOverride) {
|
|
1523
|
+
const cachedRefState = this.keystore.readCachedCarrier(this.repoId, refStateReceipt.object_id);
|
|
1524
|
+
const cachedRoots = this.keystore.readCachedCarrier(this.repoId, rootsReceipt.object_id);
|
|
1525
|
+
const refStateHit = Boolean(cachedRefState && sha256Hex(cachedRefState.bytes) === refStateReceipt.ciphertext_sha256);
|
|
1526
|
+
const rootsHit = Boolean(cachedRoots && sha256Hex(cachedRoots.bytes) === rootsReceipt.ciphertext_sha256);
|
|
1527
|
+
const missingPaths = [];
|
|
1528
|
+
if (!refStateHit)
|
|
1529
|
+
missingPaths.push(refStatePath);
|
|
1530
|
+
if (!rootsHit)
|
|
1531
|
+
missingPaths.push(rootsPath);
|
|
1532
|
+
const fetched = missingPaths.length > 0 ? await this.transport.getObjects({ repo_id: this.repoId, paths: missingPaths }) : [];
|
|
1533
|
+
let next = 0;
|
|
1534
|
+
const refStateFrame = refStateHit ? cachedRefState.bytes : (fetched[next++] ?? null);
|
|
1535
|
+
const rootsFrame = rootsHit ? cachedRoots.bytes : (fetched[next++] ?? null);
|
|
1536
|
+
const refState = this.decodeCarrierFrame("ref_state", refStateReceipt, refStateFrame, writerKey, keyOverride);
|
|
1537
|
+
const roots = this.decodeCarrierFrame("retention_roots", rootsReceipt, rootsFrame, writerKey, keyOverride);
|
|
1538
|
+
if (!refStateHit && refStateFrame)
|
|
1539
|
+
this.keystore.writeCachedCarrier(this.repoId, refStateReceipt.object_id, refState.generation, refStateReceipt.ciphertext_sha256, refStateFrame);
|
|
1540
|
+
if (!rootsHit && rootsFrame)
|
|
1541
|
+
this.keystore.writeCachedCarrier(this.repoId, rootsReceipt.object_id, roots.generation, rootsReceipt.ciphertext_sha256, rootsFrame);
|
|
1542
|
+
return { refState, roots };
|
|
1543
|
+
}
|
|
1258
1544
|
/**
|
|
1259
1545
|
* Verify to newest, then decrypt + apply its carriers — advancing the
|
|
1260
1546
|
* materialized pin. `options.persist` (default `true`) is forwarded to
|
|
@@ -1281,7 +1567,10 @@ export class GitvaultVault {
|
|
|
1281
1567
|
}
|
|
1282
1568
|
// `strict: true` guarantees `state.decrypt` is non-null with no failure
|
|
1283
1569
|
// and `decryptable_to_generation === state.generation` — it throws
|
|
1284
|
-
// otherwise, so these are never null/mismatched here.
|
|
1570
|
+
// otherwise, so these are never null/mismatched here. `verifyToNewest`'s
|
|
1571
|
+
// own `tryDecrypt` is what actually fetches these now (design D2/D3's
|
|
1572
|
+
// batching + caching moved there with it — see its doc comment) —
|
|
1573
|
+
// `openMaterializeCarriers` no longer has a caller from here.
|
|
1285
1574
|
const refState = state.decrypt.ref_state;
|
|
1286
1575
|
const roots = state.decrypt.retention_roots;
|
|
1287
1576
|
if (refState.generation !== state.generation || roots.generation !== state.generation)
|
|
@@ -1838,11 +2127,18 @@ export class GitvaultVault {
|
|
|
1838
2127
|
return { outcome: "conflict", generation, winner: admitted.winner };
|
|
1839
2128
|
return { outcome: "admitted", generation, head, head_sha256: admitted.head_sha256, admission_record_sha256: admitted.admission_record_sha256, capture_receipt: admitted.capture_receipt, form, refs: input.refs };
|
|
1840
2129
|
}
|
|
1841
|
-
/**
|
|
2130
|
+
/**
|
|
2131
|
+
* The complete push: verify → materialize → evaluate → pack → upload → head → admit (409: re-apply to the winner, retry) → read back → advance pins.
|
|
2132
|
+
*
|
|
2133
|
+
* `options.base` (design D1), when supplied, is used VERBATIM for the
|
|
2134
|
+
* first attempt instead of a fresh {@link materialize} call — a conflict
|
|
2135
|
+
* retry always re-materializes from storage, exactly as when no base is
|
|
2136
|
+
* supplied.
|
|
2137
|
+
*/
|
|
1842
2138
|
async push(options) {
|
|
1843
2139
|
let conflicts = 0;
|
|
2140
|
+
let base = options.base ?? (await this.materialize());
|
|
1844
2141
|
for (;;) {
|
|
1845
|
-
const base = await this.materialize();
|
|
1846
2142
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
1847
2143
|
const published = await this.publishGeneration({
|
|
1848
2144
|
base, refs: evaluation.refs, dropped: evaluation.dropped, head_target: options.head_target ?? base.head_target,
|
|
@@ -1852,7 +2148,8 @@ export class GitvaultVault {
|
|
|
1852
2148
|
conflicts += 1;
|
|
1853
2149
|
if (conflicts > this.retries)
|
|
1854
2150
|
fail("HEAD_CAS_CONFLICT", `admission lost ${conflicts} races at generation ${published.generation}; giving up`, "publishing gitvault head", { generation: published.generation, winner: published.winner }, [{ action: "verify the attached winner from storage, rebase, retry" }]);
|
|
1855
|
-
|
|
2151
|
+
base = await this.materialize(); // re-verify from storage (the winner) before re-applying the transaction
|
|
2152
|
+
continue;
|
|
1856
2153
|
}
|
|
1857
2154
|
// `push()` never sets `dry_run`, so this outcome is unreachable here —
|
|
1858
2155
|
// narrows `published` to `"admitted"` for the return below.
|
|
@@ -1882,7 +2179,11 @@ export class GitvaultVault {
|
|
|
1882
2179
|
* that case instead of calling this method).
|
|
1883
2180
|
*/
|
|
1884
2181
|
async planPush(options) {
|
|
1885
|
-
|
|
2182
|
+
// Design D1: a caller-supplied base reports against the CALLER's own
|
|
2183
|
+
// observed snapshot instead of materializing a fresh one — same
|
|
2184
|
+
// verbatim-first-attempt contract as `push`, minus the retry loop
|
|
2185
|
+
// (a dry run never re-materializes; there is nothing to retry against).
|
|
2186
|
+
const base = options.base ?? (await this.materialize());
|
|
1886
2187
|
const evaluation = await evaluateRefTransaction(base.refs, options.transaction, { isAncestor: (a, d) => isAncestor(this.git(), a, d), protocol_refs: options.protocol_refs });
|
|
1887
2188
|
const headTarget = options.head_target ?? base.head_target;
|
|
1888
2189
|
const published = await this.publishGeneration({
|
|
@@ -1995,10 +2296,18 @@ export class GitvaultVault {
|
|
|
1995
2296
|
const result = await this.transport.admitHead({ repo_id: this.repoId, generation: head.generation, stored_bytes: bytes, stored_bytes_sha256: hash });
|
|
1996
2297
|
if (result.outcome === "conflict")
|
|
1997
2298
|
return result;
|
|
2299
|
+
// Post-admit readback is a spec-mandated verification obligation, never
|
|
2300
|
+
// a cache lookup — it exists to prove the SERVER stored what was sent,
|
|
2301
|
+
// which only a genuine network read can answer.
|
|
1998
2302
|
const back = await this.transport.getObject({ repo_id: this.repoId, path: gitvaultPaths.head(head.generation) });
|
|
1999
2303
|
if (!back || sha256Hex(back) !== hash) {
|
|
2000
2304
|
fail("GITVAULT_HEAD_READBACK_MISMATCH", `the admitted head at generation ${head.generation} read back ${back ? sha256Hex(back) : "absent"} ≠ ${hash}; the push is NOT reported as landed and no pin advances`, "reading back admitted head", { generation: head.generation, expected: hash, observed: back ? sha256Hex(back) : null });
|
|
2001
2305
|
}
|
|
2306
|
+
// Design D3: the readback just network-confirmed these exact bytes —
|
|
2307
|
+
// warm the cache with them so the NEXT operation (this session's own
|
|
2308
|
+
// `materialize` retry, or a later invocation) can skip re-fetching this
|
|
2309
|
+
// generation's head entirely.
|
|
2310
|
+
this.keystore.writeCachedHead(this.repoId, head.generation, hash, back);
|
|
2002
2311
|
const pin = { generation: head.generation, head_sha256: hash, pinned_at: formatGitvaultTimestamp(this.now()) };
|
|
2003
2312
|
this.keystore.updateRepo(this.repoId, { head_pin: pin, materialized_pin: pin, verified_prefix: null });
|
|
2004
2313
|
return { outcome: "admitted", head_sha256: hash, admission_record_sha256: result.admission_record_sha256, capture_receipt: result.capture_receipt };
|
|
@@ -2561,6 +2870,20 @@ export class GitvaultVault {
|
|
|
2561
2870
|
* Pull the newest checkpoint (if any) and every later WAL pack into
|
|
2562
2871
|
* `targetRepoDir` (an initialized repository), then verify every canonical
|
|
2563
2872
|
* ref + the HEAD target resolves. Returns the materialized refs.
|
|
2873
|
+
*
|
|
2874
|
+
* Design D5 (gitvault-client-round-trips): incremental above the local
|
|
2875
|
+
* `restored_through` marker (this target directory's own local git
|
|
2876
|
+
* config, read/written by {@link readGitvaultRestoreMarker}/{@link
|
|
2877
|
+
* writeGitvaultRestoreMarker}) — replaying only the WAL packs of
|
|
2878
|
+
* generations above it — WHEN the walk from newest back to the marker is
|
|
2879
|
+
* plain WAL the entire way. The moment that walk crosses a
|
|
2880
|
+
* checkpoint-bearing, repair, or transition head (checked BEFORE
|
|
2881
|
+
* including a head, so the marker-boundary and wholesale-boundary checks
|
|
2882
|
+
* share one pass), this falls back to the ORIGINAL wholesale walk below —
|
|
2883
|
+
* re-fetching any head already visited during the aborted attempt is a
|
|
2884
|
+
* cache hit (design D3), never a second network round trip. Coverage
|
|
2885
|
+
* verification and retained-refs reconciliation run UNCHANGED on both
|
|
2886
|
+
* paths; the marker only advances after they both succeed.
|
|
2564
2887
|
*/
|
|
2565
2888
|
async restoreObjectsInto(targetRepoDir) {
|
|
2566
2889
|
const newest = await this.materialize();
|
|
@@ -2569,17 +2892,51 @@ export class GitvaultVault {
|
|
|
2569
2892
|
return { refs: {}, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2570
2893
|
}
|
|
2571
2894
|
const writerKey = newest.genesis.creator_signing_pubkey;
|
|
2572
|
-
|
|
2895
|
+
const marker = await readGitvaultRestoreMarker(targetRepoDir);
|
|
2896
|
+
// Already up to date locally: no pack fetch, not even a walk — only the
|
|
2897
|
+
// (entirely local) coverage + retained-refs bookkeeping below runs.
|
|
2898
|
+
if (marker && marker.generation === newest.generation && marker.head_sha256 === newest.head_sha256) {
|
|
2899
|
+
for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
|
|
2900
|
+
if (!(await hasObject(targetRepoDir, t)))
|
|
2901
|
+
fail("CHAIN_UNUSABLE", `covered tip ${t} does not resolve after restore`, "restoring gitvault objects", { oid: t });
|
|
2902
|
+
}
|
|
2903
|
+
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
2904
|
+
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2905
|
+
}
|
|
2906
|
+
// Walk back from newest, trying for an INCREMENTAL boundary at the
|
|
2907
|
+
// marker (a pure local prev_sha256 comparison — no fetch needed to
|
|
2908
|
+
// CONFIRM the boundary itself); abandon incremental the moment a
|
|
2909
|
+
// non-plain-WAL head would have to be included, and restart as the
|
|
2910
|
+
// ORIGINAL wholesale walk (stopping at the newest checkpoint, or
|
|
2911
|
+
// genesis) — every head visited in the aborted attempt is a D3 cache
|
|
2912
|
+
// hit on this restart, so abandoning costs no extra round trip.
|
|
2573
2913
|
const heads = [];
|
|
2574
2914
|
let cur = newest.head;
|
|
2915
|
+
let incremental = marker !== null;
|
|
2575
2916
|
while (cur) {
|
|
2917
|
+
// `cur` disqualifies incremental (checked BEFORE including it): abort
|
|
2918
|
+
// and restart as the wholesale walk. Re-visiting heads already fetched
|
|
2919
|
+
// this attempt costs nothing (D3 cache hits).
|
|
2920
|
+
if (incremental && (cur.checkpoint !== null || cur.transition !== null)) {
|
|
2921
|
+
incremental = false;
|
|
2922
|
+
heads.length = 0;
|
|
2923
|
+
cur = newest.head;
|
|
2924
|
+
continue;
|
|
2925
|
+
}
|
|
2576
2926
|
heads.unshift(cur);
|
|
2577
|
-
if (cur.checkpoint)
|
|
2578
|
-
break;
|
|
2927
|
+
if (!incremental && cur.checkpoint)
|
|
2928
|
+
break; // wholesale stop: the newest checkpoint-bearing head
|
|
2579
2929
|
if (cur.generation === "0000000000000001")
|
|
2580
|
-
break;
|
|
2930
|
+
break; // genesis — a hard stop either way
|
|
2931
|
+
// Before fetching `cur`'s predecessor, check whether the marker
|
|
2932
|
+
// already names it — a pure LOCAL comparison against bytes this
|
|
2933
|
+
// client already applied last time, no network round trip.
|
|
2581
2934
|
const prevGen = bigIntToGeneration(generationToBigInt(cur.generation) - 1n);
|
|
2582
|
-
|
|
2935
|
+
if (incremental && cur.prev_sha256 === marker.head_sha256 && prevGen === marker.generation) {
|
|
2936
|
+
cur = null; // the predecessor is the marker's own head — already applied; `heads` already holds everything above it
|
|
2937
|
+
break;
|
|
2938
|
+
}
|
|
2939
|
+
const bytes = await this.readCachedHeadBytes(prevGen, cur.prev_sha256);
|
|
2583
2940
|
if (!bytes || sha256Hex(bytes) !== cur.prev_sha256)
|
|
2584
2941
|
fail("CHAIN_BROKEN", `head ${prevGen} does not match the chain during restore`, "restoring gitvault objects");
|
|
2585
2942
|
cur = parseGitvaultStrict(new TextDecoder().decode(bytes));
|
|
@@ -2608,8 +2965,14 @@ export class GitvaultVault {
|
|
|
2608
2965
|
fail("CHECKPOINT_INCOMPLETE", "claim set signature fails", "restoring gitvault objects");
|
|
2609
2966
|
const manifest = await this.openCarrier("checkpoint_manifest", claimSet.manifest_receipt, gitvaultPaths.checkpointManifest(claimSet.manifest_receipt.object_id), writerKey, { epoch: first.epoch, k_repo: kRepoForEpoch(first.epoch) });
|
|
2610
2967
|
checkClaimSetEquality(claimSet, manifest, first.checkpoint.covers_through_generation);
|
|
2611
|
-
|
|
2612
|
-
|
|
2968
|
+
// Design D2: every checkpoint pack is independent — one batched
|
|
2969
|
+
// presign for all of them, THEN applied via index-pack strictly in
|
|
2970
|
+
// manifest order (the fetch is concurrent; the local git write is
|
|
2971
|
+
// not, and does not need to be).
|
|
2972
|
+
const frames = await this.transport.getObjects({ repo_id: this.repoId, paths: manifest.packs.map((p) => gitvaultPaths.checkpointPack(p.object_id)) });
|
|
2973
|
+
for (let i = 0; i < manifest.packs.length; i++) {
|
|
2974
|
+
const p = manifest.packs[i];
|
|
2975
|
+
const frame = frames[i] ?? null;
|
|
2613
2976
|
if (!frame)
|
|
2614
2977
|
fail("CHECKPOINT_INCOMPLETE", `checkpoint pack ${p.object_id} absent`, "restoring gitvault objects");
|
|
2615
2978
|
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(first.epoch), this.repoId, first.epoch, "checkpoint_pack", p.object_id), repo_id: this.repoId, object_kind: "checkpoint_pack", object_id: p.object_id, epoch: first.epoch, frame, expected_ciphertext_sha256: p.ciphertext_sha256 });
|
|
@@ -2618,14 +2981,24 @@ export class GitvaultVault {
|
|
|
2618
2981
|
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
2619
2982
|
}
|
|
2620
2983
|
}
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2984
|
+
// Design D2: every WAL pack across every head in this restore's range is
|
|
2985
|
+
// independent — one batched presign for the whole set (this is the
|
|
2986
|
+
// "restore pack set" the design's own D2 prose names alongside
|
|
2987
|
+
// materialize's carriers), fetched concurrently, then applied via
|
|
2988
|
+
// index-pack in the SAME chain order the wholesale path always used
|
|
2989
|
+
// (git's pack application is sequential; the network fetch need not be).
|
|
2990
|
+
// Each entry decrypts under its OWN carrying head's epoch (D194) — the
|
|
2991
|
+
// flattened list keeps that pairing so a rotation-spanning restore never
|
|
2992
|
+
// reuses one head's epoch for another's pack.
|
|
2993
|
+
const walEntries = heads.flatMap((h) => h.wal_entries.map((w) => ({ w, epoch: h.epoch })));
|
|
2994
|
+
const walFrames = await this.transport.getObjects({ repo_id: this.repoId, paths: walEntries.map(({ w }) => gitvaultPaths.wal(w.object_id)) });
|
|
2995
|
+
for (let i = 0; i < walEntries.length; i++) {
|
|
2996
|
+
const { w, epoch } = walEntries[i];
|
|
2997
|
+
const frame = walFrames[i] ?? null;
|
|
2998
|
+
if (!frame)
|
|
2999
|
+
fail("CHAIN_UNUSABLE", `WAL pack ${w.object_id} absent`, "restoring gitvault objects");
|
|
3000
|
+
const plain = openFrame({ k_obj: deriveObjectKey(kRepoForEpoch(epoch), this.repoId, epoch, "wal_pack", w.object_id), repo_id: this.repoId, object_kind: "wal_pack", object_id: w.object_id, epoch, frame, expected_ciphertext_sha256: w.ciphertext_sha256 });
|
|
3001
|
+
await hardenedGit(targetRepoDir, ["index-pack", "--stdin", "--strict"], { input: plain });
|
|
2629
3002
|
}
|
|
2630
3003
|
// the §4.7 coverage set — canonical refs ∪ unexpired roots ∪ the HEAD target — must all resolve.
|
|
2631
3004
|
for (const t of GitvaultVault.coverageTips(newest.refs, newest.roots, newest.head_target)) {
|
|
@@ -2637,6 +3010,12 @@ export class GitvaultVault {
|
|
|
2637
3010
|
// ref so `git fsck` is silent. Runs AFTER coverage verification so a
|
|
2638
3011
|
// reconcile never references an object the restore itself failed to land.
|
|
2639
3012
|
const retained_refs = await reconcileRetainedTipRefs(targetRepoDir, { refs: newest.refs, roots: newest.roots, head_target: newest.head_target });
|
|
3013
|
+
// Design D5: the marker advances ONLY here — after coverage verification
|
|
3014
|
+
// and the retained-refs reconcile both succeeded. An interrupted restore
|
|
3015
|
+
// (a crash or throw anywhere above) leaves the marker unadvanced, so the
|
|
3016
|
+
// next fetch simply repeats this same range; applying an already-applied
|
|
3017
|
+
// WAL pack again is the existing, already-safe wholesale behavior.
|
|
3018
|
+
await writeGitvaultRestoreMarker(targetRepoDir, newest.generation, newest.head_sha256);
|
|
2640
3019
|
return { refs: newest.refs, head_target: newest.head_target, generation: newest.generation, retained_refs };
|
|
2641
3020
|
}
|
|
2642
3021
|
}
|