deployments.ts220 lines · main
| 1 | import { newId, NotFoundError } from '@briven/shared'; |
| 2 | import { and, desc, eq } from 'drizzle-orm'; |
| 3 | |
| 4 | import { getDb } from '../db/client.js'; |
| 5 | import { log } from '../lib/logger.js'; |
| 6 | import { |
| 7 | deployments, |
| 8 | type Deployment, |
| 9 | type DeploymentStatus, |
| 10 | type NewDeployment, |
| 11 | } from '../db/schema.js'; |
| 12 | import { publishEvent } from './outbound-webhooks.js'; |
| 13 | |
| 14 | /** |
| 15 | * Source-file extensions a function file may carry. A deployed function's |
| 16 | * identity is its bare basename, but callers historically sent (and the |
| 17 | * bundle still keys by) the on-disk filename with one of these extensions. |
| 18 | */ |
| 19 | const SOURCE_EXTENSIONS = ['.tsx', '.ts', '.mjs', '.cjs', '.js'] as const; |
| 20 | |
| 21 | /** |
| 22 | * Strip a single trailing source extension from a function name so the |
| 23 | * stored/registered name is the bare basename: |
| 24 | * `listNotes.ts` → `listNotes` |
| 25 | * `my.helper.ts` → `my.helper` (only the known extension is removed) |
| 26 | * `listNotes` → `listNotes` (already bare — unchanged) |
| 27 | * Bundle keys are intentionally NOT run through this — the runtime reads |
| 28 | * source from `functions/<name>.ts` and relies on the extension staying. |
| 29 | */ |
| 30 | export function stripFunctionExt(name: string): string { |
| 31 | for (const ext of SOURCE_EXTENSIONS) { |
| 32 | if (name.endsWith(ext)) return name.slice(0, -ext.length); |
| 33 | } |
| 34 | return name; |
| 35 | } |
| 36 | |
| 37 | export interface CreateDeploymentInput { |
| 38 | projectId: string; |
| 39 | triggeredBy: string | null; |
| 40 | apiKeyId: string | null; |
| 41 | schemaDiffSummary?: Record<string, unknown>; |
| 42 | schemaSnapshot?: Record<string, unknown>; |
| 43 | functionCount?: number; |
| 44 | functionNames?: readonly string[]; |
| 45 | bundle?: Readonly<Record<string, string>>; |
| 46 | } |
| 47 | |
| 48 | export async function createDeployment(input: CreateDeploymentInput): Promise<Deployment> { |
| 49 | const row: NewDeployment = { |
| 50 | id: newId('d'), |
| 51 | projectId: input.projectId, |
| 52 | triggeredBy: input.triggeredBy, |
| 53 | apiKeyId: input.apiKeyId, |
| 54 | status: 'pending', |
| 55 | schemaDiffSummary: input.schemaDiffSummary ?? null, |
| 56 | schemaSnapshot: input.schemaSnapshot ?? null, |
| 57 | functionCount: input.functionCount != null ? String(input.functionCount) : null, |
| 58 | // Register the bare basename as the function's identity. This normalises |
| 59 | // every write path (CLI deploy + PATCH /deployments/latest, which derives |
| 60 | // names from bundle keys) so an invoke for `listNotes` matches. |
| 61 | functionNames: input.functionNames ? input.functionNames.map(stripFunctionExt) : null, |
| 62 | bundle: input.bundle ? { ...input.bundle } : null, |
| 63 | }; |
| 64 | const db = getDb(); |
| 65 | const [created] = await db.insert(deployments).values(row).returning(); |
| 66 | if (!created) throw new Error('deployment insert returned no row'); |
| 67 | return created; |
| 68 | } |
| 69 | |
| 70 | export async function getDeploymentBundle( |
| 71 | deploymentId: string, |
| 72 | ): Promise<Readonly<Record<string, string>> | null> { |
| 73 | const db = getDb(); |
| 74 | const [row] = await db |
| 75 | .select({ bundle: deployments.bundle }) |
| 76 | .from(deployments) |
| 77 | .where(eq(deployments.id, deploymentId)) |
| 78 | .limit(1); |
| 79 | return (row?.bundle as Record<string, string> | null) ?? null; |
| 80 | } |
| 81 | |
| 82 | /** |
| 83 | * The most recent deployment eligible to serve invokes. Phase 1 scope: |
| 84 | * any deployment other than `failed` or `cancelled` counts — there is no |
| 85 | * runner yet that transitions pending→succeeded. Phase 2 tightens this to |
| 86 | * `succeeded` only once the shard worker lands. |
| 87 | */ |
| 88 | export async function getCurrentDeployment(projectId: string): Promise<Deployment | null> { |
| 89 | const db = getDb(); |
| 90 | const [row] = await db |
| 91 | .select() |
| 92 | .from(deployments) |
| 93 | .where(eq(deployments.projectId, projectId)) |
| 94 | .orderBy(desc(deployments.createdAt)) |
| 95 | .limit(1); |
| 96 | if (!row) return null; |
| 97 | if (row.status === 'failed' || row.status === 'cancelled') return null; |
| 98 | return row; |
| 99 | } |
| 100 | |
| 101 | /** |
| 102 | * The most recent deployment whose schema snapshot is authoritative for the |
| 103 | * project. "Authoritative" means `succeeded`, or — before any deployment has |
| 104 | * ever succeeded — the most recent `pending`/`running` one. This mirrors |
| 105 | * what a CLI needs to compute the next diff against. |
| 106 | */ |
| 107 | export async function getCurrentSchema(projectId: string): Promise<{ |
| 108 | deploymentId: string | null; |
| 109 | snapshot: Record<string, unknown> | null; |
| 110 | }> { |
| 111 | const db = getDb(); |
| 112 | const [succeeded] = await db |
| 113 | .select({ id: deployments.id, snapshot: deployments.schemaSnapshot }) |
| 114 | .from(deployments) |
| 115 | .where(and(eq(deployments.projectId, projectId), eq(deployments.status, 'succeeded'))) |
| 116 | .orderBy(desc(deployments.createdAt)) |
| 117 | .limit(1); |
| 118 | if (succeeded) { |
| 119 | return { |
| 120 | deploymentId: succeeded.id, |
| 121 | snapshot: (succeeded.snapshot as Record<string, unknown> | null) ?? null, |
| 122 | }; |
| 123 | } |
| 124 | return { deploymentId: null, snapshot: null }; |
| 125 | } |
| 126 | |
| 127 | export async function listDeploymentsForProject( |
| 128 | projectId: string, |
| 129 | limit = 50, |
| 130 | ): Promise<Deployment[]> { |
| 131 | const db = getDb(); |
| 132 | return db |
| 133 | .select() |
| 134 | .from(deployments) |
| 135 | .where(eq(deployments.projectId, projectId)) |
| 136 | .orderBy(desc(deployments.createdAt)) |
| 137 | .limit(limit); |
| 138 | } |
| 139 | |
| 140 | export async function getDeployment(projectId: string, deploymentId: string): Promise<Deployment> { |
| 141 | const db = getDb(); |
| 142 | const [row] = await db |
| 143 | .select() |
| 144 | .from(deployments) |
| 145 | .where(and(eq(deployments.id, deploymentId), eq(deployments.projectId, projectId))) |
| 146 | .limit(1); |
| 147 | if (!row) throw new NotFoundError('deployment', deploymentId); |
| 148 | return row; |
| 149 | } |
| 150 | |
| 151 | export interface TransitionDeploymentInput { |
| 152 | projectId: string; |
| 153 | deploymentId: string; |
| 154 | status: DeploymentStatus; |
| 155 | errorCode?: string | null; |
| 156 | errorMessage?: string | null; |
| 157 | } |
| 158 | |
| 159 | export async function transitionDeployment(input: TransitionDeploymentInput): Promise<Deployment> { |
| 160 | const existing = await getDeployment(input.projectId, input.deploymentId); |
| 161 | const now = new Date(); |
| 162 | const patch: Partial<Deployment> = { status: input.status }; |
| 163 | if (input.status === 'running' && !existing.startedAt) patch.startedAt = now; |
| 164 | if (input.status === 'succeeded' || input.status === 'failed' || input.status === 'cancelled') { |
| 165 | patch.finishedAt = now; |
| 166 | if (!existing.startedAt) patch.startedAt = now; |
| 167 | } |
| 168 | if (input.errorCode !== undefined) patch.errorCode = input.errorCode; |
| 169 | if (input.errorMessage !== undefined) patch.errorMessage = input.errorMessage; |
| 170 | |
| 171 | const db = getDb(); |
| 172 | const [updated] = await db |
| 173 | .update(deployments) |
| 174 | .set(patch) |
| 175 | .where(eq(deployments.id, input.deploymentId)) |
| 176 | .returning(); |
| 177 | if (!updated) throw new Error('deployment update returned no row'); |
| 178 | |
| 179 | // Fan-out to outbound subscribers on terminal transitions only. Errors |
| 180 | // here are swallowed: the deploy state-machine is the load-bearing |
| 181 | // path, the notification is opportunistic. |
| 182 | if (input.status === 'succeeded' || input.status === 'failed') { |
| 183 | const eventType = input.status === 'succeeded' ? 'deploy.succeeded' : 'deploy.failed'; |
| 184 | void publishEvent({ |
| 185 | projectId: input.projectId, |
| 186 | eventType, |
| 187 | payload: { |
| 188 | projectId: input.projectId, |
| 189 | deploymentId: updated.id, |
| 190 | status: updated.status, |
| 191 | finishedAt: updated.finishedAt?.toISOString() ?? new Date().toISOString(), |
| 192 | errorCode: updated.errorCode ?? null, |
| 193 | errorMessage: updated.errorMessage ?? null, |
| 194 | }, |
| 195 | }).catch((err: unknown) => { |
| 196 | log.warn('outbound_publish_failed', { |
| 197 | event: eventType, |
| 198 | projectId: input.projectId, |
| 199 | deploymentId: updated.id, |
| 200 | message: err instanceof Error ? err.message : String(err), |
| 201 | }); |
| 202 | }); |
| 203 | } |
| 204 | return updated; |
| 205 | } |
| 206 | |
| 207 | export async function cancelPendingDeployment( |
| 208 | projectId: string, |
| 209 | deploymentId: string, |
| 210 | ): Promise<Deployment> { |
| 211 | const existing = await getDeployment(projectId, deploymentId); |
| 212 | if (existing.status !== 'pending' && existing.status !== 'running') { |
| 213 | return existing; |
| 214 | } |
| 215 | return transitionDeployment({ |
| 216 | projectId, |
| 217 | deploymentId, |
| 218 | status: 'cancelled', |
| 219 | }); |
| 220 | } |