g1t/services/deployments/src/moves.flow.test.ts

322 lines14,574 bytesCodeBlame
1/**
2 * The whole service, on Node, following a repository transfer: a D1 made
3 * of node:sqlite with the service's migrations, the other services as
4 * fakes that answer by method, and Cloudflare's API as a recorded fetch.
5 *
6 * Run with `--experimental-transform-types` (see package.json): the
7 * service's own modules import without extensions, so a resolve hook adds
8 * them.
9 */
10
11import assert from "node:assert/strict";
12import { readdirSync, readFileSync } from "node:fs";
13import { registerHooks } from "node:module";
14import { DatabaseSync } from "node:sqlite";
15import { beforeEach, test } from "node:test";
16
17registerHooks({
18 resolve(specifier, context, next) {
19 try {
20 return next(specifier, context);
21 } catch (error) {
22 if (specifier.startsWith(".")) return next(`${specifier}.ts`, context);
23 throw error;
24 }
25 },
26});
27const { default: worker } = await import("./index.ts");
28
29// ---- D1, on node:sqlite ------------------------------------------------
30
31function d1(db: DatabaseSync) {
32 const value = (v: unknown) => (v === undefined ? null : typeof v === "boolean" ? Number(v) : v);
33 const statement = (sql: string, params: unknown[] = []) => ({
34 sql,
35 params,
36 bind: (...args: unknown[]) => statement(sql, args.map(value)),
37 async first<T>() {
38 return (db.prepare(sql).get(...(params as never[])) as T) ?? null;
39 },
40 async all<T>() {
41 return { results: db.prepare(sql).all(...(params as never[])) as T[], success: true };
42 },
43 async run() {
44 const r = db.prepare(sql).run(...(params as never[]));
45 return { success: true, meta: { changes: Number(r.changes) } };
46 },
47 });
48 return {
49 prepare: (sql: string) => statement(sql),
50 async batch(statements: ReturnType<typeof statement>[]) {
51 db.exec("BEGIN");
52 try {
53 const out = statements.map((s) => ({ results: db.prepare(s.sql).all(...(s.params as never[])) }));
54 db.exec("COMMIT");
55 return out;
56 } catch (error) {
57 db.exec("ROLLBACK");
58 throw error;
59 }
60 },
61 };
62}
63
64// ---- The world ------------------------------------------------------------
65
66const API = "rep_api";
67const PROJECT = "prj_api";
68
69type World = ReturnType<typeof world>;
70
71function world() {
72 const sqlite = new DatabaseSync(":memory:");
73 const dir = new URL("../migrations/", import.meta.url);
74 for (const file of readdirSync(dir).sort()) sqlite.exec(readFileSync(new URL(file, dir), "utf8"));
75 const state = {
76 sqlite,
77 /** Where the repository is now. */
78 path: { namespace: "flagon-io", name: "lab-api" },
79 /** Where the projects service has the project now. */
80 projectWorkspace: "flagon-io",
81 /** Each workspace's usage limit state. */
82 limits: new Map<string, string>([["syntaqx", "stopped"]]),
83 runnerUp: true,
84 /** Builds the runner was asked for: deploy id and token. */
85 builds: [] as { deployId: string; token: string; workspace: string }[],
86 /** Cloudflare API calls, as `METHOD path`. */
87 cloudflare: [] as string[],
88 kv: new Map<string, { value: string; expiration?: number }>(),
89 };
90 const project = () => ({
91 id: PROJECT,
92 workspace: state.projectWorkspace,
93 slug: "lab-api",
94 name: "lab-api",
95 primary: true,
96 source: { kind: "hosted", repoId: API, repo: { ...state.path }, defaultBranch: "main", rootDir: null },
97 });
98 const ok = (value: unknown) => ({ ok: true, value });
99 const service = (answer: (method: string, args: any) => unknown) => ({
100 async fetch(input: string | Request, init?: RequestInit) {
101 const url = typeof input === "string" ? input : input.url;
102 const method = new URL(url).pathname.split("/").pop()!;
103 const args = init?.body ? JSON.parse(String(init.body)) : {};
104 const answered = await answer(method, args);
105 if (answered instanceof Response) return answered;
106 return Response.json(answered ?? ok(null));
107 },
108 });
109 const env = {
110 DB: d1(sqlite),
111 CLOUDFLARE_API_TOKEN: "test",
112 CLOUDFLARE_ACCOUNT_ID: "acct",
113 DISPATCH_NAMESPACE: "g1t-deployments",
114 SITE: "https://g1t.sh",
115 DOMAINS: {
116 async get(key: string) {
117 const found = state.kv.get(key);
118 return found ? JSON.parse(found.value) : null;
119 },
120 async put(key: string, value: string, options?: { expiration?: number }) {
121 state.kv.set(key, { value, expiration: options?.expiration });
122 },
123 async delete(key: string) {
124 state.kv.delete(key);
125 },
126 },
127 REPOS: service((method) => {
128 if (method === "path_by_id") return state.path;
129 if (method === "get") return ok({ isPrivate: true });
130 return ok([]);
131 }),
132 PROJECTS: service((method) => {
133 if (method === "by_repo") return [project()];
134 if (method === "graph") return { dependsOn: [], usedBy: [] };
135 return ok(project());
136 }),
137 IDENTITY: service((method, args) => {
138 if (method === "get_workspace") return { id: `wsp_${args.slug}`, slug: args.slug };
139 if (method === "collaborator_permission") return ok({ role: "write" });
140 return ok(null);
141 }),
142 BILLING: service((method, args) => {
143 if (method === "has_feature") return ok(true);
144 if (method === "check_limit") return ok({ state: state.limits.get(args.workspace) ?? "ok" });
145 if (method === "entitlements") return ok({ plan: "paid" });
146 if (method === "reserve") return ok({ id: "res_1", paidBy: "credit" });
147 if (method === "prices") return { prices: [] };
148 return ok(null);
149 }),
150 WORK: service((method) => {
151 if (method === "get_pull") {
152 return ok({ pull: { status: "open", headCommit: "pr1head", branch: "v2", author: { username: "syntaqx", kind: "user" } } });
153 }
154 return ok(null);
155 }),
156 ACTIONS: service(() => ({ secrets: {}, variables: {} })),
157 RUNNER: service((method, args) => {
158 if (!state.runnerUp) return new Response("unavailable", { status: 503 });
159 if (method === "start_deploy") state.builds.push({ deployId: args.deployId, token: args.token, workspace: args.workspace });
160 return ok(true);
161 }),
162 };
163 return { state, env };
164}
165
166let w: World;
167const realFetch = globalThis.fetch;
168
169beforeEach(() => {
170 w = world();
171 // Cloudflare's API: every call succeeds, and none lists anything.
172 globalThis.fetch = (async (input: string | Request, init?: RequestInit) => {
173 const url = new URL(typeof input === "string" ? input : input.url);
174 w.state.cloudflare.push(`${init?.method ?? "GET"} ${url.pathname}`);
175 if (url.pathname.endsWith("/graphql")) return Response.json({ data: { viewer: { accounts: [{}] } } });
176 return Response.json({ success: true, result: [], result_info: { total_pages: 1 } });
177 }) as typeof fetch;
178 // As production was on 2026-10-06: the project's rows still in syntaqx,
179 // its apps paused there by syntaqx's limit, minutes before the transfer.
180 const db = w.state.sqlite;
181 db.prepare(
182 `INSERT INTO settings (project_id, repo_id, workspace, slug, enabled, previews, production, idle_days, updated_at)
183 VALUES (?, ?, 'syntaqx', 'lab-api', 1, 1, 1, 7, '2026-10-05T00:00:00Z')`,
184 ).run(PROJECT, API);
185 const app = db.prepare(
186 `INSERT INTO apps (script, project_id, workspace, slug, kind, branch, number, commit_sha, deployed_at, created_at, paused_at)
187 VALUES (?, ?, 'syntaqx', 'lab-api', ?, ?, ?, ?, '2026-10-05T00:26:29Z', '2026-10-05T00:26:29Z', '2026-10-06T01:20:54Z')`,
188 );
189 app.run("lab-api-syntaqx", PROJECT, "production", null, null, "8e500b1");
190 app.run("lab-api-git-v2-syntaqx", PROJECT, "preview", "v2", 1, "d2ca224");
191 const deployment = db.prepare(
192 `INSERT INTO deployments (id, project_id, workspace, slug, repo_id, repo, kind, branch, number, commit_sha, script, status,
193 trusted, created_by, created_at, finished_at)
194 VALUES (?, ?, 'syntaqx', 'lab-api', ?, 'syntaqx/lab-api', ?, ?, ?, ?, ?, 'ready', 1, 'g1t', '2026-10-05T00:26:00Z', '2026-10-05T00:26:29Z')`,
195 );
196 deployment.run("dpl_prod", PROJECT, API, "production", null, null, "8e500b1", "lab-api-syntaqx");
197 deployment.run("dpl_prev", PROJECT, API, "preview", "v2", 1, "d2ca224", "lab-api-git-v2-syntaqx");
198});
199
200process.on("exit", () => {
201 globalThis.fetch = realFetch;
202});
203
204const transferred = {
205 id: "evt_1",
206 type: "repo.transferred",
207 source: "repos",
208 time: "2026-10-06T01:23:38Z",
209 repoId: API,
210 actor: "usr_1",
211 data: { from: "syntaqx", name: "lab-api", repoId: API, to: "flagon-io" },
212};
213
214/** Delivers the event as the queue would; whether it was acked. */
215async function deliver(event: unknown, attempts = 1): Promise<boolean> {
216 let acked = false;
217 let retried = false;
218 const message = { body: event, attempts, ack: () => (acked = true), retry: () => (retried = true) };
219 await worker.queue({ messages: [message] } as never, w.env as never);
220 assert.notEqual(acked, retried);
221 return acked;
222}
223
224const rows = (sql: string, ...params: unknown[]) =>
225 (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row }));
226
227test("a transfer rebuilds every app under the new workspace, paused ones too", async () => {
228 assert.equal(await deliver(transferred), true);
229 assert.deepEqual(rows("SELECT workspace FROM settings"), [{ workspace: "flagon-io" }]);
230 const queued = rows("SELECT script, kind, commit_sha FROM deployments WHERE status = 'queued' ORDER BY script");
231 assert.deepEqual(queued, [
232 { script: "lab-api-flagon-io", kind: "production", commit_sha: "8e500b1" },
233 { script: "lab-api-git-v2-flagon-io", kind: "preview", commit_sha: "pr1head" },
234 ]);
235 assert.equal(w.state.builds.length, 2);
236 assert.ok(w.state.builds.every((b) => b.workspace === "flagon-io"));
237});
238
239test("a second delivery builds nothing more", async () => {
240 await deliver(transferred);
241 assert.equal(await deliver(transferred, 2), true);
242 assert.equal(w.state.builds.length, 2);
243 assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2);
244});
245
246test("a rebuild that could not be queued is not acked, and the next delivery queues it", async () => {
247 w.state.runnerUp = false;
248 assert.equal(await deliver(transferred), false);
249 // The rows moved, but nothing is building.
250 assert.deepEqual(rows("SELECT workspace FROM settings"), [{ workspace: "flagon-io" }]);
251 assert.equal(rows("SELECT id FROM deployments WHERE status IN ('queued', 'building')").length, 0);
252 w.state.runnerUp = true;
253 assert.equal(await deliver(transferred, 2), true);
254 assert.deepEqual(
255 rows("SELECT script FROM deployments WHERE status = 'queued' ORDER BY script").map((r) => r.script),
256 ["lab-api-flagon-io", "lab-api-git-v2-flagon-io"],
257 );
258});
259
260test("the sweep picks up a move whose deliveries ran out, keyed on the new workspace", async () => {
261 w.state.runnerUp = false;
262 await deliver(transferred);
263 w.state.runnerUp = true;
264 // A failed attempt was just made: the sweep waits before trying again.
265 await worker.scheduled({} as never, w.env as never);
266 assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 0);
267 w.state.sqlite.exec("UPDATE deployments SET created_at = '2026-10-06T00:00:00Z' WHERE status = 'failed'");
268 await worker.scheduled({} as never, w.env as never);
269 assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2);
270 // syntaqx is over its limit, flagon-io is not: nothing of the project's is
271 // paused for syntaqx's sake, and nothing new was paused at all.
272 assert.ok(!w.state.cloudflare.some((call) => call.startsWith("PUT") && /scripts\/lab-api-flagon-io/.test(call)));
273});
274
275test("a move waits, without refused deployments, while the new workspace is over its limit", async () => {
276 w.state.limits.set("flagon-io", "stopped");
277 assert.equal(await deliver(transferred), true);
278 assert.equal(rows("SELECT id FROM deployments WHERE created_at > '2026-10-06'").length, 0);
279 w.state.limits.delete("flagon-io");
280 await worker.scheduled({} as never, w.env as never);
281 assert.equal(rows("SELECT id FROM deployments WHERE status = 'queued'").length, 2);
282});
283
284test("once the new app is live, the old address redirects to it, paused or not", async () => {
285 await deliver(transferred);
286 const build = w.state.builds.find((b) => rows("SELECT script FROM deployments WHERE id = ?", b.deployId)[0].script === "lab-api-flagon-io")!;
287 const finish = await worker.fetch(
288 new Request(`https://deployments/jobs/${build.deployId}/finish`, {
289 method: "POST",
290 body: JSON.stringify({ token: build.token, worker: { mainModule: "index.js", modules: [{ name: "index.js", contentBase64: btoa("export default {}"), contentType: "application/javascript+module" }] }, buildSeconds: 30 }),
291 }),
292 w.env as never,
293 {} as never,
294 );
295 assert.deepEqual(await finish.json(), { ok: true, value: true });
296 assert.deepEqual(
297 rows("SELECT script, workspace FROM apps WHERE kind = 'production'"),
298 [{ script: "lab-api-flagon-io", workspace: "flagon-io" }],
299 );
300 const [redirect] = rows("SELECT script, target, workspace FROM redirects");
301 assert.deepEqual({ ...redirect }, { script: "lab-api-syntaqx", target: "lab-api-flagon-io.g1t.page", workspace: "flagon-io" });
302 // The dispatcher's entry, followed before the old (paused) script runs.
303 const entry = w.state.kv.get("lab-api-syntaqx.g1t.page")!;
304 assert.deepEqual(JSON.parse(entry.value), { script: "lab-api-syntaqx", redirect: "lab-api-flagon-io.g1t.page" });
305 assert.ok(entry.expiration! > Date.now() / 1000 + 89 * 24 * 3600);
306 // And the old script itself is the redirect too.
307 assert.ok(w.state.cloudflare.some((call) => call === "PUT /client/v4/accounts/acct/workers/dispatch/namespaces/g1t-deployments/scripts/lab-api-syntaqx"));
308 // The preview is still to come; the old one stays until it is live.
309 assert.equal(rows("SELECT script FROM apps WHERE kind = 'preview'")[0].script, "lab-api-git-v2-syntaqx");
310});
311
312test("the sweep never pauses a moved app for its old workspace's limit", async () => {
313 // Rows moved (as the event does) but no rebuild yet, and the apps not paused.
314 w.state.sqlite.exec("UPDATE settings SET workspace = 'flagon-io'; UPDATE apps SET paused_at = NULL");
315 w.state.runnerUp = false;
316 await worker.scheduled({} as never, w.env as never);
317 assert.deepEqual(rows("SELECT paused_at FROM apps").map((r) => r.paused_at), [null, null]);
318 // When the project's own workspace is over its limit, they are paused.
319 w.state.limits.set("flagon-io", "stopped");
320 await worker.scheduled({} as never, w.env as never);
321 assert.ok(rows("SELECT paused_at FROM apps").every((r) => r.paused_at));
322});