@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
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
|
+
});
|
package/src/size-cap.ts
ADDED
|
@@ -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
|
+
});
|