@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.
- package/CHANGELOG.md +273 -0
- package/drizzle/0000_flashy_squadron_supreme.sql +63 -0
- package/drizzle/0001_flawless_drax.sql +15 -0
- package/drizzle/meta/0000_snapshot.json +395 -0
- package/drizzle/meta/0001_snapshot.json +491 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +7 -0
- package/package.json +32 -0
- package/src/atomic-symlink.test.ts +47 -0
- package/src/atomic-symlink.ts +66 -0
- package/src/blob-gc-runner.test.ts +120 -0
- package/src/blob-gc-runner.ts +139 -0
- package/src/blob-gc.test.ts +182 -0
- package/src/blob-gc.ts +161 -0
- package/src/blob-hash.test.ts +70 -0
- package/src/blob-hash.ts +56 -0
- package/src/blob-store-registry.test.ts +78 -0
- package/src/blob-store-registry.ts +75 -0
- package/src/blob-store.ts +51 -0
- package/src/cache-archive.test.ts +164 -0
- package/src/cache-archive.ts +192 -0
- package/src/cache-layout.ts +64 -0
- package/src/data-dir.test.ts +41 -0
- package/src/data-dir.ts +42 -0
- package/src/e2e-install-reconcile.test.ts +121 -0
- package/src/hooks.ts +20 -0
- package/src/index.ts +594 -0
- package/src/install-controller.test.ts +257 -0
- package/src/install-controller.ts +144 -0
- package/src/install-service.test.ts +104 -0
- package/src/install-service.ts +116 -0
- package/src/install-state-store.ts +131 -0
- package/src/lockfile.test.ts +60 -0
- package/src/lockfile.ts +0 -0
- package/src/npmrc.test.ts +48 -0
- package/src/npmrc.ts +42 -0
- package/src/package-types.test.ts +293 -0
- package/src/package-types.ts +408 -0
- package/src/parse-bun-lock.test.ts +62 -0
- package/src/parse-bun-lock.ts +59 -0
- package/src/reconcile-diff.test.ts +41 -0
- package/src/reconcile-diff.ts +26 -0
- package/src/reconcile-fs.ts +199 -0
- package/src/reconciler.test.ts +289 -0
- package/src/reconciler.ts +81 -0
- package/src/registry-client.test.ts +314 -0
- package/src/registry-client.ts +0 -0
- package/src/registry-request-config.ts +63 -0
- package/src/registry-token.test.ts +124 -0
- package/src/registry-token.ts +104 -0
- package/src/resolution-root.test.ts +82 -0
- package/src/resolution-root.ts +127 -0
- package/src/resolver.test.ts +133 -0
- package/src/resolver.ts +132 -0
- package/src/router.ts +273 -0
- package/src/schema.ts +166 -0
- package/src/size-cap.test.ts +32 -0
- package/src/size-cap.ts +40 -0
- package/src/storage-migration.test.ts +318 -0
- package/src/storage-migration.ts +213 -0
- package/src/stores.ts +533 -0
- package/src/tree-gc.test.ts +184 -0
- package/src/tree-gc.ts +160 -0
- package/src/tree-retirement.ts +81 -0
- package/src/type-acquisition-route.ts +178 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, mkdir, writeFile, rm, stat, utimes } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { sweepTreeGc } from "./tree-gc";
|
|
6
|
+
import { atomicSymlinkSwap } from "./atomic-symlink";
|
|
7
|
+
import { storePaths } from "./data-dir";
|
|
8
|
+
import { RETIRED_AT_MARKER } from "./tree-retirement";
|
|
9
|
+
|
|
10
|
+
const HOUR = 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
describe("sweepTreeGc", () => {
|
|
13
|
+
let storeRoot: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
storeRoot = await mkdtemp(path.join(tmpdir(), "cs-treegc-"));
|
|
17
|
+
});
|
|
18
|
+
afterEach(async () => {
|
|
19
|
+
await rm(storeRoot, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** Create `trees/<hash>/node_modules` and set its mtime `ageMs` in the past. */
|
|
23
|
+
async function makeTree(hash: string, ageMs: number) {
|
|
24
|
+
const paths = storePaths(storeRoot);
|
|
25
|
+
const dir = path.join(paths.trees, hash);
|
|
26
|
+
await mkdir(path.join(dir, "node_modules"), { recursive: true });
|
|
27
|
+
await writeFile(path.join(dir, "package.json"), "{}");
|
|
28
|
+
const when = new Date(Date.now() - ageMs);
|
|
29
|
+
await utimes(dir, when, when);
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Stamp a `.retired-at` marker `ageMs` in the past onto a tree dir. */
|
|
34
|
+
async function retireTree(hash: string, ageMs: number) {
|
|
35
|
+
const dir = path.join(storePaths(storeRoot).trees, hash);
|
|
36
|
+
await writeFile(
|
|
37
|
+
path.join(dir, RETIRED_AT_MARKER),
|
|
38
|
+
`${Date.now() - ageMs}\n`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function exists(dir: string): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
await stat(dir);
|
|
45
|
+
return true;
|
|
46
|
+
} catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function pointCurrentAt(hash: string) {
|
|
52
|
+
const paths = storePaths(storeRoot);
|
|
53
|
+
await atomicSymlinkSwap({
|
|
54
|
+
linkPath: paths.current,
|
|
55
|
+
target: path.join("trees", hash),
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test("never deletes the current tree, regardless of age", async () => {
|
|
60
|
+
const cur = await makeTree("hashCur", 100 * HOUR); // very old
|
|
61
|
+
await pointCurrentAt("hashCur");
|
|
62
|
+
|
|
63
|
+
const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
64
|
+
expect(res.currentHash).toBe("hashCur");
|
|
65
|
+
expect(res.deleted).toEqual([]);
|
|
66
|
+
expect(await exists(cur)).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("prunes a non-current tree retired past the grace window", async () => {
|
|
70
|
+
const cur = await makeTree("hashCur", 0);
|
|
71
|
+
const old = await makeTree("hashOld", 100 * HOUR); // ancient mtime
|
|
72
|
+
await retireTree("hashOld", 5 * HOUR); // but retired 5h ago > grace
|
|
73
|
+
await pointCurrentAt("hashCur");
|
|
74
|
+
|
|
75
|
+
const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
76
|
+
expect(res.deleted).toEqual(["hashOld"]);
|
|
77
|
+
expect(await exists(old)).toBe(false);
|
|
78
|
+
expect(await exists(cur)).toBe(true);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("keeps a non-current tree still within the grace window (live run may be pinned)", async () => {
|
|
82
|
+
const recent = await makeTree("hashRecent", 100 * HOUR); // ancient mtime
|
|
83
|
+
await retireTree("hashRecent", 0.25 * HOUR); // retired 15 min ago < grace
|
|
84
|
+
await makeTree("hashCur", 0);
|
|
85
|
+
await pointCurrentAt("hashCur");
|
|
86
|
+
|
|
87
|
+
const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
88
|
+
expect(res.deleted).toEqual([]);
|
|
89
|
+
expect(res.keptWithinGrace).toContain("hashRecent");
|
|
90
|
+
expect(await exists(recent)).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("respects the grace boundary using the injected clock", async () => {
|
|
94
|
+
await makeTree("hashCur", 0);
|
|
95
|
+
const old = path.join(storePaths(storeRoot).trees, "hashEdge");
|
|
96
|
+
await mkdir(path.join(old, "node_modules"), { recursive: true });
|
|
97
|
+
await retireTree("hashEdge", 2 * HOUR);
|
|
98
|
+
await pointCurrentAt("hashCur");
|
|
99
|
+
|
|
100
|
+
// now far in the future → well past grace-since-retirement → deleted.
|
|
101
|
+
const res = await sweepTreeGc({
|
|
102
|
+
storeRoot,
|
|
103
|
+
graceMs: HOUR,
|
|
104
|
+
now: Date.now() + 10 * HOUR,
|
|
105
|
+
});
|
|
106
|
+
expect(res.deleted).toContain("hashEdge");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("back-fills a marker for an unmarked non-current tree and retains it that pass", async () => {
|
|
110
|
+
await makeTree("hashCur", 0);
|
|
111
|
+
const orphan = await makeTree("hashOrphan", 100 * HOUR); // ancient, no marker
|
|
112
|
+
await pointCurrentAt("hashCur");
|
|
113
|
+
|
|
114
|
+
// First pass: no retirement marker → retained + back-filled.
|
|
115
|
+
const first = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
116
|
+
expect(first.deleted).toEqual([]);
|
|
117
|
+
expect(first.keptWithinGrace).toContain("hashOrphan");
|
|
118
|
+
expect(await exists(orphan)).toBe(true);
|
|
119
|
+
expect(await exists(path.join(orphan, RETIRED_AT_MARKER))).toBe(true);
|
|
120
|
+
|
|
121
|
+
// A later pass past grace (from the back-filled time) deletes it.
|
|
122
|
+
const later = await sweepTreeGc({
|
|
123
|
+
storeRoot,
|
|
124
|
+
graceMs: HOUR,
|
|
125
|
+
now: Date.now() + 10 * HOUR,
|
|
126
|
+
});
|
|
127
|
+
expect(later.deleted).toContain("hashOrphan");
|
|
128
|
+
expect(await exists(orphan)).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("no trees dir → no-op", async () => {
|
|
132
|
+
const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
133
|
+
expect(res.deleted).toEqual([]);
|
|
134
|
+
expect(res.keptWithinGrace).toEqual([]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── H3 regression: grace must key on RETIREMENT time, not materialize mtime ──
|
|
138
|
+
//
|
|
139
|
+
// A tree that was `current` for days has an old mtime. The instant it is
|
|
140
|
+
// superseded by a flip it would (under the old mtime-keyed logic) be
|
|
141
|
+
// INSTANTLY eligible — and sweepTreeGc runs right after a flip, so it would
|
|
142
|
+
// delete a tree an in-flight run is still pinned to. The grace window must
|
|
143
|
+
// start ticking from when the tree BECAME non-current, not from its mtime.
|
|
144
|
+
|
|
145
|
+
test("retains a long-current tree the instant it is superseded by a flip", async () => {
|
|
146
|
+
// Tree A has been current for ~days (very old mtime).
|
|
147
|
+
const treeA = await makeTree("hashA", 100 * HOUR);
|
|
148
|
+
// Tree B has just been materialized.
|
|
149
|
+
await makeTree("hashB", 0);
|
|
150
|
+
|
|
151
|
+
// Flip current A -> ... -> B (A becomes non-current right now).
|
|
152
|
+
await pointCurrentAt("hashA");
|
|
153
|
+
await pointCurrentAt("hashB");
|
|
154
|
+
|
|
155
|
+
// Immediately sweep. A's mtime is ancient, but it only just retired, so it
|
|
156
|
+
// MUST be retained (a run started before the flip is still pinned to it).
|
|
157
|
+
const res = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
158
|
+
expect(res.currentHash).toBe("hashB");
|
|
159
|
+
expect(res.deleted).toEqual([]);
|
|
160
|
+
expect(res.keptWithinGrace).toContain("hashA");
|
|
161
|
+
expect(await exists(treeA)).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("deletes a superseded tree only once grace-since-RETIREMENT elapses", async () => {
|
|
165
|
+
const treeA = await makeTree("hashA", 100 * HOUR);
|
|
166
|
+
await makeTree("hashB", 0);
|
|
167
|
+
await pointCurrentAt("hashA");
|
|
168
|
+
await pointCurrentAt("hashB"); // A retires now
|
|
169
|
+
|
|
170
|
+
// Within grace from retirement → retained.
|
|
171
|
+
const within = await sweepTreeGc({ storeRoot, graceMs: HOUR });
|
|
172
|
+
expect(within.deleted).toEqual([]);
|
|
173
|
+
expect(await exists(treeA)).toBe(true);
|
|
174
|
+
|
|
175
|
+
// Well past grace from retirement (injected clock) → deleted.
|
|
176
|
+
const after = await sweepTreeGc({
|
|
177
|
+
storeRoot,
|
|
178
|
+
graceMs: HOUR,
|
|
179
|
+
now: Date.now() + 10 * HOUR,
|
|
180
|
+
});
|
|
181
|
+
expect(after.deleted).toContain("hashA");
|
|
182
|
+
expect(await exists(treeA)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
package/src/tree-gc.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readdir, stat, rm } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { DEFAULT_TREE_GC_GRACE_MS } from "@checkstack/script-packages-common";
|
|
4
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
5
|
+
import { storePaths } from "./data-dir";
|
|
6
|
+
import { readCurrentTarget } from "./atomic-symlink";
|
|
7
|
+
import { markTreeRetired, readTreeRetiredAt } from "./tree-retirement";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Host-local tree garbage collection: prune materialized
|
|
11
|
+
* `trees/<lockfileHash>/` dirs whose hash is NOT the one `current` points at,
|
|
12
|
+
* after a grace period. Applies identically to core pods and satellites (the
|
|
13
|
+
* materialize/flip code is shared, so this sweep is too).
|
|
14
|
+
*
|
|
15
|
+
* ## Why the conservative grace window (active-run safety)
|
|
16
|
+
*
|
|
17
|
+
* The runner pins in-flight runs to the tree they STARTED on by snapshotting
|
|
18
|
+
* `resolutionRoot` (which dereferences `<store>/current` at run start). After
|
|
19
|
+
* an atomic flip, a live run keeps resolving against the old tree's
|
|
20
|
+
* `node_modules`; deleting that tree mid-run would break the run.
|
|
21
|
+
*
|
|
22
|
+
* There is no robust cross-process refcount available here: runs execute in
|
|
23
|
+
* throwaway Bun subprocesses under `.runs/<uuid>/` whose `resolutionRoot`
|
|
24
|
+
* points at `current` (a symlink), not at a specific tree, and a crashed
|
|
25
|
+
* run leaves no reliable "release" signal. A naive refcount would leak on
|
|
26
|
+
* crash and could under-count, risking deletion of a live tree. So we take
|
|
27
|
+
* the **robust** option: a grace window chosen to comfortably exceed the
|
|
28
|
+
* longest possible script-run timeout. A tree only becomes eligible once it
|
|
29
|
+
* has been non-current for longer than any run could still be using it. When
|
|
30
|
+
* in doubt, we retain.
|
|
31
|
+
*
|
|
32
|
+
* ## Why grace is keyed on RETIREMENT time, not dir mtime
|
|
33
|
+
*
|
|
34
|
+
* A tree's mtime reflects when it was MATERIALIZED, not when it stopped being
|
|
35
|
+
* current. A tree that served as `current` for days has an ancient mtime, so
|
|
36
|
+
* keying the grace window on mtime makes it eligible for deletion the instant
|
|
37
|
+
* it is superseded — and this sweep runs right after a flip. That would
|
|
38
|
+
* delete a tree an in-flight run (which snapshotted `resolutionRoot` before
|
|
39
|
+
* the flip) is still pinned to. Instead the flip stamps a `.retired-at`
|
|
40
|
+
* marker into the superseded tree (see `tree-retirement.ts`), and the grace
|
|
41
|
+
* window is measured from THAT timestamp.
|
|
42
|
+
*
|
|
43
|
+
* A non-current tree with NO marker (e.g. one superseded before this scheme
|
|
44
|
+
* existed, or where the flip-time marker write failed) is RETAINED and the
|
|
45
|
+
* marker is lazily back-filled with `now`, so it ages out on subsequent
|
|
46
|
+
* sweeps instead of leaking forever — but is never deleted on a missing
|
|
47
|
+
* signal.
|
|
48
|
+
*
|
|
49
|
+
* `current`'s target is NEVER a candidate, regardless of age.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
export interface TreeGcResult {
|
|
53
|
+
/** Tree hashes deleted this pass. */
|
|
54
|
+
deleted: string[];
|
|
55
|
+
/** Non-current tree hashes kept because still within the grace window. */
|
|
56
|
+
keptWithinGrace: string[];
|
|
57
|
+
/** The hash `current` points at (never a candidate), if any. */
|
|
58
|
+
currentHash?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Sweep the `trees/` dir of one host store, deleting non-current trees older
|
|
63
|
+
* than the grace window. Idempotent and crash-safe (a partially-deleted tree
|
|
64
|
+
* is simply re-swept next pass).
|
|
65
|
+
*
|
|
66
|
+
* @param storeRoot the package-store root (`<dataDir>/script-packages`).
|
|
67
|
+
* @param graceMs grace window in ms (default 1h, > the max run timeout).
|
|
68
|
+
* @param now injectable clock for deterministic tests.
|
|
69
|
+
*/
|
|
70
|
+
export async function sweepTreeGc({
|
|
71
|
+
storeRoot,
|
|
72
|
+
graceMs = DEFAULT_TREE_GC_GRACE_MS,
|
|
73
|
+
now = Date.now(),
|
|
74
|
+
logger,
|
|
75
|
+
}: {
|
|
76
|
+
storeRoot: string;
|
|
77
|
+
graceMs?: number;
|
|
78
|
+
now?: number;
|
|
79
|
+
logger?: { debug(msg: string): void; error(msg: string): void };
|
|
80
|
+
}): Promise<TreeGcResult> {
|
|
81
|
+
const paths = storePaths(storeRoot);
|
|
82
|
+
|
|
83
|
+
// The hash `current` points at (basename of `trees/<hash>`). NEVER deleted.
|
|
84
|
+
const currentTarget = await readCurrentTarget(paths.current);
|
|
85
|
+
const currentHash = currentTarget
|
|
86
|
+
? path.basename(currentTarget)
|
|
87
|
+
: undefined;
|
|
88
|
+
|
|
89
|
+
let entries: string[];
|
|
90
|
+
try {
|
|
91
|
+
entries = await readdir(paths.trees);
|
|
92
|
+
} catch {
|
|
93
|
+
// No trees dir yet → nothing to GC.
|
|
94
|
+
return { deleted: [], keptWithinGrace: [], currentHash };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const deleted: string[] = [];
|
|
98
|
+
const keptWithinGrace: string[] = [];
|
|
99
|
+
|
|
100
|
+
for (const hash of entries) {
|
|
101
|
+
if (hash === currentHash) continue; // never delete the live tree
|
|
102
|
+
|
|
103
|
+
const treeDir = path.join(paths.trees, hash);
|
|
104
|
+
let info: Awaited<ReturnType<typeof stat>>;
|
|
105
|
+
try {
|
|
106
|
+
info = await stat(treeDir);
|
|
107
|
+
} catch {
|
|
108
|
+
continue; // vanished between readdir and stat; nothing to do
|
|
109
|
+
}
|
|
110
|
+
if (!info.isDirectory()) continue;
|
|
111
|
+
|
|
112
|
+
// Grace keyed on RETIREMENT time (the flip stamps `.retired-at` into the
|
|
113
|
+
// superseded tree), NOT the dir mtime. A long-current tree has an ancient
|
|
114
|
+
// mtime but only just retired, so mtime would make it instantly eligible
|
|
115
|
+
// and this sweep (run right after a flip) would delete a tree a live run
|
|
116
|
+
// is still pinned to.
|
|
117
|
+
const retiredAt = await readTreeRetiredAt({ treeDir });
|
|
118
|
+
if (retiredAt === undefined) {
|
|
119
|
+
// No marker yet: never delete on a missing signal. Back-fill `now` so
|
|
120
|
+
// the tree ages out on later sweeps instead of leaking forever, and
|
|
121
|
+
// retain it this pass.
|
|
122
|
+
await markTreeRetired({ treeDir, at: now });
|
|
123
|
+
keptWithinGrace.push(hash);
|
|
124
|
+
logger?.debug(
|
|
125
|
+
`Tree GC: non-current tree ${hash} has no retirement marker; ` +
|
|
126
|
+
`back-filling now and retaining this pass.`,
|
|
127
|
+
);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const ageMs = now - retiredAt;
|
|
132
|
+
if (ageMs < graceMs) {
|
|
133
|
+
keptWithinGrace.push(hash);
|
|
134
|
+
logger?.debug(
|
|
135
|
+
`Tree GC: keeping non-current tree ${hash} (retired ${Math.round(
|
|
136
|
+
ageMs / 1000,
|
|
137
|
+
)}s ago < grace ${Math.round(graceMs / 1000)}s; a run may still be pinned).`,
|
|
138
|
+
);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
try {
|
|
143
|
+
await rm(treeDir, { recursive: true, force: true });
|
|
144
|
+
deleted.push(hash);
|
|
145
|
+
logger?.debug(`Tree GC: deleted non-current tree ${hash}.`);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
logger?.error(
|
|
148
|
+
`Tree GC: failed to delete tree ${hash}: ${extractErrorMessage(
|
|
149
|
+
error,
|
|
150
|
+
)}. Retaining for the next pass.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
logger?.debug(
|
|
156
|
+
`Tree GC complete: current=${currentHash ?? "(none)"}, ${deleted.length} deleted, ${keptWithinGrace.length} kept within grace.`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
return { deleted, keptWithinGrace, currentHash };
|
|
160
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { readFile, writeFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Per-tree "became-non-current-at" sentinel.
|
|
6
|
+
*
|
|
7
|
+
* Tree GC must key its grace window on the moment a tree RETIRED (stopped
|
|
8
|
+
* being `current`), not on the tree dir's mtime. A tree that was `current`
|
|
9
|
+
* for days has an ancient mtime, so keying grace on mtime makes it eligible
|
|
10
|
+
* for deletion the instant it is superseded — while an in-flight run is still
|
|
11
|
+
* pinned to it (the runner snapshots `resolutionRoot` at run start). Keying on
|
|
12
|
+
* retirement time guarantees a tree is only collected once it has been
|
|
13
|
+
* non-current for longer than any run could still be using it.
|
|
14
|
+
*
|
|
15
|
+
* The marker is a tiny file `<treeDir>/.retired-at` holding the epoch-ms
|
|
16
|
+
* timestamp of retirement. It is written at flip time (in
|
|
17
|
+
* {@link import("./atomic-symlink").atomicSymlinkSwap}) for the tree being
|
|
18
|
+
* superseded. Tree GC reads it; a tree with no marker yet is RETAINED (and
|
|
19
|
+
* lazily back-filled — see `tree-gc.ts`) so we never delete on a missing
|
|
20
|
+
* signal.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** Sentinel file name written into a tree dir when it stops being current. */
|
|
24
|
+
export const RETIRED_AT_MARKER = ".retired-at";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record that the tree at `treeDir` became non-current at `at` (epoch ms).
|
|
28
|
+
* Best-effort and idempotent-by-overwrite; never throws (the caller is on a
|
|
29
|
+
* flip hot path where a marker write must not fail the flip).
|
|
30
|
+
*/
|
|
31
|
+
export async function markTreeRetired({
|
|
32
|
+
treeDir,
|
|
33
|
+
at,
|
|
34
|
+
}: {
|
|
35
|
+
treeDir: string;
|
|
36
|
+
at: number;
|
|
37
|
+
}): Promise<void> {
|
|
38
|
+
try {
|
|
39
|
+
await writeFile(path.join(treeDir, RETIRED_AT_MARKER), `${at}\n`, "utf8");
|
|
40
|
+
} catch {
|
|
41
|
+
// A failed marker write degrades to the "no marker" path in tree-gc,
|
|
42
|
+
// which RETAINS the tree — safe by construction.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read the retirement timestamp (epoch ms) for the tree at `treeDir`, or
|
|
48
|
+
* `undefined` when no marker exists (or it is unreadable / malformed).
|
|
49
|
+
*/
|
|
50
|
+
export async function readTreeRetiredAt({
|
|
51
|
+
treeDir,
|
|
52
|
+
}: {
|
|
53
|
+
treeDir: string;
|
|
54
|
+
}): Promise<number | undefined> {
|
|
55
|
+
let raw: string;
|
|
56
|
+
try {
|
|
57
|
+
raw = await readFile(path.join(treeDir, RETIRED_AT_MARKER), "utf8");
|
|
58
|
+
} catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
const parsed = Number.parseInt(raw.trim(), 10);
|
|
62
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* True when `treeDir` already carries a retirement marker. Used by tree-GC to
|
|
67
|
+
* decide whether to back-fill one for a tree that is non-current but has no
|
|
68
|
+
* marker yet (e.g. a tree superseded before this scheme existed).
|
|
69
|
+
*/
|
|
70
|
+
export async function hasRetirementMarker({
|
|
71
|
+
treeDir,
|
|
72
|
+
}: {
|
|
73
|
+
treeDir: string;
|
|
74
|
+
}): Promise<boolean> {
|
|
75
|
+
try {
|
|
76
|
+
await stat(path.join(treeDir, RETIRED_AT_MARKER));
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { AuthService, Logger } from "@checkstack/backend-api";
|
|
3
|
+
import {
|
|
4
|
+
parseTypeAcquisitionPath,
|
|
5
|
+
scriptPackagesAccess,
|
|
6
|
+
TYPE_ACQUISITION_PATH_PREFIX,
|
|
7
|
+
type PackageTypeClosure,
|
|
8
|
+
} from "@checkstack/script-packages-common";
|
|
9
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
10
|
+
import { resolvePackageTypeClosure } from "./package-types";
|
|
11
|
+
import { storePaths } from "./data-dir";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Raw, HTTP-cacheable route serving one package's `.d.ts` closure for editor
|
|
15
|
+
* lazy ATA. Mounted (via `rpc.registerHttpHandler`) at
|
|
16
|
+
* `/api/script-packages/types/:lockfileHash/:encodedSpecifier`.
|
|
17
|
+
*
|
|
18
|
+
* Why a raw route and not an oRPC procedure: oRPC responses in this codebase
|
|
19
|
+
* cannot set custom HTTP response headers (the Hono `/api` handler builds the
|
|
20
|
+
* RpcContext WITHOUT a `responseHeaders` bag, so a procedure can't emit
|
|
21
|
+
* `Cache-Control`). The user explicitly wants HTTP-level caching keyed to the
|
|
22
|
+
* install, so we serve a raw handler and set the headers directly.
|
|
23
|
+
*
|
|
24
|
+
* Caching: the path carries the current `lockfileHash`, and the response sets
|
|
25
|
+
* `Cache-Control: private, max-age=<1y>, immutable`. A given (hash, specifier)
|
|
26
|
+
* closure is immutable for the life of that install; a new install changes the
|
|
27
|
+
* hash, so the browser simply requests a fresh URL and never reuses the old
|
|
28
|
+
* cache entry. `private` because the registry (and thus the types) may be
|
|
29
|
+
* private.
|
|
30
|
+
*
|
|
31
|
+
* Auth: this is a raw route, so it bypasses the oRPC auto-auth middleware. We
|
|
32
|
+
* authenticate the request and enforce the same global `script-packages.read`
|
|
33
|
+
* access the (removed) oRPC procedure used. The resource has no instance-level
|
|
34
|
+
* access, so a global-access check is exactly equivalent.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/** One year. The (hash, specifier) closure is immutable for the install. */
|
|
38
|
+
const CACHE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
39
|
+
|
|
40
|
+
interface CreateTypeClosureHttpHandlerDeps {
|
|
41
|
+
auth: AuthService;
|
|
42
|
+
/** Returns the current desired lockfile hash (null = no packages). */
|
|
43
|
+
getLockfileHash: () => Promise<string | null>;
|
|
44
|
+
storeRoot: string;
|
|
45
|
+
logger: Logger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the authenticated user holds global `script-packages.read`.
|
|
50
|
+
* Services are trusted; users/applications must carry the rule (or `*`).
|
|
51
|
+
*/
|
|
52
|
+
function hasReadAccess(
|
|
53
|
+
user: Awaited<ReturnType<AuthService["authenticate"]>>,
|
|
54
|
+
): boolean {
|
|
55
|
+
if (!user) return false;
|
|
56
|
+
if (user.type === "service") return true;
|
|
57
|
+
const rules = user.accessRules ?? [];
|
|
58
|
+
return (
|
|
59
|
+
rules.includes("*") || rules.includes(scriptPackagesAccess.read.id)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function jsonResponse(body: unknown, status: number): Response {
|
|
64
|
+
return Response.json(body, { status });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createTypeClosureHttpHandler({
|
|
68
|
+
auth,
|
|
69
|
+
getLockfileHash,
|
|
70
|
+
storeRoot,
|
|
71
|
+
logger,
|
|
72
|
+
}: CreateTypeClosureHttpHandlerDeps): (req: Request) => Promise<Response> {
|
|
73
|
+
return async (req: Request): Promise<Response> => {
|
|
74
|
+
if (req.method !== "GET") {
|
|
75
|
+
return jsonResponse({ error: "Method not allowed" }, 405);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ─── Auth ─────────────────────────────────────────────────────────────
|
|
79
|
+
let user: Awaited<ReturnType<AuthService["authenticate"]>>;
|
|
80
|
+
try {
|
|
81
|
+
user = await auth.authenticate(req);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
logger.error(
|
|
84
|
+
`Type-closure auth failed: ${extractErrorMessage(error)}`,
|
|
85
|
+
);
|
|
86
|
+
return jsonResponse({ error: "Unauthorized" }, 401);
|
|
87
|
+
}
|
|
88
|
+
if (!user) return jsonResponse({ error: "Unauthorized" }, 401);
|
|
89
|
+
if (!hasReadAccess(user)) {
|
|
90
|
+
return jsonResponse({ error: "Forbidden" }, 403);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Parse path ───────────────────────────────────────────────────────
|
|
94
|
+
const pathname = new URL(req.url).pathname;
|
|
95
|
+
const prefixIndex = pathname.indexOf(TYPE_ACQUISITION_PATH_PREFIX);
|
|
96
|
+
if (prefixIndex === -1) {
|
|
97
|
+
return jsonResponse({ error: "Not found" }, 404);
|
|
98
|
+
}
|
|
99
|
+
const afterPrefix = pathname.slice(
|
|
100
|
+
prefixIndex + TYPE_ACQUISITION_PATH_PREFIX.length,
|
|
101
|
+
);
|
|
102
|
+
const parsed = parseTypeAcquisitionPath(afterPrefix);
|
|
103
|
+
if (!parsed) {
|
|
104
|
+
return jsonResponse({ error: "Bad request" }, 400);
|
|
105
|
+
}
|
|
106
|
+
const { lockfileHash, specifier } = parsed;
|
|
107
|
+
|
|
108
|
+
// ─── Hash must match the current install ──────────────────────────────
|
|
109
|
+
// We only retain the current materialized tree; an old hash's tree may be
|
|
110
|
+
// gone. A mismatch means the client's install state is stale — tell it so
|
|
111
|
+
// it refetches install state and retries against the new hash.
|
|
112
|
+
let currentHash: string | null;
|
|
113
|
+
try {
|
|
114
|
+
currentHash = await getLockfileHash();
|
|
115
|
+
} catch (error) {
|
|
116
|
+
logger.error(
|
|
117
|
+
`Type-closure install-state load failed: ${extractErrorMessage(error)}`,
|
|
118
|
+
);
|
|
119
|
+
return jsonResponse({ error: "Internal error" }, 500);
|
|
120
|
+
}
|
|
121
|
+
if (currentHash === null) {
|
|
122
|
+
// No packages configured: nothing to acquire. Cacheable empty result.
|
|
123
|
+
return cacheableClosure({
|
|
124
|
+
specifier,
|
|
125
|
+
files: [],
|
|
126
|
+
hasOwnTypes: false,
|
|
127
|
+
hasAtTypes: false,
|
|
128
|
+
notFound: true,
|
|
129
|
+
truncated: false,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
if (currentHash !== lockfileHash) {
|
|
133
|
+
// Don't cache a stale-hash miss (no immutable guarantee).
|
|
134
|
+
return jsonResponse(
|
|
135
|
+
{ error: "Stale lockfile hash; refetch install state." },
|
|
136
|
+
409,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ─── Resolve the closure off the materialized tree ────────────────────
|
|
141
|
+
const nodeModulesDir = path.join(
|
|
142
|
+
storePaths(storeRoot).trees,
|
|
143
|
+
lockfileHash,
|
|
144
|
+
"node_modules",
|
|
145
|
+
);
|
|
146
|
+
let closure: PackageTypeClosure;
|
|
147
|
+
try {
|
|
148
|
+
closure = await resolvePackageTypeClosure({
|
|
149
|
+
nodeModulesDir,
|
|
150
|
+
specifier,
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
logger.error(
|
|
154
|
+
`Type-closure resolution failed for "${specifier}": ${extractErrorMessage(
|
|
155
|
+
error,
|
|
156
|
+
)}`,
|
|
157
|
+
);
|
|
158
|
+
return jsonResponse({ error: "Internal error" }, 500);
|
|
159
|
+
}
|
|
160
|
+
if (closure.truncated) {
|
|
161
|
+
logger.warn(
|
|
162
|
+
`Type closure for "${specifier}" (${lockfileHash}) hit the size ceiling; some declarations were dropped.`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return cacheableClosure(closure);
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function cacheableClosure(closure: PackageTypeClosure): Response {
|
|
170
|
+
return Response.json(closure, {
|
|
171
|
+
status: 200,
|
|
172
|
+
headers: {
|
|
173
|
+
// Per-install immutable: the (hash, specifier) closure never changes
|
|
174
|
+
// for the life of the install. `private` because types may be private.
|
|
175
|
+
"Cache-Control": `private, max-age=${CACHE_MAX_AGE_SECONDS}, immutable`,
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@checkstack/tsconfig/backend.json",
|
|
3
|
+
"include": [
|
|
4
|
+
"src"
|
|
5
|
+
],
|
|
6
|
+
"references": [
|
|
7
|
+
{
|
|
8
|
+
"path": "../backend-api"
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
"path": "../common"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"path": "../script-packages-common"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"path": "../secrets-backend"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "../secrets-common"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|