@checkstack/script-packages-backend 0.2.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +273 -0
  2. package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
  3. package/drizzle/0001_flawless_drax.sql +15 -0
  4. package/drizzle/meta/0000_snapshot.json +395 -0
  5. package/drizzle/meta/0001_snapshot.json +491 -0
  6. package/drizzle/meta/_journal.json +20 -0
  7. package/drizzle.config.ts +7 -0
  8. package/package.json +32 -0
  9. package/src/atomic-symlink.test.ts +47 -0
  10. package/src/atomic-symlink.ts +66 -0
  11. package/src/blob-gc-runner.test.ts +120 -0
  12. package/src/blob-gc-runner.ts +139 -0
  13. package/src/blob-gc.test.ts +182 -0
  14. package/src/blob-gc.ts +161 -0
  15. package/src/blob-hash.test.ts +70 -0
  16. package/src/blob-hash.ts +56 -0
  17. package/src/blob-store-registry.test.ts +78 -0
  18. package/src/blob-store-registry.ts +75 -0
  19. package/src/blob-store.ts +51 -0
  20. package/src/cache-archive.test.ts +164 -0
  21. package/src/cache-archive.ts +192 -0
  22. package/src/cache-layout.ts +64 -0
  23. package/src/data-dir.test.ts +41 -0
  24. package/src/data-dir.ts +42 -0
  25. package/src/e2e-install-reconcile.test.ts +121 -0
  26. package/src/hooks.ts +20 -0
  27. package/src/index.ts +594 -0
  28. package/src/install-controller.test.ts +257 -0
  29. package/src/install-controller.ts +144 -0
  30. package/src/install-service.test.ts +104 -0
  31. package/src/install-service.ts +116 -0
  32. package/src/install-state-store.ts +131 -0
  33. package/src/lockfile.test.ts +60 -0
  34. package/src/lockfile.ts +0 -0
  35. package/src/npmrc.test.ts +48 -0
  36. package/src/npmrc.ts +42 -0
  37. package/src/package-types.test.ts +293 -0
  38. package/src/package-types.ts +408 -0
  39. package/src/parse-bun-lock.test.ts +62 -0
  40. package/src/parse-bun-lock.ts +59 -0
  41. package/src/reconcile-diff.test.ts +41 -0
  42. package/src/reconcile-diff.ts +26 -0
  43. package/src/reconcile-fs.ts +199 -0
  44. package/src/reconciler.test.ts +289 -0
  45. package/src/reconciler.ts +81 -0
  46. package/src/registry-client.test.ts +314 -0
  47. package/src/registry-client.ts +0 -0
  48. package/src/registry-request-config.ts +63 -0
  49. package/src/registry-token.test.ts +124 -0
  50. package/src/registry-token.ts +104 -0
  51. package/src/resolution-root.test.ts +82 -0
  52. package/src/resolution-root.ts +127 -0
  53. package/src/resolver.test.ts +133 -0
  54. package/src/resolver.ts +132 -0
  55. package/src/router.ts +273 -0
  56. package/src/schema.ts +166 -0
  57. package/src/size-cap.test.ts +32 -0
  58. package/src/size-cap.ts +40 -0
  59. package/src/storage-migration.test.ts +318 -0
  60. package/src/storage-migration.ts +213 -0
  61. package/src/stores.ts +533 -0
  62. package/src/tree-gc.test.ts +184 -0
  63. package/src/tree-gc.ts +160 -0
  64. package/src/tree-retirement.ts +81 -0
  65. package/src/type-acquisition-route.ts +178 -0
  66. package/tsconfig.json +23 -0
