@samebase/alchemy-convex 0.3.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.
package/src/Deployment.ts CHANGED
@@ -1,12 +1,28 @@
1
- // Convex.Deployment: one deployment of a Convex project, with the URLs that a
2
- // frontend or a Worker needs.
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.
3
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.
4
19
  // - `prod` is the project's default production deployment. Convex creates it
5
20
  // with the project, so this resource never creates one. It takes the
6
21
  // deployment over without --adopt: it never changes or deletes a production
7
22
  // deployment, so the takeover cannot lose data. It follows the default:
8
23
  // when another production deployment becomes the default, the next deploy
9
- // outputs that one. To delete a production deployment, delete its project
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
10
26
  // (Convex.Project with RemovalPolicy.destroy()). Delete reads the live type
11
27
  // first, so a dev or preview deployment that became a production
12
28
  // deployment is not deleted either.
@@ -28,37 +44,67 @@
28
44
  // preview deployment with the resource, add RemovalPolicy.destroy().
29
45
  //
30
46
  // Patterns from Confect `packages/alchemy/src/Deployment.ts`
31
- // (https://github.com/rjdellecese/confect, ISC license).
47
+ // (https://github.com/rjdellecese/confect, ISC license, see NOTICE).
32
48
  import { Resource } from "alchemy";
33
49
  import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
34
50
  import { isResolved } from "alchemy/Diff";
35
51
  import * as Provider from "alchemy/Provider";
36
52
  import * as Effect from "effect/Effect";
53
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
54
+ import * as Redacted from "effect/Redacted";
37
55
  import * as Schema from "effect/Schema";
38
56
  import { shouldAdopt } from "./Adoption.ts";
39
- import { absentAsUndefined, ManagementApi, retryIdempotentWrite } from "./ManagementApi.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";
40
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
+ }
41
90
 
42
91
  export type DeploymentProps =
43
- | {
44
- /** Numeric project id, such as `project.projectId`. Cannot change. */
45
- readonly projectId: number;
92
+ | (DeploymentInputs & {
46
93
  /** The project's default production deployment. Cannot change. */
47
94
  readonly type: "prod";
48
- }
49
- | {
50
- /** Numeric project id, such as `project.projectId`. Cannot change. */
51
- readonly projectId: number;
95
+ })
96
+ | (DeploymentInputs & {
52
97
  /** A dev deployment or a preview deployment that this resource creates. Cannot change. */
53
98
  readonly type: "dev" | "preview";
54
99
  /**
55
- * The dev reference, such as `staging`, or the preview name, such as a
56
- * pull request or stage name. Unique in the project. Cannot change.
100
+ * The dev reference, such as `staging`, or the preview name, such as
101
+ * the stage name. Unique in the project. Cannot change.
57
102
  */
58
103
  readonly name: string;
59
- };
104
+ });
60
105
 
