1import * as Effect from "effect/Effect";
2import { Unowned } from "../AdoptPolicy.ts";
3import { deepEqual, isResolved } from "../Diff.ts";
4import { createPhysicalName } from "../PhysicalName.ts";
5import * as Provider from "../Provider.ts";
6import {
7 DEV_TIMESTAMP,
8 attrOrString,
9 devId,
10 devProvider,
11} from "./Internal/DevStub.ts";
12import * as ProviderLayer from "../Local/ProviderLayer.ts";
13import { Resource } from "../Resource.ts";
14import {
15 type GetServicesResponse,
16 type GetProjectBranchesResponse,
17 getServices,
18 getService,
19 getProjectBranches,
20 updateService,
21 createService,
22} from "@distilled.cloud/prisma/management";
23import { Retry } from "@distilled.cloud/prisma";
24import { destroyApp } from "./ComputeLifecycle.ts";
25import { ensureAppImmutableIdentity } from "./Internal/AppIdentity.ts";
26import type { Project } from "./Project.ts";
27import type { Providers } from "./Providers.ts";
28import {
29 concreteIdsChanged,
30 isInputObject,
31 isPrismaDevId,
32 resolveProjectId,
33 unresolvedProjectIdOf,
34} from "./Refs.ts";
35import type { ObservedApp } from "./Internal/Observed.ts";
36import type { PrismaRegionId } from "./Types.ts";
37import { PrismaPaginationError } from "./Internal/Pagination.ts";
38
39export interface AppProps {
40 /**
41 * Project ID or `project.projectId` output that owns this App.
42 */
43 project: string | Project;
44 /**
45 * App display name. If omitted, Alchemy generates a stable physical name.
46 */
47 displayName?: string;
48 /**
49 * Region where the App is placed.
50 *
51 * @default The project's default region, falling back to "us-east-1"
52 */
53 regionId?: PrismaRegionId;
54 /**
55 * Branch ID to attach the App to. Mutually exclusive with branchGitName.
56 */
57 branchId?: string;
58 /**
59 * Branch git name to attach the App to. Mutually exclusive with branchId.
60 */
61 branchGitName?: string;
62}
63
64export interface App extends Resource<
65 "Prisma.App",
66 AppProps,
67 {
68 /**
69 * Prisma App ID.
70 */
71 appId: string;
72 /**
73 * App display name.
74 */
75 name: string;
76 /**
77 * Project ID that owns the App.
78 */
79 projectId: string;
80 /**
81 * Region ID where the App is placed.
82 */
83 regionId: string;
84 /**
85 * Branch ID attached to the App, or null when unassigned.
86 */
87 branchId: string | null;
88 /**
89 * Latest promoted deployment ID, when available.
90 */
91 latestDeploymentId: string | null;
92 /**
93 * Stable App endpoint domain.
94 */
95 appEndpointDomain: string;
96 /**
97 * ISO timestamp when the App was created.
98 */
99 createdAt: string;
100 },
101 never,
102 Providers
103> {}
104
105/**
106 * A Prisma App, the long-lived application configuration that owns deployments.
107 *
108 * Omit `branchId` and `branchGitName` to attach the App to the project's
109 * current default branch. App regions are immutable; create a second App and
110 * cut traffic over when moving regions. Use `Prisma.Compute` for the usual
111 * build, deployment, health-check, and promotion workflow; use `App` directly
112 * when managing standalone `Prisma.Deployment` resources.
113 *
114 * ### Creating an App
115 * **Example:** App on the default branch
116 * ```typescript
117 * const app = yield* Prisma.App("web", {
118 * project,
119 * });
120 * ```
121 *
122 * **Example:** App on a preview branch
123 * ```typescript
124 * const app = yield* Prisma.App("preview-web", {
125 * project,
126 * branchId: preview.branchId,
127 * });
128 * ```
129 *
130 * @resource
131 * @product Compute
132 */
133export const App = Resource<App>("Prisma.App");
134
135// Distilled emits the cursor-paginated list operations as plain ops, so
136// callers walk `pagination` themselves (see `src/Neon/Project.ts`).
137const listBranches = (projectId: string, gitName?: string) =>
138 Effect.gen(function* () {
139 const branches: GetProjectBranchesResponse["data"][number][] = [];
140 let cursor: string | undefined;
141 while (true) {
142 const page = yield* getProjectBranches({
143 projectId,
144 limit: 100,
145 ...(gitName === undefined ? {} : { gitName }),
146 ...(cursor === undefined ? {} : { cursor }),
147 });
148 branches.push(...page.data);
149 const nextCursor = page.pagination.nextCursor;
150 if (!page.pagination.hasMore) break;
151 if (nextCursor === null) {
152 return yield* Effect.fail(
153 new PrismaPaginationError({
154 message:
155 "Invalid Prisma Management API pagination response from getProjectBranches: hasMore was true without a non-empty nextCursor",
156 }),
157 );
158 }
159 cursor = nextCursor;
160 }
161 return branches;
162 });
163
164const listApps = (projectId?: string) =>
165 Effect.gen(function* () {
166 const apps: GetServicesResponse["data"][number][] = [];
167 let cursor: string | undefined;
168 while (true) {
169 const page = yield* getServices({
170 limit: 100,
171 ...(projectId === undefined ? {} : { projectId }),
172 ...(cursor === undefined ? {} : { cursor }),
173 });
174 apps.push(...page.data);
175 const nextCursor = page.pagination.nextCursor;
176 if (!page.pagination.hasMore) break;
177 if (nextCursor === null) {
178 return yield* Effect.fail(
179 new PrismaPaginationError({
180 message:
181 "Invalid Prisma Management API pagination response from getServices: hasMore was true without a non-empty nextCursor",
182 }),
183 );
184 }
185 cursor = nextCursor;
186 }
187 return apps;
188 });
189
190const desiredBranchId = Effect.fn(function* (
191 projectId: string,
192 props: Pick<AppProps, "branchId" | "branchGitName">,
193) {
194 if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) {
195 return { resolved: true as const, id: props.branchId };
196 }
197 if (props.branchGitName !== undefined) {
198 const branches = yield* listBranches(projectId, props.branchGitName);
199 if (branches.length > 1) {
200 return yield* Effect.fail(
201 new Error(
202 `Prisma returned multiple branches named '${props.branchGitName}' in project '${projectId}'; refusing an ambiguous App match.`,
203 ),
204 );
205 }
206 return branches[0]
207 ? { resolved: true as const, id: branches[0].id }
208 : { resolved: false as const };
209 }
210 const branches = yield* listBranches(projectId);
211 const defaults = branches.filter((branch) => branch.isDefault);
212 if (defaults.length > 1) {
213 return yield* Effect.fail(
214 new Error(
215 `Prisma returned multiple default branches for project '${projectId}'; refusing an ambiguous App match.`,
216 ),
217 );
218 }
219 const defaultBranch = defaults[0];
220 return defaultBranch
221 ? { resolved: true as const, id: defaultBranch.id }
222 : { resolved: false as const };
223});
224
225const createDisplayName = (id: string, displayName: string | undefined) =>
226 displayName === undefined
227 ? createPhysicalName({ id })
228 : Effect.succeed(displayName);
229
230const findApp = Effect.fn(function* (
231 projectId: string,
232 displayName: string,
233 props: Pick<AppProps, "branchId" | "branchGitName">,
234) {
235 const candidates = (yield* listApps(projectId)).filter(
236 (app) => app.name === displayName,
237 );
238 if (candidates.length === 0) return undefined;
239 const branch = yield* desiredBranchId(projectId, props);
240 if (!branch.resolved) return undefined;
241 const matches = candidates.filter((app) => app.branchId === branch.id);
242 if (matches.length > 1) {
243 return yield* Effect.fail(
244 new Error(
245 `Prisma returned multiple Apps named '${displayName}' on branch '${branch.id}' in project '${projectId}'; refusing an ambiguous ownership match.`,
246 ),
247 );
248 }
249 return matches[0];
250});
251
252const attrsFrom = (app: ObservedApp): App["Attributes"] => ({
253 appId: app.id,
254 name: app.name,
255 projectId: app.projectId,
256 regionId: app.region.id,
257 branchId: app.branchId,
258 latestDeploymentId: app.latestDeploymentId,
259 appEndpointDomain: app.appEndpointDomain,
260 createdAt: app.createdAt,
261});
262
263const branchNeedsSync = Effect.fn(function* (
264 projectId: string,
265 app: ObservedApp,
266 props: AppProps,
267) {
268 if (props.branchId !== undefined && !isPrismaDevId(props.branchId)) {
269 return app.branchId !== props.branchId;
270 }
271 if (props.branchGitName === undefined) {
272 const branch = yield* desiredBranchId(projectId, props);
273 return !branch.resolved || app.branchId !== branch.id;
274 }
275 const branch = yield* desiredBranchId(projectId, props);
276 return !branch.resolved || branch.id !== app.branchId;
277});
278
279const validateAppProps = (props: AppProps) =>
280 Effect.gen(function* () {
281 if (props.branchId !== undefined && props.branchGitName !== undefined) {
282 return yield* Effect.fail(
283 new Error("branchId and branchGitName are mutually exclusive."),
284 );
285 }
286 if (props.branchId === null || props.branchGitName === null) {
287 return yield* Effect.fail(
288 new Error(
289 "Prisma.App requires an attached branch because the Management API cannot create an unassigned App atomically. Omit both fields to use the project default branch, or provide branchId/branchGitName.",
290 ),
291 );
292 }
293 });
294
295const ProviderLive = () =>
296 Provider.effect(
297 App,
298 Effect.gen(function* () {
299 return {
300 stables: ["appId"],
301 list: () => listApps().pipe(Effect.map((apps) => apps.map(attrsFrom))),
302 diff: Effect.fn(function* ({ id, olds, news, output }) {
303 if (!isInputObject(news)) return undefined;
304 if (isPrismaDevId(output?.appId)) {
305 return { action: "update" } as const;
306 }
307 const oldProjectId =
308 output?.projectId ?? unresolvedProjectIdOf(olds.project);
309 const newProjectId = isResolved(news.project)
310 ? unresolvedProjectIdOf(news.project)
311 : undefined;
312 if (concreteIdsChanged(oldProjectId, newProjectId)) {
313 return { action: "replace" } as const;
314 }
315 if (isResolved(news.regionId) && news.regionId !== undefined) {
316 const currentRegionId = output?.regionId ?? olds.regionId;
317 if (
318 currentRegionId !== undefined &&
319 news.regionId !== currentRegionId
320 ) {
321 return yield* Effect.fail(
322 new Error(
323 `Prisma App region is immutable and the Management API cannot atomically move an App without deleting its serving endpoint first. Create a second App with a different display name in the target region, cut traffic over, then remove this App.`,
324 ),
325 );
326 }
327 }
328 const updateProps = {
329 displayName: news.displayName,
330 branchId: news.branchId,
331 branchGitName: news.branchGitName,
332 };
333 if (!isResolved(updateProps)) return undefined;
334 const resolvedUpdateProps = {
335 ...(updateProps as Pick<
336 AppProps,
337 "displayName" | "branchId" | "branchGitName"
338 >),
339 displayName: yield* createDisplayName(
340 id,
341 (updateProps as Pick<AppProps, "displayName">).displayName,
342 ),
343 };
344 if (!output) {
345 return deepEqual(resolvedUpdateProps, {
346 displayName: yield* createDisplayName(id, olds.displayName),
347 branchId: olds.branchId,
348 branchGitName: olds.branchGitName,
349 })
350 ? undefined
351 : ({ action: "update" } as const);
352 }
353 if (output.name !== resolvedUpdateProps.displayName) {
354 return { action: "update" } as const;
355 }
356 const branch = yield* desiredBranchId(
357 newProjectId ?? output.projectId,
358 resolvedUpdateProps,
359 );
360 return !branch.resolved || output.branchId !== branch.id
361 ? ({ action: "update" } as const)
362 : undefined;
363 }),
364 read: Effect.fn(function* ({ id, output, olds }) {
365 const appId = isPrismaDevId(output?.appId)
366 ? undefined
367 : output?.appId;
368 const app = appId
369 ? yield* getService({ serviceId: appId }).pipe(
370 Effect.map((response) => response.data),
371 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
372 )
373 : yield* Effect.gen(function* () {
374 const projectId = unresolvedProjectIdOf(olds.project);
375 return projectId
376 ? yield* findApp(
377 projectId,
378 yield* createDisplayName(id, olds.displayName),
379 olds,
380 )
381 : undefined;
382 });
383 if (!app) return undefined;
384 const attrs = attrsFrom(app);
385 return appId ? attrs : Unowned(attrs);
386 }),
387 reconcile: Effect.fn(function* ({ id, news, output }) {
388 yield* validateAppProps(news);
389 const projectId = yield* resolveProjectId(news.project);
390 const displayName = yield* createDisplayName(id, news.displayName);
391 const branch = yield* desiredBranchId(projectId, news);
392 if (!branch.resolved) {
393 return yield* Effect.fail(
394 new Error(
395 news.branchGitName === undefined
396 ? `Prisma project '${projectId}' has no default branch to attach App '${displayName}'. Create or promote a default branch, or specify branchId/branchGitName.`
397 : `Prisma project '${projectId}' has no branch named '${news.branchGitName}' to attach App '${displayName}'.`,
398 ),
399 );
400 }
401 const appId = isPrismaDevId(output?.appId)
402 ? undefined
403 : output?.appId;
404 let app: ObservedApp | undefined = appId
405 ? yield* getService({ serviceId: appId }).pipe(
406 Effect.map((response) => response.data),
407 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
408 )
409 : undefined;
410 if (!app) {
411 const result = yield* createService({
412 projectId,
413 displayName,
414 branchId: branch.id,
415 ...(news.regionId === undefined
416 ? {}
417 : { regionId: news.regionId }),
418 }).pipe(
419 // A replayed create would make a second App; the retry policy
420 // cannot see the request, so opt out explicitly.
421 Retry.none,
422 Effect.map((response) => ({
423 app: response.data,
424 created: true,
425 })),
426 Effect.catchTag("Conflict", (conflict) =>
427 findApp(projectId, displayName, news).pipe(
428 Effect.flatMap((app) =>
429 app &&
430 output?.appId !== undefined &&
431 app.id === output.appId
432 ? Effect.succeed({ app, created: false })
433 : Effect.fail(
434 new Error(
435 `Prisma app '${displayName}' already exists on the requested branch but is not owned by this App resource. Import it with explicit adoption or choose a different display name.`,
436 { cause: conflict },
437 ),
438 ),
439 ),
440 ),
441 ),
442 );
443 app = result.app;
444 }
445 yield* ensureAppImmutableIdentity(
446 app,
447 projectId,
448 news.regionId ?? output?.regionId ?? app.region.id,
449 );
450 const needsBranchSync = yield* branchNeedsSync(projectId, app, news);
451 if (app.name !== displayName || needsBranchSync) {
452 app = yield* updateService({
453 serviceId: app.id,
454 displayName,
455 branchId: branch.id,
456 }).pipe(Effect.map((response) => response.data));
457 }
458 if (app.name !== displayName || app.branchId !== branch.id) {
459 return yield* Effect.fail(
460 new Error(
461 `Prisma App '${app.id}' did not converge to display name '${displayName}' and branch '${branch.id ?? "null"}'. Refusing to persist mismatched App state.`,
462 ),
463 );
464 }
465 return attrsFrom(app);
466 }),
467 delete: Effect.fn(function* ({ output }) {
468 if (isPrismaDevId(output.appId)) return;
469 const app = yield* getService({ serviceId: output.appId }).pipe(
470 Effect.map((response) => response.data),
471 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
472 );
473 if (!app) return;
474 if (
475 app.projectId !== output.projectId ||
476 app.region.id !== output.regionId
477 ) {
478 return yield* Effect.fail(
479 new Error(
480 `Prisma App '${app.id}' no longer matches its persisted immutable project and region identity. Refusing to delete a mismatched App.`,
481 ),
482 );
483 }
484 yield* destroyApp(output.appId);
485 }),
486 };
487 }),
488 );
489
490const ProviderLocal = () =>
491 devProvider(App, ["appId"], ({ id, news }) => ({
492 appId: devId("app", id),
493 name: news.displayName ?? id,
494 projectId: attrOrString(news.project, "projectId"),
495 regionId: news.regionId ?? "us-east-1",
496 branchId: news.branchId ?? null,
497 latestDeploymentId: null,
498 appEndpointDomain: "localhost",
499 createdAt: DEV_TIMESTAMP,
500 }));
501
502export const AppProvider = () =>
503 ProviderLayer.dual(App, {
504 local: () => ProviderLocal(),
505 live: () => ProviderLive(),
506 });