g1t/services/deployments/src/moves.ts
| 1 | /** |
| 2 | * Apps whose project moved: its workspace was renamed, or its repository |
| 3 | * renamed or transferred, so each app's name (`<project>-<workspace>`, or |
| 4 | * `<project>-git-<branch>-<workspace>`) is not the one it has now. Each is |
| 5 | * built again under its new name, from the commit it serves; once that is |
| 6 | * live, the old name redirects to it. |
| 7 | * |
| 8 | * What is stale is read from the rows as they are, not from the event that |
| 9 | * moved them, so a second delivery, a late one, or the sweep after a |
| 10 | * rebuild that could not start all end the same: the work left is found |
| 11 | * and done, and what is done is not done twice. |
| 12 | * |
| 13 | * Kept free of imports so its tests run on Node as they are. |
| 14 | */ |
| 15 | |
| 16 | export type MoveKind = "production" | "preview"; |
| 17 | |
| 18 | /** An app row, as far as a move needs it. */ |
| 19 | export type MovedApp = { |
| 20 | script: string; |
| 21 | project_id: string; |
| 22 | kind: MoveKind; |
| 23 | branch: string | null; |
| 24 | number: number | null; |
| 25 | commit_sha: string; |
| 26 | deployed_at: string; |
| 27 | }; |
| 28 | |
| 29 | /** A build a move dropped (it was under way under the old name). */ |
| 30 | export type DroppedBuild = { |
| 31 | project_id: string; |
| 32 | kind: MoveKind; |
| 33 | branch: string | null; |
| 34 | number: number | null; |
| 35 | commit_sha: string; |
| 36 | created_at: string; |
| 37 | }; |
| 38 | |
| 39 | /** One app to build again under its new name, and the old names it replaces. */ |
| 40 | export type MoveTarget = { |
| 41 | projectId: string; |
| 42 | kind: MoveKind; |
| 43 | branch: string | null; |
| 44 | number: number | null; |
| 45 | commit: string; |
| 46 | /** The app's scripts under older names, to redirect once the new one is live. */ |
| 47 | from: string[]; |
| 48 | }; |
| 49 | |
| 50 | /** Production, or one branch's preview, of a project: one app, whatever it is called. */ |
| 51 | export function appKey(row: { project_id: string; kind: MoveKind; branch: string | null }): string { |
| 52 | return `${row.project_id}/${row.kind}/${row.branch ?? ""}`; |
| 53 | } |
| 54 | |
| 55 | /** |
| 56 | * What to build again: one target per app, from its stale rows and the |
| 57 | * builds a move dropped. The newest commit wins, so a build that was under |
| 58 | * way (newer than what was up) is the one built again. |
| 59 | */ |
| 60 | export function moveTargets(stale: MovedApp[], dropped: DroppedBuild[] = []): MoveTarget[] { |
| 61 | const targets = new Map<string, MoveTarget & { at: string }>(); |
| 62 | const consider = (key: string, at: string, row: Omit<MoveTarget, "from">, script: string | null) => { |
| 63 | const found = targets.get(key); |
| 64 | if (!found) { |
| 65 | targets.set(key, { ...row, from: script ? [script] : [], at }); |
| 66 | return; |
| 67 | } |
| 68 | if (script && !found.from.includes(script)) found.from.push(script); |
| 69 | if (at > found.at) Object.assign(found, { commit: row.commit, number: row.number ?? found.number, at }); |
| 70 | }; |
| 71 | for (const app of stale) { |
| 72 | const { project_id: projectId, kind, branch, number, commit_sha: commit } = app; |
| 73 | consider(appKey(app), app.deployed_at, { projectId, kind, branch, number, commit }, app.script); |
| 74 | } |
| 75 | for (const build of dropped) { |
| 76 | const { project_id: projectId, kind, branch, number, commit_sha: commit } = build; |
| 77 | consider(appKey(build), build.created_at, { projectId, kind, branch, number, commit }, null); |
| 78 | } |
| 79 | return [...targets.values()].map(({ at: _at, ...target }) => target); |
| 80 | } |
| 81 | |
| 82 | /** What asking for a rebuild came to. */ |
| 83 | export type RebuildOutcome = |
| 84 | /** A build is queued or under way under the new name. */ |
| 85 | | "queued" |
| 86 | /** It was asked for and refused, or failed to reach a sandbox: try again later. */ |
| 87 | | "failed" |
| 88 | /** Nothing to build: deployments are off for it, or its pull request is closed. */ |
| 89 | | "none"; |
| 90 | |
| 91 | /** |
| 92 | * Reads what starting a rebuild returned: a deployment (queued, or one |
| 93 | * that was skipped or failed at once), nothing to build, or an error. |
| 94 | */ |
| 95 | export function rebuildOutcome(started: { ok: true; value: { status: string } } | { ok: false } | null): RebuildOutcome { |
| 96 | if (started == null) return "none"; |
| 97 | if (!started.ok) return "failed"; |
| 98 | return started.value.status === "queued" || started.value.status === "building" ? "queued" : "failed"; |
| 99 | } |
| 100 | |
| 101 | /** Whether a rebuild last tried at `lastTriedAt` may be tried again at `now`. */ |
| 102 | export function retryDue(lastTriedAt: string | null, now: number, afterMs: number): boolean { |
| 103 | return lastTriedAt == null || Date.parse(lastTriedAt) + afterMs <= now; |
| 104 | } |
| 105 | |
| 106 | /** |
| 107 | * The workspace an app belongs to: its project's, as deployments has it |
| 108 | * now, falling back to the app row's own when the project is unknown. An |
| 109 | * app left under its old name after a move is the new workspace's; limits |
| 110 | * and plans are checked there, never against the workspace it moved from. |
| 111 | */ |
| 112 | export function ownerOf(app: { project_id: string; workspace: string }, owners: Map<string, string>): string { |
| 113 | return owners.get(app.project_id) ?? app.workspace; |
| 114 | } |