@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,213 @@
1
+ import type { AdvisoryLockHandle } from "@checkstack/backend-api";
2
+ import { extractErrorMessage } from "@checkstack/common";
3
+ import type { BlobStore } from "./blob-store";
4
+ import { blobSha256 } from "./blob-hash";
5
+
6
+ /**
7
+ * Storage-migration job: copy every blob from the active backend to a
8
+ * target backend, verifying each copy byte-for-byte, then atomically flip
9
+ * the active backend.
10
+ *
11
+ * Built on the Phase 2 blob-store registry's dual-backend read fallback:
12
+ * while migrating, `script_package_storage_config.activeBackend` is still
13
+ * the source, and reads fall back across both backends, so script execution
14
+ * never breaks. Per-blob `backend` is flipped only AFTER a verified copy, so
15
+ * a crash leaves a well-defined partial state that a resumed run re-derives
16
+ * from the index (blobs already on the target are skipped).
17
+ *
18
+ * Verification note: blob bytes are our content-addressed archive (a gzip
19
+ * tar of the Bun cache entry), NOT the npm tarball, so the SRI `integrity`
20
+ * key does not hash the stored bytes. Verification therefore checks the
21
+ * *copy* is faithful: hash the source bytes, hash the bytes read back from
22
+ * the target, and require they match. A mismatch aborts the migration
23
+ * cleanly (the active backend is left untouched).
24
+ *
25
+ * All collaborators are injected so the job is unit-testable without a DB
26
+ * or real object storage.
27
+ */
28
+
29
+ export interface StorageMigrationStores {
30
+ /** Blobs whose recorded backend != target (the resumable work set). */
31
+ listNotOnBackend(
32
+ target: string,
33
+ ): Promise<{ integrity: string; backend: string }[]>;
34
+ /** Flip a blob's recorded backend after a verified copy. */
35
+ setBackend(integrity: string, backend: string): Promise<void>;
36
+ }
37
+
38
+ export interface StorageMigrationConfig {
39
+ beginMigration(target: string): Promise<void>;
40
+ setMigratedCount(count: number): Promise<void>;
41
+ completeMigration(target: string): Promise<void>;
42
+ failMigration(message: string): Promise<void>;
43
+ }
44
+
45
+ export interface StorageMigrationDeps {
46
+ blobIndex: StorageMigrationStores;
47
+ storage: StorageMigrationConfig;
48
+ /** Resolve a registered blob store by id (source + target). */
49
+ getStore(id: string): BlobStore;
50
+ /** Active backend id at job start (the migration source). */
51
+ activeBackend: string;
52
+ target: string;
53
+ /** When true, delete each blob from the source after a verified copy. */
54
+ gcSource?: boolean;
55
+ logger?: { debug(msg: string): void; error(msg: string): void };
56
+ }
57
+
58
+ export interface StorageMigrationResult {
59
+ migrated: number;
60
+ /** True when every blob is verified on the target and the flip happened. */
61
+ completed: boolean;
62
+ error?: string;
63
+ }
64
+
65
+ /**
66
+ * Run (or resume) a storage migration. Idempotent + resumable: the work set
67
+ * is derived from the index each run, so blobs already flipped to the target
68
+ * are skipped. Returns a result describing what happened; never throws for
69
+ * an ordinary migration failure (it records `failMigration` and returns).
70
+ */
71
+ export async function runStorageMigration(
72
+ deps: StorageMigrationDeps,
73
+ ): Promise<StorageMigrationResult> {
74
+ const { blobIndex, storage, target, activeBackend } = deps;
75
+
76
+ if (target === activeBackend) {
77
+ return { migrated: 0, completed: true };
78
+ }
79
+
80
+ const targetStore = deps.getStore(target);
81
+ await storage.beginMigration(target);
82
+
83
+ let migrated = 0;
84
+ try {
85
+ const pending = await blobIndex.listNotOnBackend(target);
86
+ for (const { integrity, backend } of pending) {
87
+ // If the target already has it (idempotent re-run / prior partial),
88
+ // just flip the index. Otherwise copy from the blob's current backend.
89
+ let bytes: Uint8Array | undefined;
90
+ if (await targetStore.has({ integrity })) {
91
+ bytes = await targetStore.get({ integrity });
92
+ } else {
93
+ const sourceStore = deps.getStore(backend);
94
+ const source = await sourceStore.get({ integrity });
95
+ if (source === undefined) {
96
+ throw new Error(
97
+ `Blob ${integrity} missing from its source backend "${backend}"; cannot migrate.`,
98
+ );
99
+ }
100
+ await targetStore.put({ integrity, bytes: source });
101
+ // Read back from the target and verify the copy byte-for-byte.
102
+ const readBack = await targetStore.get({ integrity });
103
+ if (
104
+ readBack === undefined ||
105
+ blobSha256(readBack) !== blobSha256(source)
106
+ ) {
107
+ throw new Error(
108
+ `Integrity verification failed for ${integrity}: target copy does not match source.`,
109
+ );
110
+ }
111
+ bytes = source;
112
+
113
+ if (deps.gcSource && backend !== target) {
114
+ await sourceStore
115
+ .delete({ integrity })
116
+ .catch(() => {
117
+ // GC is best-effort; a leftover source blob is harmless.
118
+ });
119
+ }
120
+ }
121
+ void bytes;
122
+ await blobIndex.setBackend(integrity, target);
123
+ migrated++;
124
+ await storage.setMigratedCount(migrated);
125
+ }
126
+
127
+ await storage.completeMigration(target);
128
+ deps.logger?.debug(
129
+ `Storage migration to "${target}" complete (${migrated} blob(s)).`,
130
+ );
131
+ return { migrated, completed: true };
132
+ } catch (error) {
133
+ const message = extractErrorMessage(error);
134
+ await storage.failMigration(message);
135
+ deps.logger?.error(`Storage migration to "${target}" failed: ${message}`);
136
+ return { migrated, completed: false, error: message };
137
+ }
138
+ }
139
+
140
+ /** Current persisted migration state, as read on boot. */
141
+ export interface MigrationStateSnapshot {
142
+ migrationStatus: string;
143
+ migrationTarget: string | null;
144
+ activeBackend: string;
145
+ }
146
+
147
+ export interface ResumeCrashedMigrationDeps {
148
+ /** Read the current migration state (status + target + active backend). */
149
+ loadState(): Promise<MigrationStateSnapshot>;
150
+ /** Acquire the installer-election lock (so only one pod resumes). */
151
+ tryLock(): Promise<AdvisoryLockHandle | null>;
152
+ /**
153
+ * Relaunch the migration toward `target`. Resolves when the (idempotent,
154
+ * resumable) migration finishes. The caller controls whether this runs in
155
+ * the background.
156
+ */
157
+ runMigration(input: {
158
+ target: string;
159
+ activeBackend: string;
160
+ }): Promise<unknown>;
161
+ logger?: { debug(msg: string): void; error(msg: string): void };
162
+ }
163
+
164
+ export interface ResumeCrashedMigrationResult {
165
+ /** True when a resume was launched (status was "migrating", lock won). */
166
+ resumed: boolean;
167
+ reason?: string;
168
+ /**
169
+ * When `resumed`, a promise that settles once the relaunched migration
170
+ * finishes and the lock is released. Production code fires-and-forgets;
171
+ * tests can await it for deterministic assertions.
172
+ */
173
+ done?: Promise<void>;
174
+ }
175
+
176
+ /**
177
+ * Boot-time backstop: if a prior migration crashed mid-flight it left
178
+ * `migrationStatus === "migrating"`, which wedges the system (installs are
179
+ * blocked and `triggerMigration` refuses to restart). Detect that and
180
+ * relaunch the resumable migration under the installer-election lock, so
181
+ * exactly one pod resumes. Returns immediately if there is nothing to resume
182
+ * or another pod holds the lock; the actual migration may run in the
183
+ * background (controlled by `runMigration`). The lock is released when
184
+ * `runMigration` settles.
185
+ */
186
+ export async function resumeCrashedMigration(
187
+ deps: ResumeCrashedMigrationDeps,
188
+ ): Promise<ResumeCrashedMigrationResult> {
189
+ const state = await deps.loadState();
190
+ if (state.migrationStatus !== "migrating" || !state.migrationTarget) {
191
+ return { resumed: false, reason: "no migration in flight" };
192
+ }
193
+ const target = state.migrationTarget;
194
+
195
+ const lock = await deps.tryLock();
196
+ if (!lock) {
197
+ return { resumed: false, reason: "another instance holds the lock" };
198
+ }
199
+
200
+ deps.logger?.debug(`Resuming crashed storage migration toward "${target}".`);
201
+ const done = (async () => {
202
+ try {
203
+ await deps.runMigration({ target, activeBackend: state.activeBackend });
204
+ } catch (error) {
205
+ deps.logger?.error(
206
+ `Storage-migration resume failed: ${extractErrorMessage(error)}`,
207
+ );
208
+ } finally {
209
+ await lock.release();
210
+ }
211
+ })();
212
+ return { resumed: true, done };
213
+ }