@@ -0,0 +1,199 @@
1
+ import { spawn } from "bun";
2
+ import { mkdir, writeFile, readdir, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
5
+ import type { ReconcileDeps } from "./reconciler";
6
+ import { unpackInto } from "./cache-archive";
7
+ import { verifyBlobSha256 } from "./blob-hash";
8
+ import { atomicSymlinkSwap, readCurrentTarget } from "./atomic-symlink";
9
+ import { storePaths } from "./data-dir";
10
+ import { sweepTreeGc } from "./tree-gc";
11
+ import { extractErrorMessage } from "@checkstack/common";
12
+
13
+ /**
14
+ * Concrete filesystem/Bun adapter for the host {@link ReconcileDeps}.
15
+ *
16
+ * Layout under the store root:
17
+ * - `cache/` Bun cache (blobs extracted here)
18
+ * - `cache/.cs-integrity/` marker files (hex integrity) tracking which
19
+ * blobs are seeded - the cache dir names are
20
+ * `name@version@@@n`, not integrities, so we
21
+ * track presence out-of-band for delta diffing
22
+ * - `trees/<lockfileHash>/` a materialized install (package.json + the
23
+ * `bun install --offline` node_modules)
24
+ * - `current -> trees/<hash>`atomically-flipped symlink the runner's
25
+ * `resolutionRoot` points at
26
+ *
27
+ * Materialize seeds nothing itself - the reconciler has already extracted
28
+ * the needed blobs into `cache/`. It writes a package.json whose deps are
29
+ * the manifest's top-level set, runs `bun install --offline` (zero network,
30
+ * reconstructs node_modules from the warm cache), then atomically flips
31
+ * `current`.
32
+ */
33
+
34
+ const INTEGRITY_MARKER_DIR = ".cs-integrity";
35
+
36
+ function integrityMarkerName(integrity: string): string {
37
+ return Buffer.from(integrity, "utf8").toString("hex");
38
+ }
39
+
40
+ function bunfigDisableAutoInstall(): string {
41
+ return '[install]\nauto = "disable"\n';
42
+ }
43
+
44
+ export function createReconcileFsDeps(input: {
45
+ storeRoot: string;
46
+ /** Fetch a blob (shared store on core; HTTP on satellite). */
47
+ fetchBlob: (input: { integrity: string }) => Promise<Uint8Array>;
48
+ /**
49
+ * Tree-GC grace window (ms). Defaults inside {@link sweepTreeGc} to a value
50
+ * comfortably exceeding the max run timeout so a live run pinned to an
51
+ * old tree is never deleted. Injectable for tests.
52
+ */
53
+ treeGcGraceMs?: number;
54
+ logger?: { debug(msg: string): void; error(msg: string): void };
55
+ }): ReconcileDeps {
56
+ const paths = storePaths(input.storeRoot);
57
+ const markerDir = path.join(paths.cache, INTEGRITY_MARKER_DIR);
58
+
59
+ return {
60
+ logger: input.logger,
61
+
62
+ async localCacheIntegrities() {
63
+ try {
64
+ const hexes = await readdir(markerDir);
65
+ return hexes.map((hex) =>
66
+ Buffer.from(hex, "hex").toString("utf8"),
67
+ );
68
+ } catch {
69
+ return [];
70
+ }
71
+ },
72
+
73
+ fetchBlob: input.fetchBlob,
74
+
75
+ async seedBlob({ entry, bytes }) {
76
+ // Verify the transported bytes against the manifest's recorded blob
77
+ // hash BEFORE extracting. On a mismatch, discard + error clearly so a
78
+ // corrupt/tampered blob is never materialized. (Entries published
79
+ // before `blobSha256` skip verification — backward-safe.)
80
+ const verdict = verifyBlobSha256({ entry, bytes });
81
+ if (!verdict.ok) {
82
+ throw new Error(
83
+ `Blob integrity check failed for ${entry.name}@${entry.version} ` +
84
+ `(${entry.integrity}): expected sha256 ${verdict.expected}, got ` +
85
+ `${verdict.actual}. Refusing to seed.`,
86
+ );
87
+ }
88
+ await mkdir(paths.cache, { recursive: true });
89
+ await unpackInto({ targetDir: paths.cache, bytes });
90
+ await mkdir(markerDir, { recursive: true });
91
+ await writeFile(
92
+ path.join(markerDir, integrityMarkerName(entry.integrity)),
93
+ entry.integrity,
94
+ );
95
+ },
96
+
97
+ async currentLockfileHash() {
98
+ const target = await readCurrentTarget(paths.current);
99
+ if (!target) return;
100
+ // target is `trees/<hash>`; the hash is the basename.
101
+ return path.basename(target);
102
+ },
103
+
104
+ async materializeAndFlip({ lockfileHash, manifest }) {
105
+ const treeDir = path.join(paths.trees, lockfileHash);
106
+ // If the tree already exists with a node_modules, just (re)flip.
107
+ const alreadyBuilt = await dirExists(path.join(treeDir, "node_modules"));
108
+ if (!alreadyBuilt) {
109
+ await mkdir(treeDir, { recursive: true });
110
+ await writeFile(
111
+ path.join(treeDir, "package.json"),
112
+ renderManifestPackageJson(manifest),
113
+ );
114
+ await writeFile(
115
+ path.join(treeDir, "bunfig.toml"),
116
+ bunfigDisableAutoInstall(),
117
+ );
118
+ const proc = spawn({
119
+ cmd: [
120
+ process.execPath,
121
+ "install",
122
+ "--offline",
123
+ "--no-save",
124
+ "--ignore-scripts",
125
+ ],
126
+ cwd: treeDir,
127
+ env: { ...process.env, BUN_INSTALL_CACHE_DIR: paths.cache },
128
+ stdout: "pipe",
129
+ stderr: "pipe",
130
+ });
131
+ const [stderr, exitCode] = await Promise.all([
132
+ new Response(proc.stderr).text(),
133
+ proc.exited,
134
+ ]);
135
+ if (exitCode !== 0) {
136
+ throw new Error(
137
+ `bun install --offline failed for ${lockfileHash} (exit ${exitCode}): ${stderr.slice(0, 800)}`,
138
+ );
139
+ }
140
+ }
141
+ await atomicSymlinkSwap({
142
+ linkPath: paths.current,
143
+ target: path.join("trees", lockfileHash),
144
+ });
145
+
146
+ // Host-local tree GC after a successful flip: prune old non-current
147
+ // trees past the grace window. Best-effort — a sweep failure must never
148
+ // fail a reconcile (the new tree is already live). The grace window
149
+ // guards live runs still pinned to a just-superseded tree.
150
+ try {
151
+ await sweepTreeGc({
152
+ storeRoot: input.storeRoot,
153
+ graceMs: input.treeGcGraceMs,
154
+ logger: input.logger,
155
+ });
156
+ } catch (error) {
157
+ input.logger?.error(
158
+ `Tree GC after flip to ${lockfileHash} failed: ${extractErrorMessage(error)}`,
159
+ );
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ /**
166
+ * Build the package.json for a materialized tree. Pins every manifest
167
+ * package by exact version so `bun install --offline` resolves the same
168
+ * tree the installer produced (transitive deps are already in the cache).
169
+ */
170
+ function renderManifestPackageJson(manifest: ManifestEntry[]): string {
171
+ const dependencies: Record<string, string> = {};
172
+ for (const entry of manifest) {
173
+ dependencies[entry.name] = entry.version;
174
+ }
175
+ return (
176
+ JSON.stringify(
177
+ {
178
+ name: "checkstack-script-packages-tree",
179
+ private: true,
180
+ dependencies: Object.fromEntries(
181
+ Object.entries(dependencies).toSorted(([a], [b]) =>
182
+ a.localeCompare(b),
183
+ ),
184
+ ),
185
+ },
186
+ null,
187
+ 2,
188
+ ) + "\n"
189
+ );
190
+ }
191
+
192
+ async function dirExists(dir: string): Promise<boolean> {
193
+ try {
194
+ const info = await stat(dir);
195
+ return info.isDirectory();
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
@@ -0,0 +1,289 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, mkdir, writeFile, readdir, readlink, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
6
+ import { reconcileToHash, type ReconcileDeps } from "./reconciler";
7
+ import { createReconcileFsDeps } from "./reconcile-fs";
8
+ import { packDir } from "./cache-archive";
9
+ import { blobSha256 } from "./blob-hash";
10
+ import { storePaths } from "./data-dir";
11
+
12
+ const a: ManifestEntry = { name: "a", version: "1.0.0", integrity: "sha-a" };
13
+ const b: ManifestEntry = { name: "b", version: "2.0.0", integrity: "sha-b" };
14
+
15
+ function makeDeps(overrides: Partial<ReconcileDeps> = {}): {
16
+ deps: ReconcileDeps;
17
+ fetched: string[];
18
+ seeded: string[];
19
+ materialized: string[];
20
+ } {
21
+ const fetched: string[] = [];
22
+ const seeded: string[] = [];
23
+ const materialized: string[] = [];
24
+ const deps: ReconcileDeps = {
25
+ currentLockfileHash: async () => undefined,
26
+ localCacheIntegrities: async () => [],
27
+ fetchBlob: async ({ integrity }) => {
28
+ fetched.push(integrity);
29
+ return new Uint8Array([1]);
30
+ },
31
+ seedBlob: async ({ entry }) => {
32
+ seeded.push(entry.integrity);
33
+ },
34
+ materializeAndFlip: async ({ lockfileHash }) => {
35
+ materialized.push(lockfileHash);
36
+ },
37
+ ...overrides,
38
+ };
39
+ return { deps, fetched, seeded, materialized };
40
+ }
41
+
42
+ describe("reconcileToHash", () => {
43
+ test("pulls only missing blobs, then materializes + flips", async () => {
44
+ const { deps, fetched, seeded, materialized } = makeDeps({
45
+ localCacheIntegrities: async () => ["sha-a"],
46
+ });
47
+ const res = await reconcileToHash({
48
+ lockfileHash: "HASH1",
49
+ manifest: [a, b],
50
+ deps,
51
+ });
52
+ expect(fetched).toEqual(["sha-b"]); // delta
53
+ expect(seeded).toEqual(["sha-b"]);
54
+ expect(materialized).toEqual(["HASH1"]);
55
+ expect(res.alreadyConverged).toBe(false);
56
+ expect(res.pulledIntegrities).toEqual(["sha-b"]);
57
+ });
58
+
59
+ test("is a no-op when already at the desired hash", async () => {
60
+ const { deps, fetched, materialized } = makeDeps({
61
+ currentLockfileHash: async () => "HASH1",
62
+ });
63
+ const res = await reconcileToHash({
64
+ lockfileHash: "HASH1",
65
+ manifest: [a, b],
66
+ deps,
67
+ });
68
+ expect(res.alreadyConverged).toBe(true);
69
+ expect(fetched).toEqual([]);
70
+ expect(materialized).toEqual([]);
71
+ });
72
+
73
+ test("cold cache pulls the full blob set", async () => {
74
+ const { deps, fetched } = makeDeps();
75
+ await reconcileToHash({ lockfileHash: "H", manifest: [a, b], deps });
76
+ expect(fetched.sort()).toEqual(["sha-a", "sha-b"]);
77
+ });
78
+ });
79
+
80
+ describe("seedBlob blob-integrity verification (M1)", () => {
81
+ let work: string;
82
+ let blob: Uint8Array;
83
+
84
+ beforeEach(async () => {
85
+ work = await mkdtemp(path.join(tmpdir(), "cs-seed-"));
86
+ // Build a real gzip-tar blob of a fake cache entry.
87
+ const src = path.join(work, "src");
88
+ const entryDir = path.join(src, "pkg@1.0.0");
89
+ await mkdir(entryDir, { recursive: true });
90
+ await writeFile(path.join(entryDir, "index.js"), "module.exports = 1;\n");
91
+ blob = await packDir({ parentDir: src, entryName: "pkg@1.0.0" });
92
+ });
93
+ afterEach(async () => {
94
+ await rm(work, { recursive: true, force: true });
95
+ });
96
+
97
+ const entryWith = (over: Partial<ManifestEntry> = {}): ManifestEntry => ({
98
+ name: "pkg",
99
+ version: "1.0.0",
100
+ integrity: "sha512-npm-tarball-hash",
101
+ ...over,
102
+ });
103
+
104
+ test("seeds a blob whose recorded blobSha256 matches", async () => {
105
+ const storeRoot = path.join(work, "store");
106
+ const deps = createReconcileFsDeps({
107
+ storeRoot,
108
+ fetchBlob: async () => blob,
109
+ });
110
+ await deps.seedBlob({
111
+ entry: entryWith({ blobSha256: blobSha256(blob) }),
112
+ bytes: blob,
113
+ });
114
+ // Extracted into the cache.
115
+ const cacheEntries = await readdir(storePaths(storeRoot).cache);
116
+ expect(cacheEntries).toContain("pkg@1.0.0");
117
+ });
118
+
119
+ test("rejects a tampered blob and does NOT unpack it", async () => {
120
+ const storeRoot = path.join(work, "store2");
121
+ const deps = createReconcileFsDeps({
122
+ storeRoot,
123
+ fetchBlob: async () => blob,
124
+ });
125
+ const tampered = new Uint8Array(blob);
126
+ tampered[tampered.length - 1] ^= 0xff; // flip a byte
127
+
128
+ await expect(
129
+ deps.seedBlob({
130
+ // Recorded hash is of the ORIGINAL blob; tampered bytes won't match.
131
+ entry: entryWith({ blobSha256: blobSha256(blob) }),
132
+ bytes: tampered,
133
+ }),
134
+ ).rejects.toThrow(/integrity check failed/i);
135
+
136
+ // Nothing was extracted (cache dir absent or empty).
137
+ const cacheDir = storePaths(storeRoot).cache;
138
+ const entries = await readdir(cacheDir).catch(() => []);
139
+ expect(entries).not.toContain("pkg@1.0.0");
140
+ });
141
+
142
+ test("backward-safe: an entry without blobSha256 still seeds", async () => {
143
+ const storeRoot = path.join(work, "store3");
144
+ const deps = createReconcileFsDeps({
145
+ storeRoot,
146
+ fetchBlob: async () => blob,
147
+ });
148
+ await deps.seedBlob({ entry: entryWith(), bytes: blob });
149
+ const cacheEntries = await readdir(storePaths(storeRoot).cache);
150
+ expect(cacheEntries).toContain("pkg@1.0.0");
151
+ });
152
+ });
153
+
154
+ /**
155
+ * N-host (multi-pod) convergence: two hosts over SEPARATE store roots
156
+ * sharing ONE in-memory blob map (models two pods backed by one blob
157
+ * store). Both reconcile to the same lockfileHash and must converge to the
158
+ * same materialized tree; a second pass on each is idempotent.
159
+ *
160
+ * No network: the materialize uses an EMPTY manifest, so `bun install
161
+ * --offline` builds an empty tree without touching a registry. The shared
162
+ * blob map is still exercised by the delta-pull scenario below (a blob
163
+ * seeded into the shared store is pulled identically by both hosts).
164
+ */
165
+ describe("N-host reconcile convergence (shared blob store, two pods)", () => {
166
+ let work: string;
167
+
168
+ beforeEach(async () => {
169
+ work = await mkdtemp(path.join(tmpdir(), "cs-nhost-"));
170
+ });
171
+ afterEach(async () => {
172
+ await rm(work, { recursive: true, force: true });
173
+ });
174
+
175
+ /** Build a host's deps over its own store root, reading from a shared map. */
176
+ function hostDeps(storeRoot: string, sharedBlobs: Map<string, Uint8Array>) {
177
+ return createReconcileFsDeps({
178
+ storeRoot,
179
+ fetchBlob: async ({ integrity }) => {
180
+ const bytes = sharedBlobs.get(integrity);
181
+ if (!bytes) throw new Error(`blob ${integrity} not in shared store`);
182
+ return bytes;
183
+ },
184
+ });
185
+ }
186
+
187
+ test("two hosts converge to the same tree at one hash; second pass is idempotent", async () => {
188
+ // One shared blob store; two pods with independent local store roots.
189
+ const sharedBlobs = new Map<string, Uint8Array>();
190
+ const rootA = path.join(work, "podA");
191
+ const rootB = path.join(work, "podB");
192
+ const depsA = hostDeps(rootA, sharedBlobs);
193
+ const depsB = hostDeps(rootB, sharedBlobs);
194
+
195
+ const lockfileHash = "convergehash";
196
+ const manifest: ManifestEntry[] = []; // empty → offline materialize, no net
197
+
198
+ // First pass on each pod: both converge (not already-converged).
199
+ const a1 = await reconcileToHash({ lockfileHash, manifest, deps: depsA });
200
+ const b1 = await reconcileToHash({ lockfileHash, manifest, deps: depsB });
201
+ expect(a1.alreadyConverged).toBe(false);
202
+ expect(b1.alreadyConverged).toBe(false);
203
+
204
+ // Both `current` symlinks point at the SAME tree (trees/<hash>).
205
+ const targetA = await readlink(storePaths(rootA).current);
206
+ const targetB = await readlink(storePaths(rootB).current);
207
+ expect(path.basename(targetA)).toBe(lockfileHash);
208
+ expect(path.basename(targetB)).toBe(lockfileHash);
209
+ expect(path.basename(targetA)).toBe(path.basename(targetB));
210
+
211
+ // Each pod materialized the tree (node_modules present under trees/<hash>).
212
+ for (const root of [rootA, rootB]) {
213
+ const tree = path.join(storePaths(root).trees, lockfileHash);
214
+ const entries = await readdir(tree);
215
+ expect(entries).toContain("node_modules");
216
+ expect(entries).toContain("package.json");
217
+ }
218
+
219
+ // Second pass on each pod is a no-op (idempotent convergence).
220
+ const a2 = await reconcileToHash({ lockfileHash, manifest, deps: depsA });
221
+ const b2 = await reconcileToHash({ lockfileHash, manifest, deps: depsB });
222
+ expect(a2.alreadyConverged).toBe(true);
223
+ expect(b2.alreadyConverged).toBe(true);
224
+ expect(a2.pulledIntegrities).toEqual([]);
225
+ expect(b2.pulledIntegrities).toEqual([]);
226
+ }, 60_000);
227
+
228
+ test("both pods pull the SAME blob from the one shared store (delta sync)", async () => {
229
+ // Seed one real packed blob into the shared store (no network).
230
+ const src = path.join(work, "src");
231
+ const entryDir = path.join(src, "pkg@1.0.0");
232
+ await mkdir(entryDir, { recursive: true });
233
+ await writeFile(path.join(entryDir, "index.js"), "module.exports = 1;\n");
234
+ const bytes = await packDir({ parentDir: src, entryName: "pkg@1.0.0" });
235
+
236
+ const entry: ManifestEntry = {
237
+ name: "pkg",
238
+ version: "1.0.0",
239
+ integrity: "sha512-shared-blob",
240
+ blobSha256: blobSha256(bytes),
241
+ };
242
+ const sharedBlobs = new Map<string, Uint8Array>([[entry.integrity, bytes]]);
243
+
244
+ const rootA = path.join(work, "podA");
245
+ const rootB = path.join(work, "podB");
246
+
247
+ // Each pod, cold cache, seeds the missing blob — pulling the SAME bytes
248
+ // from the one shared store into its own local cache.
249
+ const fetchedA: string[] = [];
250
+ const fetchedB: string[] = [];
251
+ const depsA = createReconcileFsDeps({
252
+ storeRoot: rootA,
253
+ fetchBlob: async ({ integrity }) => {
254
+ fetchedA.push(integrity);
255
+ const b = sharedBlobs.get(integrity);
256
+ if (!b) throw new Error("missing");
257
+ return b;
258
+ },
259
+ });
260
+ const depsB = createReconcileFsDeps({
261
+ storeRoot: rootB,
262
+ fetchBlob: async ({ integrity }) => {
263
+ fetchedB.push(integrity);
264
+ const b = sharedBlobs.get(integrity);
265
+ if (!b) throw new Error("missing");
266
+ return b;
267
+ },
268
+ });
269
+
270
+ // Drive only the seeding side (delta diff + seedBlob) — both pods start
271
+ // cold, so both pull the one shared blob.
272
+ expect(await depsA.localCacheIntegrities()).toEqual([]);
273
+ expect(await depsB.localCacheIntegrities()).toEqual([]);
274
+ await depsA.seedBlob({ entry, bytes: await depsA.fetchBlob({ integrity: entry.integrity }) });
275
+ await depsB.seedBlob({ entry, bytes: await depsB.fetchBlob({ integrity: entry.integrity }) });
276
+
277
+ expect(fetchedA).toEqual([entry.integrity]);
278
+ expect(fetchedB).toEqual([entry.integrity]);
279
+
280
+ // Both pods now have the blob locally, materialized into the same cache
281
+ // entry layout from the identical shared bytes.
282
+ for (const root of [rootA, rootB]) {
283
+ const cacheEntries = await readdir(storePaths(root).cache);
284
+ expect(cacheEntries).toContain("pkg@1.0.0");
285
+ }
286
+ expect(await depsA.localCacheIntegrities()).toEqual([entry.integrity]);
287
+ expect(await depsB.localCacheIntegrities()).toEqual([entry.integrity]);
288
+ });
289
+ });
@@ -0,0 +1,81 @@
1
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
2
+ import { computeMissingBlobs } from "./reconcile-diff";
3
+
4
+ /**
5
+ * Host reconciler: converge this host's local materialized `node_modules`
6
+ * to the desired `lockfileHash`. Identical logic on every core instance and
7
+ * each satellite - only the blob transport differs (shared blob store on
8
+ * core; HTTP-via-core on satellites), injected as `fetchBlob`.
9
+ *
10
+ * Idempotent: a host already at `lockfileHash` is a no-op. Reconcile is
11
+ * triggered on startup, on the `script-packages.changed` hook (core) /
12
+ * `RefreshScriptPackages` push (satellite), and on assignment-sync
13
+ * (backstop), so a host that missed the broadcast still converges.
14
+ *
15
+ * The heavy I/O (cache seeding, `bun install --offline`, symlink flip) is
16
+ * injected so the orchestration is unit-testable; the concrete adapter
17
+ * lives in `reconcile-fs.ts`.
18
+ */
19
+
20
+ export interface ReconcileDeps {
21
+ /** Integrities already present in this host's local cache. */
22
+ localCacheIntegrities(): Promise<string[]>;
23
+ /** Fetch one blob by integrity (shared store on core; HTTP on satellite). */
24
+ fetchBlob(input: { integrity: string }): Promise<Uint8Array>;
25
+ /** Seed a fetched blob into the local cache. */
26
+ seedBlob(input: { entry: ManifestEntry; bytes: Uint8Array }): Promise<void>;
27
+ /**
28
+ * Materialize the tree for `lockfileHash` from the (now-complete) local
29
+ * cache and atomically flip `current` at it. Returns when `current`
30
+ * points at the new tree. No-op-safe if already materialized.
31
+ */
32
+ materializeAndFlip(input: {
33
+ lockfileHash: string;
34
+ manifest: ManifestEntry[];
35
+ }): Promise<void>;
36
+ /** The lockfileHash `current` already points at, if any. */
37
+ currentLockfileHash(): Promise<string | undefined>;
38
+ logger?: { debug(msg: string): void; error(msg: string): void };
39
+ }
40
+
41
+ export interface ReconcileResult {
42
+ /** True when nothing needed doing (already at the desired hash). */
43
+ alreadyConverged: boolean;
44
+ pulledIntegrities: string[];
45
+ }
46
+
47
+ export async function reconcileToHash({
48
+ lockfileHash,
49
+ manifest,
50
+ deps,
51
+ }: {
52
+ lockfileHash: string;
53
+ manifest: ManifestEntry[];
54
+ deps: ReconcileDeps;
55
+ }): Promise<ReconcileResult> {
56
+ const current = await deps.currentLockfileHash();
57
+ if (current === lockfileHash) {
58
+ deps.logger?.debug(
59
+ `Script packages already at ${lockfileHash}; reconcile is a no-op.`,
60
+ );
61
+ return { alreadyConverged: true, pulledIntegrities: [] };
62
+ }
63
+
64
+ const localIntegrities = await deps.localCacheIntegrities();
65
+ const missing = computeMissingBlobs({ manifest, localIntegrities });
66
+
67
+ for (const entry of missing) {
68
+ const bytes = await deps.fetchBlob({ integrity: entry.integrity });
69
+ await deps.seedBlob({ entry, bytes });
70
+ }
71
+
72
+ await deps.materializeAndFlip({ lockfileHash, manifest });
73
+
74
+ deps.logger?.debug(
75
+ `Reconciled script packages to ${lockfileHash} (pulled ${missing.length} blob(s)).`,
76
+ );
77
+ return {
78
+ alreadyConverged: false,
79
+ pulledIntegrities: missing.map((e) => e.integrity),
80
+ };
81
+ }