61
- export interface DeploymentAttributes {
106
+ /** The deployment itself, without the state of the push. */
107
+ interface DeploymentTarget {
62
108
  readonly projectId: number;
63
109
  readonly type: DeploymentProps["type"];
64
110
  /** Deployment name, such as `happy-animal-123`. */
@@ -73,6 +119,13 @@ export interface DeploymentAttributes {
73
119
  readonly siteUrl: string;
74
120
  }
75
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
+
76
129
  export type Deployment = Resource<
77
130
  "Convex.Deployment",
78
131
  DeploymentProps,
@@ -149,15 +202,31 @@ export const DeploymentRow = Schema.Struct({
149
202
  /** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
150
203
  previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
151
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)),
152
207
  });
153
208
  type DeploymentRow = typeof DeploymentRow.Type;
154
209
  const DeploymentRows = Schema.Array(DeploymentRow);
155
210
 
156
- const attributesOf = (
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 = (
157
226
  row: DeploymentRow,
158
227
  type: DeploymentProps["type"],
159
228
  previewName: string | undefined,
160
- ): DeploymentAttributes => ({
229
+ ): DeploymentTarget => ({
161
230
  projectId: row.projectId,
162
231
  type,
163
232
  name: row.name,
@@ -167,19 +236,23 @@ const attributesOf = (
167
236
  siteUrl: siteUrlOf(row.deploymentUrl),
168
237
  });
169
238
 
170
- /** The `name` prop of the deployment in state: the dev reference or the preview name. */
171
- const nameOf = (attributes: DeploymentAttributes) =>
172
- attributes.type === "dev"
173
- ? attributes.reference
174
- : attributes.type === "preview"
175
- ? attributes.previewName
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
176
249
  : undefined;
177
250
 
178
251
  /**
179
252
  * Fails with {@link DeploymentIdentityChange} when the props name another
180
- * deployment than `current`: the attributes in state, or a live row.
253
+ * deployment than `current`: the attributes in state, or a live deployment.
181
254
  */
182
- const checkIdentity = (current: DeploymentAttributes, news: DeploymentProps) => {
255
+ const checkIdentity = (current: DeploymentTarget, news: DeploymentProps) => {
183
256
  const change = (field: "projectId" | "type" | "name", from: string, to: string) =>
184
257
  Effect.fail(
185
258
  new DeploymentIdentityChange({
@@ -189,8 +262,8 @@ const checkIdentity = (current: DeploymentAttributes, news: DeploymentProps) =>
189
262
  requested: to,
190
263
  }),
191
264
  );
192
- if (current.projectId !== news.projectId) {
193
- return change("projectId", String(current.projectId), String(news.projectId));
265
+ if (current.projectId !== news.project.projectId) {
266
+ return change("projectId", String(current.projectId), String(news.project.projectId));
194
267
  }
195
268
  if (current.type !== news.type) return change("type", current.type, news.type);
196
269
  const currentName = nameOf(current);
@@ -200,16 +273,78 @@ const checkIdentity = (current: DeploymentAttributes, news: DeploymentProps) =>
200
273
  return Effect.void;
201
274
  };
202
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
+
203
336
  export const DeploymentProvider = () =>
204
337
  Provider.effect(
205
338
  Deployment,
206
339
  Effect.gen(function* () {
207
340
  const api = yield* ManagementApi;
341
+ const credentials = yield* Credentials;
342
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
208
343
 
209
344
  /** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
210
- const getByName = (name: string) =>
345
+ const getByName = (management: ManagementApiService, name: string) =>
211
346
  absentAsUndefined(
212
- api.request("get deployment", (client) =>
347
+ management.request("get deployment", (client) =>
213
348
  client.GET("/deployments/{deployment_name}", {
214
349
  params: { path: { deployment_name: name } },
215
350
  }),
@@ -221,11 +356,12 @@ export const DeploymentProvider = () =>
221
356
  );
222
357
 
223
358
  const listDeployments = (
359
+ management: ManagementApiService,
224
360
  projectId: number,
225
361
  type: DeploymentProps["type"],
226
362
  isDefault?: boolean,
227
363
  ) =>
228
- api
364
+ management
229
365
  .request("list deployments", (client) =>
230
366
  client.GET("/projects/{project_id}/list_deployments", {
231
367
  params: {
@@ -236,24 +372,32 @@ export const DeploymentProvider = () =>
236
372
  )
237
373
  .pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows)(body)));
238
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
+
239
383
  /** The project's default production deployment, or undefined. */
240
- const production = (projectId: number) =>
241
- listDeployments(projectId, "prod", true).pipe(
242
- Effect.map((rows) => {
243
- const row = rows.find(
244
- (candidate) => candidate.deploymentType === "prod" && candidate.isDefault,
245
- );
246
- return row === undefined ? undefined : attributesOf(row, "prod", undefined);
247
- }),
384
+ const production = (management: ManagementApiService, projectId: number) =>
385
+ productionRow(management, projectId).pipe(
386
+ Effect.map((row) => (row === undefined ? undefined : targetOf(row, "prod", undefined))),
248
387
  );
249
388
 
250
389
  /**
251
390
  * The one dev deployment with this reference, or the one preview
252
391
  * deployment with this preview name, or undefined.
253
392
  */
254
- const findByName = (projectId: number, type: "dev" | "preview", name: string) =>
393
+ const findByName = (
394
+ management: ManagementApiService,
395
+ projectId: number,
396
+ type: "dev" | "preview",
397
+ name: string,
398
+ ) =>
255
399
  Effect.gen(function* () {
256
- const matches = (yield* listDeployments(projectId, type)).filter(
400
+ const matches = (yield* listDeployments(management, projectId, type)).filter(
257
401
  (row) =>
258
402
  row.deploymentType === type &&
259
403
  (type === "dev" ? row.reference === name : row.previewIdentifier === name),
@@ -269,16 +413,114 @@ export const DeploymentProvider = () =>
269
413
  return matches[0];
270
414
  });
271
415
 
272
- /** The deployment that the props name, for a resource without state. */
273
- const lookup = (news: DeploymentProps) =>
274
- news.type === "prod"
275
- ? production(news.projectId)
276
- : findByName(news.projectId, news.type, news.name).pipe(
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(
277
421
  Effect.map((row) =>
278
- row === undefined ? undefined : attributesOf(row, news.type, news.name),
422
+ row === undefined ? undefined : targetOf(row, address.type, address.name),
279
423
  ),
280
424
  );
281
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
+
282
524
  return {
283
525
  // `name`, `reference`, and the URLs change when an expired preview is
284
526
  // created again, or when another production deployment becomes the
@@ -290,113 +532,210 @@ export const DeploymentProvider = () =>
290
532
  diff: Effect.fn(function* ({ news, output }) {
291
533
  if (!isResolved(news) || output === undefined) return undefined;
292
534
  yield* checkIdentity(output, news);
293
- // `prod` follows the project's default production deployment,
294
- // which can change in Convex. Reconcile then returns the new one.
295
- if (news.type === "prod") {
296
- // A 404 for a deleted project plans an update; reconcile then reports it.
297
- const current = yield* absentAsUndefined(production(news.projectId));
298
- return current?.name === output.name ? undefined : ({ action: "update" } as const);
299
- }
300
- // A preview expires, and anyone can delete a deployment. Reconcile
301
- // then finds or creates it again, so the stack never hands out the
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
302
539
  // URL of a deployment that is gone.
303
- const live = yield* getByName(output.name);
304
- return live === undefined ? ({ action: "update" } as const) : undefined;
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);
305
546
  }),
306
547
 
307
548
  read: Effect.fn(function* ({ olds, output }) {
308
- if (output?.type === "prod") {
309
- // 404: the project is gone, and so is its production deployment.
310
- return yield* absentAsUndefined(production(output.projectId));
311
- }
312
549
  if (output !== undefined) {
313
- const live = yield* getByName(output.name);
314
- return live === undefined
315
- ? undefined
316
- : attributesOf(live, output.type, output.previewName);
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;
317
572
  }
318
- const found = yield* lookup(olds);
573
+ const found = yield* lookup(access.api, address);
319
574
  if (found === undefined) return undefined;
320
- // The production deployment comes with the project, and this
321
- // resource never changes or deletes it, so it is ours to read.
322
575
  // A dev or preview deployment that is not in state is someone
323
576
  // else's until the user passes --adopt.
324
- return olds.type === "prod" ? found : Unowned(found);
577
+ return Unowned({ ...found, hash: undefined, envNames: [] });
325
578
  }),
326
579
 
327
580
  reconcile: Effect.fn(function* ({ fqn, news, output }) {
328
581
  // Also here: with an unresolved prop in plan, diff could not check.
329
582
  if (output !== undefined) yield* checkIdentity(output, news);
330
- if (news.type === "prod") {
331
- // Always the current default, also when another production
332
- // deployment became the default after the last deploy.
333
- const current = yield* production(news.projectId);
334
- if (current === undefined) {
335
- return yield* new ProductionDeploymentNotFound({ projectId: news.projectId });
336
- }
337
- return current;
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
+ });
338
594
  }
339
- if (output !== undefined) {
340
- const live = yield* getByName(output.name);
341
- if (live !== undefined) {
342
- if (live.deploymentType !== output.type) {
343
- return yield* new DeploymentIdentityChange({
344
- deployment: live.name,
345
- field: "type",
346
- current: live.deploymentType,
347
- requested: news.type,
348
- });
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 });
349
616
  }
350
- const current = attributesOf(live, output.type, output.previewName);
351
- // The live deployment can have moved to another project.
352
- yield* checkIdentity(current, news);
353
- return current;
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 };
354
620
  }
355
- // The deployment in state is gone, such as an expired preview.
356
- // Find or create it below like a resource without state.
357
- }
358
- // Never create over an existing deployment: a preview create
359
- // replaces the preview deployment with the same name. Another
360
- // writer can still create one between this lookup and the create.
361
- const existing = yield* lookup(news);
362
- if (existing !== undefined) {
363
- if (!(yield* shouldAdopt(fqn))) {
364
- return yield* new OwnedBySomeoneElse({
365
- message: `Project ${news.projectId} already has ${news.type} deployment ${existing.name} named "${news.name}". Re-run with --adopt to take it over, or use another name.`,
366
- resourceType: Deployment.Type,
367
- physicalName: existing.name,
368
- });
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.
369
639
  }
370
- return existing;
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: [] };
371
667
  }
372
- const created = yield* api.request("create deployment", (client) =>
373
- client.POST("/projects/{project_id}/create_deployment", {
374
- params: { path: { project_id: news.projectId } },
375
- body: { type: news.type, reference: news.name },
376
- }),
377
- );
378
- return attributesOf(
379
- Schema.decodeUnknownSync(DeploymentRow)(created),
380
- news.type,
381
- news.name,
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
+ ),
382
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
+ };
383
718
  }),
384
719
 
385
- // Runs only with RemovalPolicy.destroy(): the default policy is `retain`.
720
+ // Runs only with RemovalPolicy.destroy(): the default policy is
721
+ // `retain`. The variables go with the deployment.
386
722
  delete: Effect.fn(function* ({ output }) {
387
723
  const keep = Effect.logWarning(
388
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.`,
389
725
  );
390
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;
391
730
  // The type in state can be old: a dev or preview deployment can
392
731
  // become a production deployment in Convex. The live type decides.
393
- const live = yield* getByName(output.name);
732
+ const row = yield* getByName(management, output.name);
394
733
  // 404: an earlier attempt, the expiry, or someone else deleted it.
395
- if (live === undefined) return;
396
- if (live.deploymentType === "prod") return yield* keep;
734
+ if (row === undefined) return;
735
+ if (row.deploymentType === "prod") return yield* keep;
397
736
  yield* absentAsUndefined(
398
737
  retryIdempotentWrite(
399
- api.requestVoid("delete deployment", (client) =>
738
+ management.requestVoid("delete deployment", (client) =>
400
739
  client.POST("/deployments/{deployment_name}/delete", {
401
740
  params: { path: { deployment_name: output.name } },
402
741
  }),