g1t/services/deployments/src/moves.flow.test.ts
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 quotas | 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 | ||
| 11 | import assert from "node:assert/strict"; | |
| 12 | import { readdirSync, readFileSync } from "node:fs"; | |
| 13 | import { registerHooks } from "node:module"; | |
| 14 | import { DatabaseSync } from "node:sqlite"; | |
| 15 | import { beforeEach, test } from "node:test"; | |
| 16 | ||
| 17 | registerHooks({ | |
| 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 | }); | |
| 27 | const { default: worker } = await import("./index.ts"); | |
| 28 | ||
| 29 | // ---- D1, on node:sqlite ------------------------------------------------ | |
| 30 | ||
| 31 | function 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 | ||
| 66 | const API = "rep_api"; | |
| 67 | const PROJECT = "prj_api"; | |
| 68 | ||
| 69 | type World = ReturnType<typeof world>; | |
| 70 | ||
| 71 | function 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 | ||
| 166 | let w: World; | |
| 167 | const realFetch = globalThis.fetch; | |
| 168 | ||
| 169 | beforeEach(() => { | |
| 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 | ||
| 200 | process.on("exit", () => { | |
| 201 | globalThis.fetch = realFetch; | |
| 202 | }); | |
| 203 | ||
| 204 | const 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. */ | |
| 215 | async 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 | ||
| 224 | const rows = (sql: string, ...params: unknown[]) => | |
| 225 | (w.state.sqlite.prepare(sql).all(...(params as never[])) as any[]).map((row) => ({ ...row })); | |
| 226 | ||
| 227 | test("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 | ||
| 239 | test("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 | ||
| 246 | test("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 | ||
| 260 | test("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 | ||
| 275 | test("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 | ||
| 284 | test("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 | ||
| 312 | test("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 | }); |