@samebase/alchemy-convex 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,748 @@
1
+ // Convex.Deployment: one deployment of a Convex project, its environment
2
+ // variables, and its functions, with the URLs that a frontend or a Worker
3
+ // needs. This is the Convex plane of a stack: no deploy key leaves it.
4
+ //
5
+ // - `source` is the Convex functions directory, such as `./convex`. The
6
+ // update pushes it with the Convex CLI that the app installed (Push.ts).
7
+ // The content identity of the push is a hash of the source files, the
8
+ // app's package.json, convex.json, and lockfile, the deployment name, and
9
+ // `env`. The same hash is a no-op. The hash is taken again after the push,
10
+ // so files that `convex deploy` generates are part of it.
11
+ // `source.command` replaces `convex deploy`, such as `convex-hosting
12
+ // deploy`, and `source.include` adds the files that it reads to the hash.
13
+ // - `env` is set on the deployment before the push. A name that leaves `env`
14
+ // is removed. A variable that exists with another value and that this
15
+ // resource did not set is someone else's: it is overwritten only with
16
+ // --adopt. Delete leaves the variables: they go with the deployment.
17
+ // - Credentials by scope (see Access below): a deploy key of the deployment,
18
+ // a preview deploy key of the project, or the Management API token.
19
+ // - `prod` is the project's default production deployment. Convex creates it
20
+ // with the project, so this resource never creates one. It takes the
21
+ // deployment over without --adopt: it never changes or deletes a production
22
+ // deployment, so the takeover cannot lose data. It follows the default:
23
+ // when another production deployment becomes the default, the next deploy
24
+ // outputs that one. With a deploy key, `prod` is the production deployment
25
+ // of that key. To delete a production deployment, delete its project
26
+ // (Convex.Project with RemovalPolicy.destroy()). Delete reads the live type
27
+ // first, so a dev or preview deployment that became a production
28
+ // deployment is not deleted either.
29
+ // - `dev` and `preview` are created with POST
30
+ // /projects/{project_id}/create_deployment and the `name` prop as the
31
+ // reference. Convex keeps a dev reference as given. For a preview, Convex
32
+ // records `name` as the preview identifier, the name that
33
+ // `npx convex deploy --preview-name <name>` finds, and derives the reference
34
+ // from it (`preview/<slug>`). A create for a preview name that already has
35
+ // a preview deployment deletes that deployment and creates a new one, so
36
+ // reconcile always looks the name up first and never creates over an
37
+ // existing deployment. A dev or preview deployment that exists but is not
38
+ // in state belongs to someone else: the resource takes it over only with
39
+ // --adopt.
40
+ // - `projectId`, `type`, and `name` are the identity. A change fails with
41
+ // DeploymentIdentityChange; the resource is never replaced.
42
+ // - The removal policy is `retain` for every type. A preview deployment
43
+ // expires on Convex's schedule (14 days by default). To delete a dev or
44
+ // preview deployment with the resource, add RemovalPolicy.destroy().
45
+ //
46
+ // Patterns from Confect `packages/alchemy/src/Deployment.ts`
47
+ // (https://github.com/rjdellecese/confect, ISC license, see NOTICE).
48
+ import { Resource } from "alchemy";
49
+ import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
50
+ import { isResolved } from "alchemy/Diff";
51
+ import * as Provider from "alchemy/Provider";
52
+ import * as Effect from "effect/Effect";
53
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
54
+ import * as Redacted from "effect/Redacted";
55
+ import * as Schema from "effect/Schema";
56
+ import { shouldAdopt } from "./Adoption.ts";
57
+ import { Credentials } from "./Credentials.ts";
58
+ import {
59
+ absentAsUndefined,
60
+ type ConvexApiError,
61
+ ManagementApi,
62
+ type ManagementApiService,
63
+ retryIdempotentWrite,
64
+ } from "./ManagementApi.ts";
65
+ import type { Providers } from "./Providers.ts";
66
+ import {
67
+ checkSource,
68
+ EnvironmentNeedsDeployKey,
69
+ type EnvValue,
70
+ keyTargetOf,
71
+ push,
72
+ pushHash,
73
+ type Source,
74
+ withPushKey,
75
+ } from "./Push.ts";
76
+
77
+ interface DeploymentInputs {
78
+ /** The project: a Convex.Project resource, or `{ projectId }`. Cannot change. */
79
+ readonly project: { readonly projectId: number };
80
+ /**
81
+ * The Convex functions directory, such as `./convex`, or that directory
82
+ * with a push command and more hashed files. The push runs in the nearest
83
+ * directory above it with a package.json. Without `source`, the resource
84
+ * pushes nothing.
85
+ */
86
+ readonly source?: Source;
87
+ /** Environment variables to set on the deployment before the push. */
88
+ readonly env?: Readonly<Record<string, EnvValue>>;
89
+ }
90
+
91
+ export type DeploymentProps =
92
+ | (DeploymentInputs & {
93
+ /** The project's default production deployment. Cannot change. */
94
+ readonly type: "prod";
95
+ })
96
+ | (DeploymentInputs & {
97
+ /** A dev deployment or a preview deployment that this resource creates. Cannot change. */
98
+ readonly type: "dev" | "preview";
99
+ /**
100
+ * The dev reference, such as `staging`, or the preview name, such as
101
+ * the stage name. Unique in the project. Cannot change.
102
+ */
103
+ readonly name: string;
104
+ });
105
+
106
+ /** The deployment itself, without the state of the push. */
107
+ interface DeploymentTarget {
108
+ readonly projectId: number;
109
+ readonly type: DeploymentProps["type"];
110
+ /** Deployment name, such as `happy-animal-123`. */
111
+ readonly name: string;
112
+ /** The reference that Convex lists, such as `production`, `staging`, or `preview/pr-42`. */
113
+ readonly reference: string;
114
+ /** The preview name of a preview deployment. Undefined for prod and dev. */
115
+ readonly previewName: string | undefined;
116
+ /** Client URL, such as `https://happy-animal-123.convex.cloud`. */
117
+ readonly url: string;
118
+ /** HTTP actions URL, such as `https://happy-animal-123.convex.site`. */
119
+ readonly siteUrl: string;
120
+ }
121
+
122
+ export interface DeploymentAttributes extends DeploymentTarget {
123
+ /** Content identity of the last push and `env`. Undefined when the resource pushed nothing. */
124
+ readonly hash: string | undefined;
125
+ /** The names of the `env` variables that this resource set on the deployment, sorted. */
126
+ readonly envNames: readonly string[];
127
+ }
128
+
129
+ export type Deployment = Resource<
130
+ "Convex.Deployment",
131
+ DeploymentProps,
132
+ DeploymentAttributes,
133
+ never,
134
+ Providers
135
+ >;
136
+ export const Deployment = Resource<Deployment>("Convex.Deployment", {
137
+ defaultRemovalPolicy: "retain",
138
+ });
139
+
140
+ /** The props name another deployment than the one in state. */
141
+ export class DeploymentIdentityChange extends Schema.TaggedError<DeploymentIdentityChange>()(
142
+ "DeploymentIdentityChange",
143
+ {
144
+ /** Name of the deployment in state, such as `happy-animal-123`. */
145
+ deployment: Schema.String,
146
+ field: Schema.Literals(["projectId", "type", "name"]),
147
+ current: Schema.String,
148
+ requested: Schema.String,
149
+ },
150
+ ) {
151
+ override get message() {
152
+ return `Convex.Deployment cannot change the ${this.field} of deployment ${this.deployment} from ${this.current} to ${this.requested}. Set ${this.field} back to ${this.current}. For another deployment, create a new resource with a new logical id.`;
153
+ }
154
+ }
155
+
156
+ /** The project has no default production deployment for a `prod` resource to take over. */
157
+ export class ProductionDeploymentNotFound extends Schema.TaggedError<ProductionDeploymentNotFound>()(
158
+ "ProductionDeploymentNotFound",
159
+ { projectId: Schema.Number },
160
+ ) {
161
+ override get message() {
162
+ return `Project ${this.projectId} has no default production deployment, and Convex.Deployment does not create one. Create it in the Convex dashboard, or with \`npx convex deployment create --type prod --default\` in the project, then run the deploy again.`;
163
+ }
164
+ }
165
+
166
+ /** More than one deployment of the project has the name, so the lookup cannot choose one. */
167
+ export class AmbiguousDeployment extends Schema.TaggedError<AmbiguousDeployment>()(
168
+ "AmbiguousDeployment",
169
+ {
170
+ projectId: Schema.Number,
171
+ type: Schema.Literals(["dev", "preview"]),
172
+ name: Schema.String,
173
+ deployments: Schema.Array(Schema.String),
174
+ },
175
+ ) {
176
+ override get message() {
177
+ return `Project ${this.projectId} has ${this.deployments.length} ${this.type} deployments named "${this.name}" (${this.deployments.join(", ")}). Convex.Deployment does not choose one. Delete the extra deployments in the Convex dashboard, or use another name.`;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * The URL of a Convex cloud deployment: `https://<name>.convex.cloud`, or
183
+ * `https://<name>.<region>.convex.cloud` outside US East.
184
+ */
185
+ const CLOUD_URL =
186
+ /^https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud$/;
187
+
188
+ /**
189
+ * The HTTP actions URL of a deployment: the client URL with `.convex.site`
190
+ * for `.convex.cloud`, as Convex sets CONVEX_SITE_URL. Custom domains are
191
+ * not covered.
192
+ */
193
+ export const siteUrlOf = (url: string): string => url.replace(/\.convex\.cloud$/, ".convex.site");
194
+
195
+ /** One row of list_deployments, GET /deployments/{name}, and create_deployment. Only the fields the provider uses. */
196
+ export const DeploymentRow = Schema.Struct({
197
+ name: Schema.String,
198
+ projectId: Schema.Number,
199
+ deploymentType: Schema.String,
200
+ isDefault: Schema.Boolean,
201
+ reference: Schema.String,
202
+ /** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
203
+ previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
204
+ deploymentUrl: Schema.String.check(Schema.isPattern(CLOUD_URL)),
205
+ /** Absent or null until the first push: list_deployments leaves it out for a new deployment. */
206
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number)),
207
+ });
208
+ type DeploymentRow = typeof DeploymentRow.Type;
209
+ const DeploymentRows = Schema.Array(DeploymentRow);
210
+
211
+ /** GET https://<deployment>/api/v1/deployment_info, for a cloud deployment. Only the fields the provider uses. */
212
+ const DeploymentInfo = Schema.Struct({
213
+ kind: Schema.Literal("cloud"),
214
+ projectId: Schema.Number,
215
+ deploymentType: Schema.String,
216
+ reference: Schema.optionalKey(Schema.NullOr(Schema.String)),
217
+ });
218
+
219
+ /** GET https://<deployment>/api/v1/get_canonical_urls */
220
+ const CanonicalUrls = Schema.Struct({
221
+ convexCloudUrl: Schema.String,
222
+ convexSiteUrl: Schema.String,
223
+ });
224
+
225
+ const targetOf = (
226
+ row: DeploymentRow,
227
+ type: DeploymentProps["type"],
228
+ previewName: string | undefined,
229
+ ): DeploymentTarget => ({
230
+ projectId: row.projectId,
231
+ type,
232
+ name: row.name,
233
+ reference: row.reference,
234
+ previewName: type === "preview" ? previewName : undefined,
235
+ url: row.deploymentUrl,
236
+ siteUrl: siteUrlOf(row.deploymentUrl),
237
+ });
238
+
239
+ /** True when the deployment has received a push. */
240
+ const isDeployed = (row: DeploymentRow) =>
241
+ row.lastDeployTime !== undefined && row.lastDeployTime !== null;
242
+
243
+ /** The `name` prop of a deployment: the dev reference or the preview name. */
244
+ const nameOf = (target: DeploymentTarget) =>
245
+ target.type === "dev"
246
+ ? target.reference
247
+ : target.type === "preview"
248
+ ? target.previewName
249
+ : undefined;
250
+
251
+ /**
252
+ * Fails with {@link DeploymentIdentityChange} when the props name another
253
+ * deployment than `current`: the attributes in state, or a live deployment.
254
+ */
255
+ const checkIdentity = (current: DeploymentTarget, news: DeploymentProps) => {
256
+ const change = (field: "projectId" | "type" | "name", from: string, to: string) =>
257
+ Effect.fail(
258
+ new DeploymentIdentityChange({
259
+ deployment: current.name,
260
+ field,
261
+ current: from,
262
+ requested: to,
263
+ }),
264
+ );
265
+ if (current.projectId !== news.project.projectId) {
266
+ return change("projectId", String(current.projectId), String(news.project.projectId));
267
+ }
268
+ if (current.type !== news.type) return change("type", current.type, news.type);
269
+ const currentName = nameOf(current);
270
+ if (news.type !== "prod" && currentName !== news.name) {
271
+ return change("name", currentName ?? "(none)", news.name);
272
+ }
273
+ return Effect.void;
274
+ };
275
+
276
+ /** The deployment that a resource addresses: its project, type, and dev or preview name. */
277
+ interface Address {
278
+ readonly projectId: number;
279
+ readonly type: DeploymentProps["type"];
280
+ readonly name: string | undefined;
281
+ }
282
+
283
+ const addressOf = (props: DeploymentProps): Address => ({
284
+ projectId: props.project.projectId,
285
+ type: props.type,
286
+ name: props.type === "prod" ? undefined : props.name,
287
+ });
288
+
289
+ /** True when an update writes or removes variables: `env` has names, or state has names to remove. */
290
+ const changesEnv = (news: DeploymentProps, output: DeploymentAttributes | undefined) =>
291
+ Object.keys(news.env ?? {}).length > 0 || envNamesOf(output).length > 0;
292
+
293
+ /** The `env` names in state. State from 0.3 has none. */
294
+ const envNamesOf = (output: DeploymentAttributes | undefined): readonly string[] =>
295
+ output?.envNames ?? [];
296
+
297
+ /** The address of the deployment in state. */
298
+ const addressInState = (output: DeploymentAttributes): Address => ({
299
+ projectId: output.projectId,
300
+ type: output.type,
301
+ name: nameOf(output),
302
+ });
303
+
304
+ /**
305
+ * How the provider reaches one deployment, by the narrowest credential that
306
+ * fits:
307
+ *
308
+ * - `deployKey`: the configured deploy key belongs to this deployment. The
309
+ * deployment API answers its identity and URLs, the key sets variables and
310
+ * pushes, and no Management API call is made.
311
+ * - `previewKey`: the configured preview deploy key belongs to this project.
312
+ * The Management API takes it for the preview deployments of the project,
313
+ * and the CLI pushes with it and `--preview-name`.
314
+ * - `token`: the Management API access token. Each push gets a short-lived
315
+ * deploy key (Push.ts).
316
+ */
317
+ type Access =
318
+ | {
319
+ readonly kind: "deployKey";
320
+ readonly key: Redacted.Redacted<string>;
321
+ readonly target: DeploymentTarget;
322
+ }
323
+ | {
324
+ readonly kind: "previewKey";
325
+ readonly key: Redacted.Redacted<string>;
326
+ readonly api: ManagementApiService;
327
+ }
328
+ | { readonly kind: "token"; readonly api: ManagementApiService };
329
+
330
+ const ProjectSlugs = Schema.Struct({ slug: Schema.String, teamSlug: Schema.String });
331
+
332
+ /** True for an answer that says this credential cannot read the object. */
333
+ const isRefused = (error: ConvexApiError) =>
334
+ error.status === 400 || error.status === 401 || error.status === 403 || error.status === 404;
335
+
336
+ export const DeploymentProvider = () =>
337
+ Provider.effect(
338
+ Deployment,
339
+ Effect.gen(function* () {
340
+ const api = yield* ManagementApi;
341
+ const credentials = yield* Credentials;
342
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
343
+
344
+ /** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
345
+ const getByName = (management: ManagementApiService, name: string) =>
346
+ absentAsUndefined(
347
+ management.request("get deployment", (client) =>
348
+ client.GET("/deployments/{deployment_name}", {
349
+ params: { path: { deployment_name: name } },
350
+ }),
351
+ ),
352
+ ).pipe(
353
+ Effect.map((body) =>
354
+ body === undefined ? undefined : Schema.decodeUnknownSync(DeploymentRow)(body),
355
+ ),
356
+ );
357
+
358
+ const listDeployments = (
359
+ management: ManagementApiService,
360
+ projectId: number,
361
+ type: DeploymentProps["type"],
362
+ isDefault?: boolean,
363
+ ) =>
364
+ management
365
+ .request("list deployments", (client) =>
366
+ client.GET("/projects/{project_id}/list_deployments", {
367
+ params: {
368
+ path: { project_id: projectId },
369
+ query: { deploymentType: type, ...(isDefault === undefined ? {} : { isDefault }) },
370
+ },
371
+ }),
372
+ )
373
+ .pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows)(body)));
374
+
375
+ /** The row of the project's default production deployment, or undefined. */
376
+ const productionRow = (management: ManagementApiService, projectId: number) =>
377
+ listDeployments(management, projectId, "prod", true).pipe(
378
+ Effect.map((rows) =>
379
+ rows.find((candidate) => candidate.deploymentType === "prod" && candidate.isDefault),
380
+ ),
381
+ );
382
+
383
+ /** The project's default production deployment, or undefined. */
384
+ const production = (management: ManagementApiService, projectId: number) =>
385
+ productionRow(management, projectId).pipe(
386
+ Effect.map((row) => (row === undefined ? undefined : targetOf(row, "prod", undefined))),
387
+ );
388
+
389
+ /**
390
+ * The one dev deployment with this reference, or the one preview
391
+ * deployment with this preview name, or undefined.
392
+ */
393
+ const findByName = (
394
+ management: ManagementApiService,
395
+ projectId: number,
396
+ type: "dev" | "preview",
397
+ name: string,
398
+ ) =>
399
+ Effect.gen(function* () {
400
+ const matches = (yield* listDeployments(management, projectId, type)).filter(
401
+ (row) =>
402
+ row.deploymentType === type &&
403
+ (type === "dev" ? row.reference === name : row.previewIdentifier === name),
404
+ );
405
+ if (matches.length > 1) {
406
+ return yield* new AmbiguousDeployment({
407
+ projectId,
408
+ type,
409
+ name,
410
+ deployments: matches.map((row) => row.name),
411
+ });
412
+ }
413
+ return matches[0];
414
+ });
415
+
416
+ /** The deployment that the address names, through the Management API, or undefined. */
417
+ const lookup = (management: ManagementApiService, address: Address) =>
418
+ address.type === "prod" || address.name === undefined
419
+ ? production(management, address.projectId)
420
+ : findByName(management, address.projectId, address.type, address.name).pipe(
421
+ Effect.map((row) =>
422
+ row === undefined ? undefined : targetOf(row, address.type, address.name),
423
+ ),
424
+ );
425
+
426
+ /** The deployment of a deploy key, when it is the deployment that the address names. */
427
+ const deployKeyTarget = (
428
+ key: Redacted.Redacted<string>,
429
+ deployment: string,
430
+ address: Address,
431
+ ) =>
432
+ Effect.gen(function* () {
433
+ const info = Schema.decodeUnknownSync(DeploymentInfo)(
434
+ yield* api.deploymentRequest("get deployment info", deployment, key, (client) =>
435
+ client.GET("/deployment_info"),
436
+ ),
437
+ );
438
+ const reference = info.reference ?? "";
439
+ if (
440
+ info.projectId !== address.projectId ||
441
+ info.deploymentType !== address.type ||
442
+ (address.type === "dev" && reference !== address.name)
443
+ ) {
444
+ return undefined;
445
+ }
446
+ const urls = Schema.decodeUnknownSync(CanonicalUrls)(
447
+ yield* api.deploymentRequest("get canonical urls", deployment, key, (client) =>
448
+ client.GET("/get_canonical_urls"),
449
+ ),
450
+ );
451
+ return {
452
+ projectId: info.projectId,
453
+ type: address.type,
454
+ name: deployment,
455
+ reference,
456
+ previewName: undefined,
457
+ url: urls.convexCloudUrl,
458
+ siteUrl: urls.convexSiteUrl,
459
+ } satisfies DeploymentTarget;
460
+ }).pipe(Effect.catchIf(isRefused, () => Effect.succeed(undefined)));
461
+
462
+ /** True when the preview deploy key belongs to the project. */
463
+ const previewKeyFits = (
464
+ management: ManagementApiService,
465
+ slugs: { readonly team: string; readonly project: string },
466
+ projectId: number,
467
+ ) =>
468
+ management
469
+ .request("get project", (client) =>
470
+ client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }),
471
+ )
472
+ .pipe(
473
+ Effect.map((body) => {
474
+ const project = Schema.decodeUnknownSync(ProjectSlugs)(body);
475
+ return project.slug === slugs.project && project.teamSlug === slugs.team;
476
+ }),
477
+ Effect.catchIf(isRefused, () => Effect.succeed(false)),
478
+ );
479
+
480
+ /**
481
+ * The narrowest credential that reaches the deployment. See {@link Access}.
482
+ * When `changesEnv`, a preview deploy key gives way to the access token,
483
+ * because it cannot change variables.
484
+ */
485
+ const accessFor = (address: Address, changesEnv: boolean) =>
486
+ Effect.gen(function* (): Effect.fn.Return<Access, ConvexApiError> {
487
+ const key = yield* credentials.deployKey;
488
+ if (key !== undefined) {
489
+ const target = keyTargetOf(Redacted.value(key));
490
+ if (target.kind === "deployment" && address.type !== "preview") {
491
+ const found = yield* deployKeyTarget(key, target.deployment, address);
492
+ if (found !== undefined) return { kind: "deployKey", key, target: found };
493
+ }
494
+ if (target.kind === "preview" && address.type === "preview") {
495
+ const scoped = api.withToken(key);
496
+ if (
497
+ !(changesEnv && (yield* credentials.hasAccessToken)) &&
498
+ (yield* previewKeyFits(scoped, target, address.projectId))
499
+ ) {
500
+ return { kind: "previewKey", key, api: scoped };
501
+ }
502
+ }
503
+ }
504
+ return { kind: "token", api };
505
+ });
506
+
507
+ /** The live deployment of a resource in state, or undefined when it is gone. */
508
+ const live = (access: Access, output: DeploymentAttributes) =>
509
+ Effect.gen(function* () {
510
+ if (access.kind === "deployKey") return access.target;
511
+ // 404 for a deleted project: its production deployment is gone too.
512
+ if (output.type === "prod")
513
+ return yield* absentAsUndefined(production(access.api, output.projectId));
514
+ const row = yield* getByName(access.api, output.name);
515
+ return row === undefined ? undefined : targetOf(row, output.type, output.previewName);
516
+ });
517
+
518
+ /** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
519
+ const wantedHash = (news: DeploymentProps, deployment: string) =>
520
+ news.source === undefined && Object.keys(news.env ?? {}).length === 0
521
+ ? Effect.succeed(undefined)
522
+ : pushHash({ source: news.source, deployment, env: news.env ?? {} });
523
+
524
+ return {
525
+ // `name`, `reference`, and the URLs change when an expired preview is
526
+ // created again, or when another production deployment becomes the
527
+ // default.
528
+ stables: ["projectId", "type", "previewName"],
529
+
530
+ // Compares with the attributes: after a refused change, `olds` are
531
+ // the refused props.
532
+ diff: Effect.fn(function* ({ news, output }) {
533
+ if (!isResolved(news) || output === undefined) return undefined;
534
+ yield* checkIdentity(output, news);
535
+ const access = yield* accessFor(addressOf(news), changesEnv(news, output));
536
+ // A preview expires, anyone can delete a deployment, and another
537
+ // production deployment can become the default. Reconcile then
538
+ // finds or creates the right one, so the stack never hands out the
539
+ // URL of a deployment that is gone.
540
+ const current = yield* live(access, output);
541
+ if (current?.name !== output.name) return { action: "update" } as const;
542
+ const hash = yield* wantedHash(news, output.name);
543
+ return hash === output.hash
544
+ ? ({ action: "noop" } as const)
545
+ : ({ action: "update" } as const);
546
+ }),
547
+
548
+ read: Effect.fn(function* ({ olds, output }) {
549
+ if (output !== undefined) {
550
+ const access = yield* accessFor(addressInState(output), false);
551
+ const current = yield* live(access, output);
552
+ return current === undefined ? undefined : { ...output, ...current };
553
+ }
554
+ const address = addressOf(olds);
555
+ const access = yield* accessFor(address, false);
556
+ if (access.kind === "deployKey") {
557
+ // The deployment API does not say whether the deployment has code.
558
+ return Unowned({ ...access.target, hash: undefined, envNames: [] });
559
+ }
560
+ if (olds.type === "prod") {
561
+ const row = yield* productionRow(access.api, address.projectId);
562
+ if (row === undefined) return undefined;
563
+ const attributes = {
564
+ ...targetOf(row, "prod", undefined),
565
+ hash: undefined,
566
+ envNames: [],
567
+ };
568
+ // A production deployment that never received a push, such as
569
+ // the one of a project that this stack created, loses nothing to
570
+ // a push. One with code is someone else's until --adopt.
571
+ return isDeployed(row) ? Unowned(attributes) : attributes;
572
+ }
573
+ const found = yield* lookup(access.api, address);
574
+ if (found === undefined) return undefined;
575
+ // A dev or preview deployment that is not in state is someone
576
+ // else's until the user passes --adopt.
577
+ return Unowned({ ...found, hash: undefined, envNames: [] });
578
+ }),
579
+
580
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
581
+ // Also here: with an unresolved prop in plan, diff could not check.
582
+ if (output !== undefined) yield* checkIdentity(output, news);
583
+ // The functions directory and the program, before any change.
584
+ if (news.source !== undefined) yield* checkSource(news.source);
585
+ const address = addressOf(news);
586
+ const access = yield* accessFor(address, changesEnv(news, output));
587
+ const env = news.env ?? {};
588
+ // Before any create: a preview deploy key cannot change variables,
589
+ // and accessFor chose it because there is no access token.
590
+ if (access.kind === "previewKey" && changesEnv(news, output)) {
591
+ return yield* new EnvironmentNeedsDeployKey({
592
+ deployment: output?.name ?? address.name ?? "(new)",
593
+ });
594
+ }
595
+ const adopt = yield* shouldAdopt(fqn);
596
+ const refuse = (name: string) =>
597
+ new OwnedBySomeoneElse({
598
+ message: `Project ${address.projectId} already has ${news.type} deployment ${name}${news.type === "prod" ? " with code" : ` named "${news.name}"`}. Re-run with --adopt to take it over${news.type === "prod" ? "" : ", or use another name"}.`,
599
+ resourceType: Deployment.Type,
600
+ physicalName: name,
601
+ });
602
+
603
+ // 1. The deployment. `created` is true when this reconcile created it.
604
+ const { target, created } = yield* Effect.gen(function* () {
605
+ if (access.kind === "deployKey") {
606
+ // read() refused it without --adopt; this covers an apply without a plan.
607
+ if (output === undefined && !adopt) return yield* refuse(access.target.name);
608
+ return { target: access.target, created: false };
609
+ }
610
+ if (news.type === "prod") {
611
+ // Always the current default, also when another production
612
+ // deployment became the default after the last deploy.
613
+ const row = yield* productionRow(access.api, address.projectId);
614
+ if (row === undefined) {
615
+ return yield* new ProductionDeploymentNotFound({ projectId: address.projectId });
616
+ }
617
+ // Code that appeared after the plan is someone else's.
618
+ if (output === undefined && isDeployed(row) && !adopt) return yield* refuse(row.name);
619
+ return { target: targetOf(row, "prod", undefined), created: false };
620
+ }
621
+ if (output !== undefined) {
622
+ const row = yield* getByName(access.api, output.name);
623
+ if (row !== undefined) {
624
+ if (row.deploymentType !== output.type) {
625
+ return yield* new DeploymentIdentityChange({
626
+ deployment: row.name,
627
+ field: "type",
628
+ current: row.deploymentType,
629
+ requested: news.type,
630
+ });
631
+ }
632
+ const current = targetOf(row, output.type, output.previewName);
633
+ // The live deployment can have moved to another project.
634
+ yield* checkIdentity(current, news);
635
+ return { target: current, created: false };
636
+ }
637
+ // The deployment in state is gone, such as an expired preview.
638
+ // Find or create it below like a resource without state.
639
+ }
640
+ // Never create over an existing deployment: a preview create
641
+ // replaces the preview deployment with the same name. Another
642
+ // writer can still create one between this lookup and the create.
643
+ const existing = yield* lookup(access.api, address);
644
+ if (existing !== undefined) {
645
+ if (!adopt) return yield* refuse(existing.name);
646
+ return { target: existing, created: false };
647
+ }
648
+ const row = yield* access.api.request("create deployment", (client) =>
649
+ client.POST("/projects/{project_id}/create_deployment", {
650
+ params: { path: { project_id: address.projectId } },
651
+ body: { type: news.type, reference: news.name },
652
+ }),
653
+ );
654
+ return {
655
+ target: targetOf(Schema.decodeUnknownSync(DeploymentRow)(row), news.type, news.name),
656
+ created: true,
657
+ };
658
+ });
659
+
660
+ // 2. The variables and the push, only when their content changed.
661
+ // Variables set on another deployment stay there: they are not ours
662
+ // on this one.
663
+ const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
664
+ const before = yield* wantedHash(news, target.name);
665
+ if (before === undefined && previousEnvNames.length === 0) {
666
+ return { ...target, hash: undefined, envNames: [] };
667
+ }
668
+ if (output?.name === target.name && before === output.hash) {
669
+ return { ...target, hash: output.hash, envNames: previousEnvNames };
670
+ }
671
+ const options = {
672
+ deployment: target.name,
673
+ url: target.url,
674
+ source: news.source,
675
+ env,
676
+ previousEnvNames,
677
+ adopt,
678
+ };
679
+ const pushed =
680
+ access.kind === "deployKey"
681
+ ? push(spawner, access.key, { ...options, previewName: undefined })
682
+ : access.kind === "previewKey"
683
+ ? push(spawner, access.key, { ...options, previewName: target.previewName })
684
+ : withPushKey(access.api, target.name, news.source, (key) =>
685
+ push(spawner, key, { ...options, previewName: undefined }),
686
+ );
687
+ yield* pushed.pipe(
688
+ // A deployment that this reconcile created and could not push to
689
+ // would be someone else's on the next run, and a dev deployment
690
+ // never expires. It is new and empty, so it goes.
691
+ Effect.onError(() =>
692
+ created && access.kind !== "deployKey"
693
+ ? absentAsUndefined(
694
+ retryIdempotentWrite(
695
+ access.api.requestVoid("delete deployment", (client) =>
696
+ client.POST("/deployments/{deployment_name}/delete", {
697
+ params: { path: { deployment_name: target.name } },
698
+ }),
699
+ ),
700
+ ),
701
+ ).pipe(
702
+ Effect.catch((error) =>
703
+ Effect.logWarning(
704
+ `Convex.Deployment could not delete ${target.name} after the failed push (${error.message}). The next run takes it over only with --adopt.`,
705
+ ),
706
+ ),
707
+ )
708
+ : Effect.void,
709
+ ),
710
+ );
711
+ // Taken again after the push, so that files the push generates,
712
+ // such as convex/_generated, are part of the identity.
713
+ return {
714
+ ...target,
715
+ hash: yield* wantedHash(news, target.name),
716
+ envNames: Object.keys(env).sort(),
717
+ };
718
+ }),
719
+
720
+ // Runs only with RemovalPolicy.destroy(): the default policy is
721
+ // `retain`. The variables go with the deployment.
722
+ delete: Effect.fn(function* ({ output }) {
723
+ const keep = Effect.logWarning(
724
+ `Convex.Deployment does not delete production deployment ${output.name}: it belongs to project ${output.projectId}. To delete it, delete the project with RemovalPolicy.destroy() on Convex.Project.`,
725
+ );
726
+ if (output.type === "prod") return yield* keep;
727
+ const access = yield* accessFor(addressInState(output), false);
728
+ // A deploy key cannot delete its deployment.
729
+ const management = access.kind === "deployKey" ? api : access.api;
730
+ // The type in state can be old: a dev or preview deployment can
731
+ // become a production deployment in Convex. The live type decides.
732
+ const row = yield* getByName(management, output.name);
733
+ // 404: an earlier attempt, the expiry, or someone else deleted it.
734
+ if (row === undefined) return;
735
+ if (row.deploymentType === "prod") return yield* keep;
736
+ yield* absentAsUndefined(
737
+ retryIdempotentWrite(
738
+ management.requestVoid("delete deployment", (client) =>
739
+ client.POST("/deployments/{deployment_name}/delete", {
740
+ params: { path: { deployment_name: output.name } },
741
+ }),
742
+ ),
743
+ ),
744
+ );
745
+ }),
746
+ };
747
+ }),
748
+ );