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

322 lines14,574 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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});