deployments.ts220 lines · main
1import { newId, NotFoundError } from '@briven/shared';
2import { and, desc, eq } from 'drizzle-orm';
3
4import { getDb } from '../db/client.js';
5import { log } from '../lib/logger.js';
6import {
7 deployments,
8 type Deployment,
9 type DeploymentStatus,
10 type NewDeployment,
11} from '../db/schema.js';
12import { 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 */
19const 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 */
30export 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
37export 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
48export 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
70export 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 */
88export 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 */
107export 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
127export 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
140export 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
151export interface TransitionDeploymentInput {
152 projectId: string;
153 deploymentId: string;
154 status: DeploymentStatus;
155 errorCode?: string | null;
156 errorMessage?: string | null;
157}
158
159export 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
207export 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}