@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
package/src/schema.ts ADDED
@@ -0,0 +1,166 @@
1
+ import {
2
+ pgTable,
3
+ text,
4
+ jsonb,
5
+ integer,
6
+ bigint,
7
+ boolean,
8
+ timestamp,
9
+ index,
10
+ } from "drizzle-orm/pg-core";
11
+ import type { ManifestEntry } from "@checkstack/script-packages-common";
12
+
13
+ /**
14
+ * Drizzle schema for the script-packages plugin.
15
+ *
16
+ * Tables (per the feature plan §3.7). Singleton tables use a fixed text PK
17
+ * (`"singleton"`) so an upsert always targets the one row. Core instances
18
+ * are ephemeral and not individually addressable - they reconcile to the
19
+ * desired `lockfile_hash` without per-pod rows.
20
+ */
21
+
22
+ /** The admin-curated allowlist of pinned packages. */
23
+ export const scriptPackages = pgTable("script_packages", {
24
+ name: text("name").primaryKey(),
25
+ version: text("version").notNull(),
26
+ enabled: boolean("enabled").notNull().default(true),
27
+ addedBy: text("added_by"),
28
+ addedAt: timestamp("added_at").defaultNow().notNull(),
29
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
30
+ });
31
+
32
+ /** Singleton registry config. Auth token is a connection-store secret ref. */
33
+ export const scriptPackageRegistryConfig = pgTable(
34
+ "script_package_registry_config",
35
+ {
36
+ id: text("id").primaryKey().default("singleton"),
37
+ registryUrl: text("registry_url")
38
+ .notNull()
39
+ .default("https://registry.npmjs.org/"),
40
+ scopedRegistries: jsonb("scoped_registries")
41
+ .$type<{ scope: string; registryUrl: string }[]>()
42
+ .notNull()
43
+ .default([]),
44
+ /** Secret ref into the connection-store; never the plaintext token. */
45
+ authSecretRef: text("auth_secret_ref"),
46
+ ignoreScripts: boolean("ignore_scripts").notNull().default(true),
47
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
48
+ },
49
+ );
50
+
51
+ /** Singleton desired install state + lockfile manifest. */
52
+ export const scriptPackageInstallState = pgTable(
53
+ "script_package_install_state",
54
+ {
55
+ id: text("id").primaryKey().default("singleton"),
56
+ /** "idle" | "installing" | "ready" | "error" */
57
+ status: text("status").notNull().default("idle"),
58
+ /** Desired lockfile hash every host reconciles to. */
59
+ lockfileHash: text("lockfile_hash"),
60
+ manifest: jsonb("manifest").$type<ManifestEntry[]>().notNull().default([]),
61
+ totalSizeBytes: bigint("total_size_bytes", { mode: "number" })
62
+ .notNull()
63
+ .default(0),
64
+ lastInstalledAt: timestamp("last_installed_at"),
65
+ errorMessage: text("error_message"),
66
+ },
67
+ );
68
+
69
+ /** Singleton size-cap config (warn / block thresholds). */
70
+ export const scriptPackageSizeCap = pgTable("script_package_size_cap", {
71
+ id: text("id").primaryKey().default("singleton"),
72
+ warnBytes: bigint("warn_bytes", { mode: "number" })
73
+ .notNull()
74
+ .default(150 * 1024 * 1024),
75
+ blockBytes: bigint("block_bytes", { mode: "number" })
76
+ .notNull()
77
+ .default(300 * 1024 * 1024),
78
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
79
+ });
80
+
81
+ /**
82
+ * Content-addressed blob index. `integrity` is the stable identity across
83
+ * blob-store backends; `backend` records which `BlobStore` currently holds
84
+ * it (well-defined during migration). Powers delta sync + blob GC.
85
+ */
86
+ export const scriptPackageBlob = pgTable(
87
+ "script_package_blob",
88
+ {
89
+ integrity: text("integrity").primaryKey(),
90
+ name: text("name").notNull(),
91
+ version: text("version").notNull(),
92
+ /** "postgres" | "s3" | ... */
93
+ backend: text("backend").notNull(),
94
+ sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
95
+ createdAt: timestamp("created_at").defaultNow().notNull(),
96
+ },
97
+ (t) => ({
98
+ backendIdx: index("script_package_blob_backend_idx").on(t.backend),
99
+ }),
100
+ );
101
+
102
+ /** Singleton storage config + in-flight migration state. */
103
+ export const scriptPackageStorageConfig = pgTable(
104
+ "script_package_storage_config",
105
+ {
106
+ id: text("id").primaryKey().default("singleton"),
107
+ activeBackend: text("active_backend").notNull().default("postgres"),
108
+ /** "idle" | "migrating" | "completed" | "error" */
109
+ migrationStatus: text("migration_status").notNull().default("idle"),
110
+ migrationTarget: text("migration_target"),
111
+ migratedCount: integer("migrated_count").notNull().default(0),
112
+ migrationError: text("migration_error"),
113
+ updatedAt: timestamp("updated_at").defaultNow().notNull(),
114
+ },
115
+ );
116
+
117
+ /**
118
+ * Recent lockfile-manifest history. The installer records each successful
119
+ * `lockfileHash` + its manifest here so the blob GC can compute the
120
+ * "retained set" (current + the previous N hashes) without that history
121
+ * being reconstructable from the singleton install state (which only holds
122
+ * the CURRENT desired manifest). Old rows beyond the retention window are
123
+ * pruned by the GC itself.
124
+ */
125
+ export const scriptPackageLockfileHistory = pgTable(
126
+ "script_package_lockfile_history",
127
+ {
128
+ lockfileHash: text("lockfile_hash").primaryKey(),
129
+ manifest: jsonb("manifest").$type<ManifestEntry[]>().notNull().default([]),
130
+ recordedAt: timestamp("recorded_at").defaultNow().notNull(),
131
+ },
132
+ (t) => ({
133
+ recordedIdx: index("script_package_lockfile_history_recorded_idx").on(
134
+ t.recordedAt,
135
+ ),
136
+ }),
137
+ );
138
+
139
+ /** Singleton blob-GC last-run state (for the admin UI; never gates safety). */
140
+ export const scriptPackageBlobGcState = pgTable(
141
+ "script_package_blob_gc_state",
142
+ {
143
+ id: text("id").primaryKey().default("singleton"),
144
+ lastRunAt: timestamp("last_run_at"),
145
+ lastDeleted: integer("last_deleted").notNull().default(0),
146
+ lastBytesReclaimed: bigint("last_bytes_reclaimed", { mode: "number" })
147
+ .notNull()
148
+ .default(0),
149
+ totalBytesReclaimed: bigint("total_bytes_reclaimed", { mode: "number" })
150
+ .notNull()
151
+ .default(0),
152
+ },
153
+ );
154
+
155
+ /** Per-satellite reconcile state (satellites are individually addressable). */
156
+ export const scriptPackageSatelliteState = pgTable(
157
+ "script_package_satellite_state",
158
+ {
159
+ satelliteId: text("satellite_id").primaryKey(),
160
+ lockfileHash: text("lockfile_hash"),
161
+ /** "pending" | "syncing" | "ready" | "error" */
162
+ status: text("status").notNull().default("pending"),
163
+ errorMessage: text("error_message"),
164
+ syncedAt: timestamp("synced_at"),
165
+ },
166
+ );
@@ -0,0 +1,32 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { evaluateSizeCap } from "./size-cap";
3
+
4
+ const cap = { warnBytes: 150 * 1024 * 1024, blockBytes: 300 * 1024 * 1024 };
5
+
6
+ describe("evaluateSizeCap", () => {
7
+ test("ok below the warn threshold", () => {
8
+ expect(evaluateSizeCap({ totalSizeBytes: 50 * 1024 * 1024, cap }).level).toBe(
9
+ "ok",
10
+ );
11
+ });
12
+
13
+ test("warns between warn and block", () => {
14
+ const v = evaluateSizeCap({ totalSizeBytes: 200 * 1024 * 1024, cap });
15
+ expect(v.level).toBe("warn");
16
+ if (v.level !== "ok") expect(v.message).toContain("200.0MB");
17
+ });
18
+
19
+ test("blocks above the block threshold", () => {
20
+ const v = evaluateSizeCap({ totalSizeBytes: 400 * 1024 * 1024, cap });
21
+ expect(v.level).toBe("block");
22
+ });
23
+
24
+ test("exactly at a threshold is not over it", () => {
25
+ expect(
26
+ evaluateSizeCap({ totalSizeBytes: cap.warnBytes, cap }).level,
27
+ ).toBe("ok");
28
+ expect(
29
+ evaluateSizeCap({ totalSizeBytes: cap.blockBytes, cap }).level,
30
+ ).toBe("warn");
31
+ });
32
+ });
@@ -0,0 +1,40 @@
1
+ import type { SizeCapConfig } from "@checkstack/script-packages-common";
2
+
3
+ export type SizeCapVerdict =
4
+ | { level: "ok" }
5
+ | { level: "warn"; message: string }
6
+ | { level: "block"; message: string };
7
+
8
+ function mb(bytes: number): string {
9
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
10
+ }
11
+
12
+ /**
13
+ * Evaluate a resolved total size against the configured cap. `block` should
14
+ * refuse the install; `warn` surfaces a non-fatal notice in the admin UI.
15
+ */
16
+ export function evaluateSizeCap({
17
+ totalSizeBytes,
18
+ cap,
19
+ }: {
20
+ totalSizeBytes: number;
21
+ cap: SizeCapConfig;
22
+ }): SizeCapVerdict {
23
+ if (totalSizeBytes > cap.blockBytes) {
24
+ return {
25
+ level: "block",
26
+ message: `Resolved package size ${mb(totalSizeBytes)} exceeds the ${mb(
27
+ cap.blockBytes,
28
+ )} block threshold. Remove packages or raise the cap.`,
29
+ };
30
+ }
31
+ if (totalSizeBytes > cap.warnBytes) {
32
+ return {
33
+ level: "warn",
34
+ message: `Resolved package size ${mb(totalSizeBytes)} exceeds the ${mb(
35
+ cap.warnBytes,
36
+ )} warning threshold.`,
37
+ };
38
+ }
39
+ return { level: "ok" };
40
+ }
@@ -0,0 +1,318 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { BlobStore } from "./blob-store";
3
+ import {
4
+ runStorageMigration,
5
+ resumeCrashedMigration,
6
+ type MigrationStateSnapshot,
7
+ type StorageMigrationConfig,
8
+ type StorageMigrationStores,
9
+ } from "./storage-migration";
10
+ import type { AdvisoryLockHandle } from "@checkstack/backend-api";
11
+
12
+ function memStore(id: string, seed: Record<string, string> = {}): BlobStore {
13
+ const map = new Map<string, Uint8Array>(
14
+ Object.entries(seed).map(([k, v]) => [k, new TextEncoder().encode(v)]),
15
+ );
16
+ return {
17
+ id,
18
+ async put({ integrity, bytes }) {
19
+ map.set(integrity, bytes);
20
+ },
21
+ async get({ integrity }) {
22
+ return map.get(integrity);
23
+ },
24
+ async has({ integrity }) {
25
+ return map.has(integrity);
26
+ },
27
+ async delete({ integrity }) {
28
+ map.delete(integrity);
29
+ },
30
+ async list() {
31
+ return [...map.keys()];
32
+ },
33
+ };
34
+ }
35
+
36
+ /** In-memory blob index + storage-config doubles. */
37
+ function harness(initial: { integrity: string; backend: string }[]) {
38
+ const index = new Map(initial.map((b) => [b.integrity, b.backend]));
39
+ const config = {
40
+ status: "idle" as string,
41
+ activeBackend: "postgres",
42
+ target: null as string | null,
43
+ migratedCount: 0,
44
+ error: null as string | null,
45
+ };
46
+ const blobIndex: StorageMigrationStores = {
47
+ listNotOnBackend: async (target) =>
48
+ [...index.entries()]
49
+ .filter(([, backend]) => backend !== target)
50
+ .map(([integrity, backend]) => ({ integrity, backend })),
51
+ setBackend: async (integrity, backend) => {
52
+ index.set(integrity, backend);
53
+ },
54
+ };
55
+ const storage: StorageMigrationConfig = {
56
+ beginMigration: async (target) => {
57
+ config.status = "migrating";
58
+ config.target = target;
59
+ config.migratedCount = 0;
60
+ config.error = null;
61
+ },
62
+ setMigratedCount: async (n) => {
63
+ config.migratedCount = n;
64
+ },
65
+ completeMigration: async (target) => {
66
+ config.status = "completed";
67
+ config.activeBackend = target;
68
+ },
69
+ failMigration: async (message) => {
70
+ config.status = "error";
71
+ config.error = message;
72
+ },
73
+ };
74
+ return { index, config, blobIndex, storage };
75
+ }
76
+
77
+ describe("runStorageMigration", () => {
78
+ test("copies + verifies every blob, then atomically flips active backend", async () => {
79
+ const pg = memStore("postgres", { "sha-a": "AAA", "sha-b": "BBB" });
80
+ const s3 = memStore("s3");
81
+ const { index, config, blobIndex, storage } = harness([
82
+ { integrity: "sha-a", backend: "postgres" },
83
+ { integrity: "sha-b", backend: "postgres" },
84
+ ]);
85
+
86
+ const res = await runStorageMigration({
87
+ blobIndex,
88
+ storage,
89
+ getStore: (id) => (id === "postgres" ? pg : s3),
90
+ activeBackend: "postgres",
91
+ target: "s3",
92
+ });
93
+
94
+ expect(res.completed).toBe(true);
95
+ expect(res.migrated).toBe(2);
96
+ // Bytes copied to target + index flipped.
97
+ expect(new TextDecoder().decode(await s3.get({ integrity: "sha-a" }))).toBe("AAA");
98
+ expect(index.get("sha-a")).toBe("s3");
99
+ expect(index.get("sha-b")).toBe("s3");
100
+ // Active backend flipped only at completion.
101
+ expect(config.activeBackend).toBe("s3");
102
+ expect(config.status).toBe("completed");
103
+ expect(config.migratedCount).toBe(2);
104
+ });
105
+
106
+ test("resumes from a partial state (blobs already on target are skipped)", async () => {
107
+ // sha-a already migrated; only sha-b remains.
108
+ const pg = memStore("postgres", { "sha-b": "BBB" });
109
+ const s3 = memStore("s3", { "sha-a": "AAA" });
110
+ const { index, config, blobIndex, storage } = harness([
111
+ { integrity: "sha-a", backend: "s3" }, // already flipped
112
+ { integrity: "sha-b", backend: "postgres" },
113
+ ]);
114
+
115
+ const res = await runStorageMigration({
116
+ blobIndex,
117
+ storage,
118
+ getStore: (id) => (id === "postgres" ? pg : s3),
119
+ activeBackend: "postgres",
120
+ target: "s3",
121
+ });
122
+
123
+ expect(res.migrated).toBe(1); // only sha-b
124
+ expect(index.get("sha-b")).toBe("s3");
125
+ expect(config.activeBackend).toBe("s3");
126
+ });
127
+
128
+ test("aborts cleanly on an integrity mismatch (active backend unchanged)", async () => {
129
+ const pg = memStore("postgres", { "sha-a": "AAA" });
130
+ // Target silently corrupts on write (put stores different bytes).
131
+ const corrupt: BlobStore = {
132
+ id: "s3",
133
+ async put() {
134
+ /* drop the write */
135
+ },
136
+ async get() {
137
+ return new TextEncoder().encode("CORRUPT");
138
+ },
139
+ async has() {
140
+ return false;
141
+ },
142
+ async delete() {},
143
+ async list() {
144
+ return [];
145
+ },
146
+ };
147
+ const { index, config, blobIndex, storage } = harness([
148
+ { integrity: "sha-a", backend: "postgres" },
149
+ ]);
150
+
151
+ const res = await runStorageMigration({
152
+ blobIndex,
153
+ storage,
154
+ getStore: (id) => (id === "postgres" ? pg : corrupt),
155
+ activeBackend: "postgres",
156
+ target: "s3",
157
+ });
158
+
159
+ expect(res.completed).toBe(false);
160
+ expect(res.error).toMatch(/integrity verification failed/i);
161
+ expect(config.status).toBe("error");
162
+ expect(config.activeBackend).toBe("postgres"); // NOT flipped
163
+ expect(index.get("sha-a")).toBe("postgres"); // NOT flipped
164
+ });
165
+
166
+ test("GCs the source after a verified copy when gcSource is set", async () => {
167
+ const pg = memStore("postgres", { "sha-a": "AAA" });
168
+ const s3 = memStore("s3");
169
+ const { blobIndex, storage } = harness([
170
+ { integrity: "sha-a", backend: "postgres" },
171
+ ]);
172
+
173
+ await runStorageMigration({
174
+ blobIndex,
175
+ storage,
176
+ getStore: (id) => (id === "postgres" ? pg : s3),
177
+ activeBackend: "postgres",
178
+ target: "s3",
179
+ gcSource: true,
180
+ });
181
+
182
+ expect(await pg.has({ integrity: "sha-a" })).toBe(false); // GC'd
183
+ expect(await s3.has({ integrity: "sha-a" })).toBe(true);
184
+ });
185
+
186
+ test("no-op when target equals the active backend", async () => {
187
+ const { config, blobIndex, storage } = harness([]);
188
+ const res = await runStorageMigration({
189
+ blobIndex,
190
+ storage,
191
+ getStore: () => memStore("postgres"),
192
+ activeBackend: "postgres",
193
+ target: "postgres",
194
+ });
195
+ expect(res.completed).toBe(true);
196
+ expect(res.migrated).toBe(0);
197
+ expect(config.status).toBe("idle"); // beginMigration not called
198
+ });
199
+
200
+ test("aborts when a source blob is missing", async () => {
201
+ const pg = memStore("postgres"); // empty — sha-a missing
202
+ const s3 = memStore("s3");
203
+ const { config, blobIndex, storage } = harness([
204
+ { integrity: "sha-a", backend: "postgres" },
205
+ ]);
206
+ const res = await runStorageMigration({
207
+ blobIndex,
208
+ storage,
209
+ getStore: (id) => (id === "postgres" ? pg : s3),
210
+ activeBackend: "postgres",
211
+ target: "s3",
212
+ });
213
+ expect(res.completed).toBe(false);
214
+ expect(res.error).toMatch(/missing from its source backend/i);
215
+ expect(config.activeBackend).toBe("postgres");
216
+ });
217
+ });
218
+
219
+ describe("resumeCrashedMigration", () => {
220
+ function fakeLock(): { handle: AdvisoryLockHandle; released: () => boolean } {
221
+ let released = false;
222
+ return {
223
+ handle: {
224
+ async release() {
225
+ released = true;
226
+ },
227
+ },
228
+ released: () => released,
229
+ };
230
+ }
231
+
232
+ test("resumes a store left in 'migrating' on init and completes it", async () => {
233
+ // H3 regression: a migration that crashed mid-flight leaves status
234
+ // "migrating", which wedges installs AND blocks triggerMigration. Init
235
+ // must relaunch the resumable migration toward the recorded target.
236
+ const pg = memStore("postgres", { "sha-a": "AAA", "sha-b": "BBB" });
237
+ const s3 = memStore("s3");
238
+ const { index, config, blobIndex, storage } = harness([
239
+ { integrity: "sha-a", backend: "postgres" },
240
+ { integrity: "sha-b", backend: "postgres" },
241
+ ]);
242
+ // Simulate the crash: status stuck at "migrating" toward "s3".
243
+ config.status = "migrating";
244
+ config.target = "s3";
245
+
246
+ const lock = fakeLock();
247
+ const result = await resumeCrashedMigration({
248
+ loadState: async (): Promise<MigrationStateSnapshot> => ({
249
+ migrationStatus: config.status,
250
+ migrationTarget: config.target,
251
+ activeBackend: config.activeBackend,
252
+ }),
253
+ tryLock: async () => lock.handle,
254
+ runMigration: ({ target, activeBackend }) =>
255
+ runStorageMigration({
256
+ blobIndex,
257
+ storage,
258
+ getStore: (id) => (id === "postgres" ? pg : s3),
259
+ activeBackend,
260
+ target,
261
+ }),
262
+ });
263
+
264
+ expect(result.resumed).toBe(true);
265
+ await result.done; // wait for the relaunched migration to finish
266
+
267
+ expect(config.status).toBe("completed");
268
+ expect(config.activeBackend).toBe("s3");
269
+ expect(index.get("sha-a")).toBe("s3");
270
+ expect(index.get("sha-b")).toBe("s3");
271
+ expect(lock.released()).toBe(true); // lock freed when done
272
+ });
273
+
274
+ test("does nothing when no migration is in flight", async () => {
275
+ const { blobIndex, storage } = harness([]);
276
+ let ran = false;
277
+ const result = await resumeCrashedMigration({
278
+ loadState: async () => ({
279
+ migrationStatus: "idle",
280
+ migrationTarget: null,
281
+ activeBackend: "postgres",
282
+ }),
283
+ tryLock: async () => {
284
+ throw new Error("should not try to lock when nothing to resume");
285
+ },
286
+ runMigration: async () => {
287
+ ran = true;
288
+ return runStorageMigration({
289
+ blobIndex,
290
+ storage,
291
+ getStore: () => memStore("x"),
292
+ activeBackend: "postgres",
293
+ target: "s3",
294
+ });
295
+ },
296
+ });
297
+ expect(result.resumed).toBe(false);
298
+ expect(ran).toBe(false);
299
+ });
300
+
301
+ test("defers to another pod that already holds the lock", async () => {
302
+ let ran = false;
303
+ const result = await resumeCrashedMigration({
304
+ loadState: async () => ({
305
+ migrationStatus: "migrating",
306
+ migrationTarget: "s3",
307
+ activeBackend: "postgres",
308
+ }),
309
+ tryLock: async () => null, // held elsewhere
310
+ runMigration: async () => {
311
+ ran = true;
312
+ },
313
+ });
314
+ expect(result.resumed).toBe(false);
315
+ expect(result.reason).toMatch(/another instance/i);
316
+ expect(ran).toBe(false);
317
+ });
318
+ });