@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,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import {
4
+ resolveDataDir,
5
+ resolveScriptPackagesDir,
6
+ storePaths,
7
+ } from "./data-dir";
8
+
9
+ describe("resolveDataDir", () => {
10
+ test("uses CHECKSTACK_DATA_DIR when set", () => {
11
+ expect(resolveDataDir({ CHECKSTACK_DATA_DIR: "/srv/cs" })).toBe("/srv/cs");
12
+ });
13
+
14
+ test("defaults to .data under cwd when unset", () => {
15
+ expect(resolveDataDir({})).toBe(path.resolve(process.cwd(), ".data"));
16
+ });
17
+
18
+ test("ignores a blank value", () => {
19
+ expect(resolveDataDir({ CHECKSTACK_DATA_DIR: " " })).toBe(
20
+ path.resolve(process.cwd(), ".data"),
21
+ );
22
+ });
23
+ });
24
+
25
+ describe("resolveScriptPackagesDir", () => {
26
+ test("nests script-packages under the data dir", () => {
27
+ expect(resolveScriptPackagesDir({ CHECKSTACK_DATA_DIR: "/srv/cs" })).toBe(
28
+ "/srv/cs/script-packages",
29
+ );
30
+ });
31
+ });
32
+
33
+ describe("storePaths", () => {
34
+ test("derives the cache / trees / current / runs subpaths", () => {
35
+ const p = storePaths("/srv/cs/script-packages");
36
+ expect(p.cache).toBe("/srv/cs/script-packages/cache");
37
+ expect(p.trees).toBe("/srv/cs/script-packages/trees");
38
+ expect(p.current).toBe("/srv/cs/script-packages/current");
39
+ expect(p.runs).toBe("/srv/cs/script-packages/.runs");
40
+ });
41
+ });
@@ -0,0 +1,42 @@
1
+ import path from "node:path";
2
+
3
+ /**
4
+ * Resolve the on-disk root for the managed package store on this host.
5
+ *
6
+ * No general backend data-dir convention exists in the repo yet, so we
7
+ * introduce `CHECKSTACK_DATA_DIR` (env), defaulting to a `.data/` dir under
8
+ * the backend's CWD. Script packages live at `<dataDir>/script-packages/`.
9
+ *
10
+ * The store contains a generated `package.json`, `bun.lock`, the
11
+ * content-addressed cache, versioned `trees/<lockfileHash>/`, the `current`
12
+ * symlink, and `.runs/<uuid>/` scratch dirs for runner resolution.
13
+ */
14
+ export function resolveDataDir(env: NodeJS.ProcessEnv = process.env): string {
15
+ const configured = env.CHECKSTACK_DATA_DIR;
16
+ if (configured && configured.trim().length > 0) {
17
+ return path.resolve(configured);
18
+ }
19
+ return path.resolve(process.cwd(), ".data");
20
+ }
21
+
22
+ /** `<dataDir>/script-packages` - the managed package store root. */
23
+ export function resolveScriptPackagesDir(
24
+ env: NodeJS.ProcessEnv = process.env,
25
+ ): string {
26
+ return path.join(resolveDataDir(env), "script-packages");
27
+ }
28
+
29
+ /** Subpaths inside the package store. */
30
+ export function storePaths(storeRoot: string) {
31
+ return {
32
+ root: storeRoot,
33
+ /** Content-addressed blob cache (compressed blobs keyed by integrity). */
34
+ cache: path.join(storeRoot, "cache"),
35
+ /** Versioned materialized trees: `trees/<lockfileHash>/node_modules`. */
36
+ trees: path.join(storeRoot, "trees"),
37
+ /** Atomically-flipped symlink to the active tree. */
38
+ current: path.join(storeRoot, "current"),
39
+ /** Per-run scratch dirs for runner resolution. */
40
+ runs: path.join(storeRoot, ".runs"),
41
+ };
42
+ }
@@ -0,0 +1,121 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { defaultEsmScriptRunner } from "@checkstack/backend-api";
6
+ import { performInstall, type BlobIndex } from "./install-service";
7
+ import { createCentralResolver } from "./resolver";
8
+ import { reconcileToHash } from "./reconciler";
9
+ import { createReconcileFsDeps } from "./reconcile-fs";
10
+ import { storePaths } from "./data-dir";
11
+
12
+ /**
13
+ * End-to-end proof of the central-store + core-reconciliation pipeline
14
+ * against a real (tiny, pure-JS) package:
15
+ *
16
+ * resolve+install -> publish per-package blobs -> reconcile (delta pull
17
+ * from the "blob store") -> bun install --offline materialize -> atomic
18
+ * `current` flip -> a user script imports the package via resolutionRoot.
19
+ *
20
+ * Network is only touched once (the central resolve); the reconcile side
21
+ * pulls from the in-memory blob store, never the registry.
22
+ */
23
+ // This test reaches the public npm registry once (the central resolve), so
24
+ // it's opt-in to avoid flakiness / failures in offline CI. Run with
25
+ // `CHECKSTACK_E2E_NETWORK=1 bun test` to exercise the full pipeline.
26
+ const E2E_ENABLED = process.env.CHECKSTACK_E2E_NETWORK === "1";
27
+
28
+ describe.skipIf(!E2E_ENABLED)("e2e: install + reconcile + import", () => {
29
+ let work: string;
30
+ // In-memory blob store standing in for the active BlobStore.
31
+ const blobs = new Map<string, Uint8Array>();
32
+
33
+ beforeAll(async () => {
34
+ work = await mkdtemp(path.join(tmpdir(), "cs-e2e-"));
35
+ });
36
+ afterAll(async () => {
37
+ await rm(work, { recursive: true, force: true });
38
+ });
39
+
40
+ test(
41
+ "resolves leftpad centrally, reconciles on a fresh host, and runs an import",
42
+ async () => {
43
+ const resolver = createCentralResolver({
44
+ scratchDir: path.join(work, "install-scratch"),
45
+ cacheDir: path.join(work, "install-cache"),
46
+ registry: {
47
+ registryUrl: "https://registry.npmjs.org/",
48
+ scopedRegistries: [],
49
+ },
50
+ });
51
+
52
+ const index: BlobIndex = { record: async () => {} };
53
+ const blobStore = {
54
+ id: "memory",
55
+ has: async ({ integrity }: { integrity: string }) =>
56
+ blobs.has(integrity),
57
+ put: async ({
58
+ integrity,
59
+ bytes,
60
+ }: {
61
+ integrity: string;
62
+ bytes: Uint8Array;
63
+ }) => {
64
+ blobs.set(integrity, bytes);
65
+ },
66
+ };
67
+
68
+ const installResult = await performInstall({
69
+ packages: [{ name: "leftpad", version: "0.0.1", enabled: true }],
70
+ ignoreScripts: true,
71
+ resolver,
72
+ blobStore,
73
+ blobIndex: index,
74
+ });
75
+
76
+ expect(installResult.manifest.length).toBeGreaterThanOrEqual(1);
77
+ expect(installResult.lockfileHash.length).toBe(64);
78
+ expect(blobs.size).toBeGreaterThanOrEqual(1);
79
+
80
+ // ── Fresh host reconcile (cold cache, pulls all blobs from the store) ──
81
+ const storeRoot = path.join(work, "host-store");
82
+ const deps = createReconcileFsDeps({
83
+ storeRoot,
84
+ fetchBlob: async ({ integrity }) => {
85
+ const bytes = blobs.get(integrity);
86
+ if (!bytes) throw new Error(`blob ${integrity} not in store`);
87
+ return bytes;
88
+ },
89
+ });
90
+
91
+ const recRes = await reconcileToHash({
92
+ lockfileHash: installResult.lockfileHash,
93
+ manifest: installResult.manifest,
94
+ deps,
95
+ });
96
+ expect(recRes.alreadyConverged).toBe(false);
97
+ expect(recRes.pulledIntegrities.length).toBe(blobs.size);
98
+
99
+ // A second reconcile is a no-op (idempotent convergence).
100
+ const again = await reconcileToHash({
101
+ lockfileHash: installResult.lockfileHash,
102
+ manifest: installResult.manifest,
103
+ deps,
104
+ });
105
+ expect(again.alreadyConverged).toBe(true);
106
+
107
+ // ── Run a user script that imports the materialized package ──
108
+ const current = storePaths(storeRoot).current;
109
+ const run = await defaultEsmScriptRunner.run({
110
+ script: `import leftpad from "leftpad";\nexport default leftpad("7", 3);`,
111
+ context: {},
112
+ timeoutMs: 20_000,
113
+ resolutionRoot: current,
114
+ });
115
+ expect(run.error).toBeUndefined();
116
+ // leftpad pads with "0" by default.
117
+ expect(run.result).toBe("007");
118
+ },
119
+ 60_000,
120
+ );
121
+ });
package/src/hooks.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { createHook } from "@checkstack/backend-api";
2
+ import {
3
+ SCRIPT_PACKAGES_CHANGED_HOOK_ID,
4
+ type ScriptPackagesChangedPayload,
5
+ } from "@checkstack/script-packages-common";
6
+
7
+ /**
8
+ * Backend hook fired by the elected installer after a successful install.
9
+ *
10
+ * Core instances subscribe in `broadcast` mode (every instance receives it,
11
+ * the deliberate inverse of installer-election) and kick their reconciler
12
+ * to delta-sync to the new `lockfileHash`. Each instance's broadcast
13
+ * handler also pushes a `RefreshScriptPackages` control message to its
14
+ * connected satellites (Phase 3). The hook is best-effort liveness; the
15
+ * durable desired state (`install_state.lockfileHash`) drives idempotent
16
+ * convergence on the next startup / assignment-sync regardless.
17
+ */
18
+ export const scriptPackagesChangedHook = createHook<ScriptPackagesChangedPayload>(
19
+ SCRIPT_PACKAGES_CHANGED_HOOK_ID,
20
+ );