@samebase/alchemy-convex 0.1.0 → 0.3.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.
Files changed (54) hide show
  1. package/README.md +246 -55
  2. package/lib/index.d.ts +510 -9
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +1411 -10
  5. package/lib/index.js.map +1 -1
  6. package/package.json +8 -5
  7. package/src/Adoption.ts +30 -0
  8. package/src/Code.ts +463 -0
  9. package/src/DefaultEnvironmentVariable.ts +127 -29
  10. package/src/Deploy.ts +47 -35
  11. package/src/DeployKey.ts +221 -60
  12. package/src/Deployment.ts +409 -0
  13. package/src/EnvironmentVariable.ts +204 -44
  14. package/src/ManagementApi.ts +57 -1
  15. package/src/PreviewDeployKey.ts +65 -38
  16. package/src/Project.ts +236 -52
  17. package/src/Providers.ts +9 -1
  18. package/src/index.ts +29 -1
  19. package/lib/Credentials.d.ts +0 -39
  20. package/lib/Credentials.d.ts.map +0 -1
  21. package/lib/Credentials.js +0 -49
  22. package/lib/Credentials.js.map +0 -1
  23. package/lib/DefaultEnvironmentVariable.d.ts +0 -58
  24. package/lib/DefaultEnvironmentVariable.d.ts.map +0 -1
  25. package/lib/DefaultEnvironmentVariable.js +0 -93
  26. package/lib/DefaultEnvironmentVariable.js.map +0 -1
  27. package/lib/Deploy.d.ts +0 -62
  28. package/lib/Deploy.d.ts.map +0 -1
  29. package/lib/Deploy.js +0 -92
  30. package/lib/Deploy.js.map +0 -1
  31. package/lib/DeployKey.d.ts +0 -48
  32. package/lib/DeployKey.d.ts.map +0 -1
  33. package/lib/DeployKey.js +0 -104
  34. package/lib/DeployKey.js.map +0 -1
  35. package/lib/EnvironmentVariable.d.ts +0 -37
  36. package/lib/EnvironmentVariable.d.ts.map +0 -1
  37. package/lib/EnvironmentVariable.js +0 -65
  38. package/lib/EnvironmentVariable.js.map +0 -1
  39. package/lib/ManagementApi.d.ts +0 -50
  40. package/lib/ManagementApi.d.ts.map +0 -1
  41. package/lib/ManagementApi.js +0 -103
  42. package/lib/ManagementApi.js.map +0 -1
  43. package/lib/PreviewDeployKey.d.ts +0 -34
  44. package/lib/PreviewDeployKey.d.ts.map +0 -1
  45. package/lib/PreviewDeployKey.js +0 -78
  46. package/lib/PreviewDeployKey.js.map +0 -1
  47. package/lib/Project.d.ts +0 -29
  48. package/lib/Project.d.ts.map +0 -1
  49. package/lib/Project.js +0 -114
  50. package/lib/Project.js.map +0 -1
  51. package/lib/Providers.d.ts +0 -13
  52. package/lib/Providers.d.ts.map +0 -1
  53. package/lib/Providers.js +0 -30
  54. package/lib/Providers.js.map +0 -1
package/src/Code.ts ADDED
@@ -0,0 +1,463 @@
1
+ // Convex.Code: pushes the functions of a Convex app to one deployment with
2
+ // the Convex CLI that the app has installed, and exposes the URLs that a
3
+ // frontend or a Worker needs.
4
+ //
5
+ // - Every apply pushes. The resource does not hash the sources: a preview
6
+ // deployment can expire, a push from outside Alchemy can change the
7
+ // deployment, and a hash of the app directory misses changes in workspace
8
+ // packages. The Convex CLI uploads only the modules that changed.
9
+ // - The CLI is the `convex` package that `cwd` resolves, run with the Node.js
10
+ // that runs Alchemy. Nothing is downloaded. The `command` prop replaces it.
11
+ // - The deploy key reaches the CLI only through an env file (`--env-file`)
12
+ // with mode 0600 in a new temporary directory. The directory is removed
13
+ // after the run, on success, on failure, and on interruption. It is also the
14
+ // home directory of the CLI, so the CLI cannot read the Convex login of this
15
+ // machine and pushes with the deploy key only. Variables named `CONVEX_*`
16
+ // are not passed on to the CLI.
17
+ // - The CLI output stays out of the logs. A failure shows its last lines with
18
+ // the deploy key removed.
19
+ // - Before the push, the key must fit the deployment: a deploy key of that
20
+ // deployment, or a preview deploy key for a Convex.Deployment preview. After
21
+ // the push, the deployment that the CLI names must be that deployment.
22
+ // - `env` variables are set on the deployment before the push with the deploy
23
+ // key, through the deployment API like Convex.EnvironmentVariable. A
24
+ // variable that exists with another value and that this resource did not
25
+ // set is someone else's: it is overwritten only with --adopt. A name that
26
+ // leaves `env` is removed from the deployment.
27
+ // - Delete does nothing. The functions and the variables stay on the
28
+ // deployment: the deployed code can need them.
29
+ //
30
+ // Patterns from Confect `packages/alchemy/src/Code.ts`,
31
+ // `internal/CodeDeployment.ts`, and `internal/CommandRunner.ts`
32
+ // (https://github.com/rjdellecese/confect, ISC license).
33
+ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
34
+ import { createRequire } from "node:module";
35
+ import { tmpdir } from "node:os";
36
+ import { dirname, join, resolve } from "node:path";
37
+ import { stripVTControlCharacters } from "node:util";
38
+ import { Resource } from "alchemy";
39
+ import { OwnedBySomeoneElse } from "alchemy/AdoptPolicy";
40
+ import * as Provider from "alchemy/Provider";
41
+ import * as Clock from "effect/Clock";
42
+ import * as Effect from "effect/Effect";
43
+ import * as ChildProcess from "effect/process/ChildProcess";
44
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
45
+ import * as Redacted from "effect/Redacted";
46
+ import * as Schema from "effect/Schema";
47
+ import * as Stream from "effect/Stream";
48
+ import { shouldAdopt } from "./Adoption.ts";
49
+ import { type DeploymentAttributes, siteUrlOf } from "./Deployment.ts";
50
+ import { listVariables, writeVariables } from "./EnvironmentVariable.ts";
51
+ import { retryIdempotentWrite } from "./ManagementApi.ts";
52
+ import type { Providers } from "./Providers.ts";
53
+
54
+ /** The attributes of a Convex.Deployment that a push needs. Pass the resource itself. */
55
+ export type CodeDeployment = Pick<DeploymentAttributes, "name" | "type" | "url" | "previewName">;
56
+
57
+ export interface CodeProps {
58
+ /**
59
+ * The deployment that receives the push: a Convex.Deployment resource, or a
60
+ * deployment name such as `happy-animal-123`. A preview deploy key needs the
61
+ * resource. For `env` on a deployment outside US East, pass the resource.
62
+ */
63
+ readonly deployment: string | CodeDeployment;
64
+ /**
65
+ * A deploy key of `deployment` (Convex.DeployKey), or, for a preview
66
+ * deployment, a preview deploy key of its project (Convex.PreviewDeployKey).
67
+ */
68
+ readonly deployKey: Redacted.Redacted<string>;
69
+ /** The Convex app directory: the one with `convex/` and the `convex` package installed. */
70
+ readonly cwd: string;
71
+ /**
72
+ * The program and first arguments that run the Convex CLI, such as
73
+ * `["pnpm", "exec", "convex"]`. Code adds `deploy` and its flags. By
74
+ * default, the `convex` package that `cwd` resolves, run with this Node.js.
75
+ */
76
+ readonly command?: readonly [string, ...string[]];
77
+ /** Environment variables to set on the deployment before the push. Needs a deploy key of the deployment. */
78
+ readonly env?: Readonly<Record<string, string | Redacted.Redacted<string>>>;
79
+ /** `convex deploy --typecheck`. The CLI default is `try`. */
80
+ readonly typecheck?: "enable" | "try" | "disable";
81
+ /** `convex deploy --codegen`. The CLI default is `enable`. */
82
+ readonly codegen?: "enable" | "disable";
83
+ }
84
+
85
+ export interface CodeAttributes {
86
+ /** The deployment that received the push, such as `happy-animal-123`. */
87
+ readonly deploymentName: string;
88
+ /** Client URL, such as `https://happy-animal-123.convex.cloud`. */
89
+ readonly url: string;
90
+ /** HTTP actions URL, such as `https://happy-animal-123.convex.site`. */
91
+ readonly siteUrl: string;
92
+ /** When the push finished, in milliseconds since the Unix epoch. */
93
+ readonly deployedAt: number;
94
+ /** The names of the `env` variables that this resource set on the deployment, sorted. */
95
+ readonly envNames: readonly string[];
96
+ }
97
+
98
+ export type Code = Resource<"Convex.Code", CodeProps, CodeAttributes, never, Providers>;
99
+ export const Code = Resource<Code>("Convex.Code");
100
+
101
+ /**
102
+ * The CLI prints this line with `logFinishedStep` after the push succeeds, to
103
+ * stderr (convex 1.46 `cli/lib/deploy2.js` for existing deployments,
104
+ * `cli/deploy.js` for preview deployments). The host is
105
+ * `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
106
+ */
107
+ const DEPLOYED_LINE =
108
+ /Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
109
+
110
+ /** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
111
+ export const parseDeployOutput = (
112
+ text: string,
113
+ ): { url: string; deploymentName: string } | undefined => {
114
+ const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
115
+ const url = match?.[1];
116
+ const deploymentName = match?.[2];
117
+ return url === undefined || deploymentName === undefined ? undefined : { url, deploymentName };
118
+ };
119
+
120
+ /**
121
+ * What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
122
+ * (or `<deployment>|<secret>` in old keys) for one deployment,
123
+ * `preview:<team>:<project>|<secret>` for preview deployments of a project,
124
+ * `project:<team>:<project>|<secret>` for the project's production
125
+ * deployment. Same rules as convex 1.46 `cli/lib/deployment.js`.
126
+ */
127
+ export const keyTargetOf = (
128
+ key: string,
129
+ ):
130
+ | { readonly kind: "deployment"; readonly deployment: string }
131
+ | { readonly kind: "preview" | "project" | "unknown" } => {
132
+ const bar = key.indexOf("|");
133
+ // A key in the env file must be one unquoted dotenv value.
134
+ if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "unknown" };
135
+ const parts = key.slice(0, bar).split(":");
136
+ const [first, second] = parts;
137
+ if (parts.length === 3 && first === "preview") return { kind: "preview" };
138
+ if (parts.length === 3 && first === "project") return { kind: "project" };
139
+ if (parts.length === 2 && second) return { kind: "deployment", deployment: second };
140
+ if (parts.length === 1 && first) return { kind: "deployment", deployment: first };
141
+ return { kind: "unknown" };
142
+ };
143
+
144
+ /** The deploy key does not fit the deployment. Checked before the push. */
145
+ export class DeployKeyMismatch extends Schema.TaggedError<DeployKeyMismatch>()(
146
+ "DeployKeyMismatch",
147
+ {
148
+ /** The deployment that should receive the push. */
149
+ deployment: Schema.String,
150
+ /** What the key targets. */
151
+ key: Schema.Literals(["deployment", "preview", "project", "unknown"]),
152
+ /** For a deployment key: the deployment in the key prefix. Never the secret. */
153
+ keyDeployment: Schema.UndefinedOr(Schema.String),
154
+ },
155
+ ) {
156
+ override get message() {
157
+ switch (this.key) {
158
+ case "deployment":
159
+ return `The deploy key is for deployment ${this.keyDeployment}, not for ${this.deployment}. Pass a deploy key of ${this.deployment}, such as Convex.DeployKey with deployment ${this.deployment}.`;
160
+ case "preview":
161
+ return `A preview deploy key pushes to a preview deployment by its preview name, and ${this.deployment} is not a Convex.Deployment of type "preview". Pass the Convex.Deployment preview resource as deployment, or a deploy key of ${this.deployment}.`;
162
+ case "project":
163
+ return `Convex.Code does not take a project deploy key: it cannot check before the push that the key targets ${this.deployment}. Pass a deploy key of ${this.deployment} (Convex.DeployKey).`;
164
+ case "unknown":
165
+ return `deployKey is not a Convex deploy key ("<type>:<deployment>|<secret>" or "preview:<team>:<project>|<secret>"). Pass Convex.DeployKey(...).deployKey or Convex.PreviewDeployKey(...).previewDeployKey.`;
166
+ }
167
+ }
168
+ }
169
+
170
+ /** `env` needs the deployment API, which takes only a deploy key of the deployment. */
171
+ export class EnvironmentNeedsDeployKey extends Schema.TaggedError<EnvironmentNeedsDeployKey>()(
172
+ "EnvironmentNeedsDeployKey",
173
+ { deployment: Schema.String },
174
+ ) {
175
+ override get message() {
176
+ return `Convex.Code cannot set or remove env variables on ${this.deployment} with a preview deploy key: the deployment API takes only a deploy key of the deployment. Pass a Convex.DeployKey of ${this.deployment}, or use Convex.DefaultEnvironmentVariable with deploymentType "preview" for values that every new preview deployment gets.`;
177
+ }
178
+ }
179
+
180
+ /** No `convex` package resolves from `cwd`. */
181
+ export class ConvexCliNotFound extends Schema.TaggedError<ConvexCliNotFound>()(
182
+ "ConvexCliNotFound",
183
+ { cwd: Schema.String },
184
+ ) {
185
+ override get message() {
186
+ return `Convex.Code found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), set cwd to the app directory, or set the command prop.`;
187
+ }
188
+ }
189
+
190
+ /** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
191
+ export class CodePushFailed extends Schema.TaggedError<CodePushFailed>()("CodePushFailed", {
192
+ /** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
193
+ exitCode: Schema.UndefinedOr(Schema.Number),
194
+ /** The last lines of the CLI output, with the deploy key removed. */
195
+ outputTail: Schema.String,
196
+ }) {
197
+ override get message() {
198
+ return this.exitCode === 0
199
+ ? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line, so Convex.Code has no deployment URL. Last output, with the deploy key removed:\n${this.outputTail}`
200
+ : `convex deploy failed${this.exitCode === undefined ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
201
+ }
202
+ }
203
+
204
+ /** The CLI pushed to another deployment than the one in the props. Checked after the push. */
205
+ export class PushTargetMismatch extends Schema.TaggedError<PushTargetMismatch>()(
206
+ "PushTargetMismatch",
207
+ { expected: Schema.String, actual: Schema.String },
208
+ ) {
209
+ override get message() {
210
+ return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Code expected ${this.expected}. With a preview deploy key, this happens when the key belongs to another project, or when the preview deployment ${this.expected} expired during the deploy and the CLI created ${this.actual} for the same preview name. Check the key. After an expiry, run the deploy again with --adopt, so that Convex.Deployment takes over ${this.actual}.`;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Checks that the key fits the deployment, and returns the preview name for
216
+ * `--preview-name` when the key is a preview deploy key.
217
+ */
218
+ const previewNameFor = (
219
+ target: {
220
+ readonly name: string;
221
+ readonly type: CodeDeployment["type"] | undefined;
222
+ readonly previewName: string | undefined;
223
+ },
224
+ keyTarget: ReturnType<typeof keyTargetOf>,
225
+ changesEnvironment: boolean,
226
+ ): Effect.Effect<string | undefined, DeployKeyMismatch | EnvironmentNeedsDeployKey> => {
227
+ const mismatch = new DeployKeyMismatch({
228
+ deployment: target.name,
229
+ key: keyTarget.kind,
230
+ keyDeployment: keyTarget.kind === "deployment" ? keyTarget.deployment : undefined,
231
+ });
232
+ switch (keyTarget.kind) {
233
+ case "deployment":
234
+ return keyTarget.deployment === target.name
235
+ ? Effect.succeed(undefined)
236
+ : Effect.fail(mismatch);
237
+ case "preview":
238
+ if (target.type !== "preview" || target.previewName === undefined) {
239
+ return Effect.fail(mismatch);
240
+ }
241
+ return changesEnvironment
242
+ ? Effect.fail(new EnvironmentNeedsDeployKey({ deployment: target.name }))
243
+ : Effect.succeed(target.previewName);
244
+ case "project":
245
+ case "unknown":
246
+ return Effect.fail(mismatch);
247
+ default:
248
+ return keyTarget satisfies never;
249
+ }
250
+ };
251
+
252
+ /** Number of output lines that a failure quotes. */
253
+ const OUTPUT_TAIL_LINES = 20;
254
+
255
+ /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
256
+ const outputTail = (text: string, secrets: readonly string[]) =>
257
+ secrets
258
+ .reduce(
259
+ (redacted, secret) => redacted.split(secret).join("[REDACTED]"),
260
+ stripVTControlCharacters(text),
261
+ )
262
+ .split(/\r?\n|\r/)
263
+ .filter((line) => line.trim() !== "")
264
+ .slice(-OUTPUT_TAIL_LINES)
265
+ .join("\n");
266
+
267
+ const CliManifest = Schema.Struct({
268
+ bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]),
269
+ });
270
+
271
+ /** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
272
+ const installedCli = (cwd: string) =>
273
+ Effect.tryPromise({
274
+ try: async (): Promise<readonly [string, ...string[]]> => {
275
+ const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
276
+ const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(
277
+ await readFile(manifestPath, "utf8"),
278
+ );
279
+ const entry = typeof bin === "string" ? bin : bin["convex"];
280
+ if (entry === undefined) throw new Error("the convex package has no convex bin");
281
+ return [process.execPath, resolve(dirname(manifestPath), entry)];
282
+ },
283
+ catch: () => new ConvexCliNotFound({ cwd }),
284
+ });
285
+
286
+ /**
287
+ * Runs `use` with an env file that holds the key, in a new temporary
288
+ * directory, and removes the directory afterwards: on success, on failure,
289
+ * and on interruption.
290
+ */
291
+ const withKeyFile = <A, E, R>(
292
+ key: string,
293
+ use: (paths: { readonly envFile: string; readonly home: string }) => Effect.Effect<A, E, R>,
294
+ ) =>
295
+ Effect.acquireUseRelease(
296
+ Effect.promise(() => mkdtemp(join(tmpdir(), "alchemy-convex-"))),
297
+ (home) =>
298
+ Effect.promise(() =>
299
+ writeFile(join(home, "deploy.env"), `CONVEX_DEPLOY_KEY=${key}\n`, { mode: 0o600 }),
300
+ ).pipe(Effect.andThen(use({ envFile: join(home, "deploy.env"), home }))),
301
+ (home) => Effect.promise(() => rm(home, { recursive: true, force: true })),
302
+ );
303
+
304
+ /**
305
+ * The environment of the CLI: this process's variables without `CONVEX_*`,
306
+ * with `home` as the home directory.
307
+ */
308
+ const cliEnvironment = (home: string): Record<string, string | undefined> => ({
309
+ ...Object.fromEntries(
310
+ Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_")),
311
+ ),
312
+ HOME: home,
313
+ USERPROFILE: home,
314
+ XDG_CONFIG_HOME: home,
315
+ });
316
+
317
+ export const CodeProvider = () =>
318
+ Provider.effect(
319
+ Code,
320
+ Effect.gen(function* () {
321
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
322
+
323
+ /** Runs the CLI with piped output. Interruption stops its process group. */
324
+ const run = (argv: readonly [string, ...string[]], cwd: string, home: string) =>
325
+ Effect.gen(function* () {
326
+ const [command, ...args] = argv;
327
+ const child = yield* spawner.spawn(
328
+ ChildProcess.make(command, args, {
329
+ cwd,
330
+ env: cliEnvironment(home),
331
+ extendEnv: false,
332
+ stdin: "ignore",
333
+ stdout: "pipe",
334
+ stderr: "pipe",
335
+ }),
336
+ );
337
+ const [exitCode, stdout, stderr] = yield* Effect.all(
338
+ [
339
+ child.exitCode,
340
+ Stream.mkString(Stream.decodeText(child.stdout)),
341
+ Stream.mkString(Stream.decodeText(child.stderr)),
342
+ ],
343
+ { concurrency: "unbounded" },
344
+ );
345
+ return { exitCode: Number(exitCode), output: `${stdout}\n${stderr}` };
346
+ }).pipe(Effect.scoped);
347
+
348
+ return {
349
+ // Every apply pushes: see the top of this file.
350
+ diff: () => Effect.succeed({ action: "update" } as const),
351
+
352
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
353
+ const target =
354
+ typeof news.deployment === "string"
355
+ ? {
356
+ name: news.deployment,
357
+ address: news.deployment,
358
+ type: undefined,
359
+ previewName: undefined,
360
+ }
361
+ : { ...news.deployment, address: news.deployment.url };
362
+ const key = Redacted.value(news.deployKey);
363
+ const secrets = [key, key.slice(key.indexOf("|") + 1)].filter(
364
+ (secret) => secret.length >= 8,
365
+ );
366
+ const env = Object.entries(news.env ?? {}).sort(([a], [b]) => (a < b ? -1 : 1));
367
+ // Names set on another deployment stay there: this push does not own them.
368
+ const previous =
369
+ output !== undefined && output.deploymentName === target.name ? output.envNames : [];
370
+ const removed = previous.filter((name) => !env.some(([envName]) => envName === name));
371
+
372
+ // 1. The key must fit the deployment. No side effect before this.
373
+ const previewName = yield* previewNameFor(
374
+ target,
375
+ keyTargetOf(key),
376
+ env.length > 0 || removed.length > 0,
377
+ );
378
+
379
+ // 2. The CLI must exist before any variable changes.
380
+ const cwd = resolve(news.cwd);
381
+ const program = news.command ?? (yield* installedCli(cwd));
382
+
383
+ // 3. Variables before the push, so the push sees them. Each
384
+ // attempt checks first, so a retry after a write conflict never
385
+ // overwrites a value that another writer set in the meantime.
386
+ if (env.length > 0 || removed.length > 0) {
387
+ const adopt = yield* shouldAdopt(fqn);
388
+ const fresh = env.filter(([name]) => !previous.includes(name));
389
+ yield* retryIdempotentWrite(
390
+ Effect.gen(function* () {
391
+ if (fresh.length > 0 && !adopt) {
392
+ const current = yield* listVariables(target.address, news.deployKey);
393
+ // The same value loses nothing, such as a value that an
394
+ // earlier run set before its push failed.
395
+ const taken = fresh
396
+ .filter(
397
+ ([name, value]) =>
398
+ Object.hasOwn(current, name) &&
399
+ current[name] !==
400
+ (Redacted.isRedacted(value) ? Redacted.value(value) : value),
401
+ )
402
+ .map(([name]) => name);
403
+ if (taken.length > 0) {
404
+ return yield* new OwnedBySomeoneElse({
405
+ message: `Environment variables ${taken.join(", ")} already exist on deployment ${target.name} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`,
406
+ resourceType: Code.Type,
407
+ physicalName: target.name,
408
+ });
409
+ }
410
+ }
411
+ yield* writeVariables(target.address, news.deployKey, [
412
+ ...env.map(([name, value]) => ({ name, value })),
413
+ ...removed.map((name) => ({ name, value: null })),
414
+ ]);
415
+ }),
416
+ );
417
+ }
418
+
419
+ // 4. The push.
420
+ const args = [
421
+ "deploy",
422
+ ...(previewName === undefined ? [] : ["--preview-name", previewName]),
423
+ ...(news.typecheck === undefined ? [] : [`--typecheck=${news.typecheck}`]),
424
+ ...(news.codegen === undefined ? [] : [`--codegen=${news.codegen}`]),
425
+ ];
426
+ const result = yield* withKeyFile(key, ({ envFile, home }) =>
427
+ run([...program, ...args, "--env-file", envFile], cwd, home),
428
+ ).pipe(
429
+ Effect.mapError(
430
+ (error) =>
431
+ new CodePushFailed({
432
+ exitCode: undefined,
433
+ outputTail: outputTail(error.message, secrets),
434
+ }),
435
+ ),
436
+ );
437
+ const deployed = parseDeployOutput(result.output);
438
+ if (result.exitCode !== 0 || deployed === undefined) {
439
+ return yield* new CodePushFailed({
440
+ exitCode: result.exitCode,
441
+ outputTail: outputTail(result.output, secrets),
442
+ });
443
+ }
444
+ if (deployed.deploymentName !== target.name) {
445
+ return yield* new PushTargetMismatch({
446
+ expected: target.name,
447
+ actual: deployed.deploymentName,
448
+ });
449
+ }
450
+ return {
451
+ deploymentName: deployed.deploymentName,
452
+ url: deployed.url,
453
+ siteUrl: siteUrlOf(deployed.url),
454
+ deployedAt: yield* Clock.currentTimeMillis,
455
+ envNames: env.map(([name]) => name),
456
+ };
457
+ }),
458
+
459
+ // The functions and the variables stay: the deployment runs them.
460
+ delete: () => Effect.void,
461
+ };
462
+ }),
463
+ );
@@ -6,14 +6,31 @@
6
6
  // credential, not with a deploy key. Setting a default and removing it are the
7
7
  // same call: update_default_environment_variables with `value: null` removes
8
8
  // the default, and removing an absent default also answers 200.
9
+ //
10
+ // A default that exists but is not in state belongs to someone else: its old
11
+ // value is lost when this resource writes it. Alchemy takes it over only with
12
+ // --adopt.
13
+ //
14
+ // The default's project, name, and deployment type cannot change. A change
15
+ // fails with VariableIdentityChange: use a new resource with a new logical
16
+ // id. Read and delete use the identity in the attributes: after a refused
17
+ // change, the props name the refused one.
9
18
  import { Resource } from "alchemy";
19
+ import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
10
20
  import { isResolved } from "alchemy/Diff";
11
21
  import * as Provider from "alchemy/Provider";
12
22
  import * as Effect from "effect/Effect";
13
23
  import * as Redacted from "effect/Redacted";
14
24
  import * as Schema from "effect/Schema";
15
25
  import type { components } from "@convex-dev/platform/managementApi";
16
- import { absentAsUndefined, ManagementApi, type ManagementApiService } from "./ManagementApi.ts";
26
+ import { shouldAdopt } from "./Adoption.ts";
27
+ import { VariableIdentityChange } from "./EnvironmentVariable.ts";
28
+ import {
29
+ absentAsUndefined,
30
+ ManagementApi,
31
+ type ManagementApiService,
32
+ retryIdempotentWrite,
33
+ } from "./ManagementApi.ts";
17
34
  import type { Providers } from "./Providers.ts";
18
35
 
19
36
  /** Deployment types a default can target, from the generated schema. */
@@ -52,11 +69,8 @@ export const listDefaults = (
52
69
  )
53
70
  .pipe(Effect.map((body) => Schema.decodeUnknownSync(DefaultEnvironmentVariableList)(body)));
54
71
 
55
- /**
56
- * Sets one default, or removes it when `value` is null. Convex answers 200
57
- * with an empty body, so this reads no response data.
58
- */
59
- export const updateDefault = (
72
+ /** One attempt to set one default, or to remove it when `value` is null. */
73
+ const writeDefault = (
60
74
  api: ManagementApiService,
61
75
  projectId: number,
62
76
  name: string,
@@ -78,6 +92,34 @@ export const updateDefault = (
78
92
  }),
79
93
  );
80
94
 
95
+ /**
96
+ * Sets one default, or removes it when `value` is null. Convex answers 200
97
+ * with an empty body, so this reads no response data. The call sets a value,
98
+ * so it runs again after a write conflict or a 5xx answer, for example when
99
+ * another resource writes a default of the same project at the same time.
100
+ */
101
+ export const updateDefault = (
102
+ api: ManagementApiService,
103
+ projectId: number,
104
+ name: string,
105
+ deploymentType: DeploymentType,
106
+ value: string | Redacted.Redacted<string> | null,
107
+ ) => retryIdempotentWrite(writeDefault(api, projectId, name, deploymentType, value));
108
+
109
+ /** The default's identity, or undefined when the default or the project is absent. */
110
+ const readDefault = (
111
+ api: ManagementApiService,
112
+ projectId: number,
113
+ name: string,
114
+ deploymentType: DeploymentType,
115
+ ) =>
116
+ // 404 ProjectNotFound: the project no longer exists, so neither does the default.
117
+ absentAsUndefined(listDefaults(api, projectId, name, deploymentType)).pipe(
118
+ Effect.map((list) =>
119
+ list === undefined ? undefined : findDefault(list, projectId, name, deploymentType),
120
+ ),
121
+ );
122
+
81
123
  /**
82
124
  * GET /projects/{project_id}/list_default_environment_variables. Only the
83
125
  * fields the provider uses: the row's `value` is the secret in plain text.
@@ -114,6 +156,34 @@ export const DefaultEnvironmentVariable = Resource<DefaultEnvironmentVariable>(
114
156
  "Convex.DefaultEnvironmentVariable",
115
157
  );
116
158
 
159
+ /** Fails with VariableIdentityChange when the props name another default than the one in state. */
160
+ const checkIdentity = (
161
+ output: DefaultEnvironmentVariableAttributes,
162
+ news: DefaultEnvironmentVariableProps,
163
+ ) => {
164
+ const changed =
165
+ output.projectId !== news.projectId
166
+ ? ({
167
+ field: "projectId",
168
+ current: `${output.projectId}`,
169
+ requested: `${news.projectId}`,
170
+ } as const)
171
+ : output.name !== news.name
172
+ ? ({ field: "name", current: output.name, requested: news.name } as const)
173
+ : output.deploymentType !== news.deploymentType
174
+ ? ({
175
+ field: "deploymentType",
176
+ current: output.deploymentType,
177
+ requested: news.deploymentType,
178
+ } as const)
179
+ : undefined;
180
+ return changed === undefined
181
+ ? Effect.void
182
+ : Effect.fail(
183
+ new VariableIdentityChange({ resourceType: DefaultEnvironmentVariable.Type, ...changed }),
184
+ );
185
+ };
186
+
117
187
  export const DefaultEnvironmentVariableProvider = () =>
118
188
  Provider.effect(
119
189
  DefaultEnvironmentVariable,
@@ -121,32 +191,60 @@ export const DefaultEnvironmentVariableProvider = () =>
121
191
  const api = yield* ManagementApi;
122
192
 
123
193
  return {
124
- stables: ["projectId", "name", "deploymentType"],
125
-
126
- diff: ({ olds, news }) => {
127
- if (!isResolved(news)) return Effect.succeed(undefined);
128
- // Otherwise undefined: the engine updates when any prop changed and
129
- // compares a Redacted value by content.
130
- return Effect.succeed(
131
- olds.projectId !== news.projectId ||
132
- olds.name !== news.name ||
133
- olds.deploymentType !== news.deploymentType
134
- ? ({ action: "replace" } as const)
135
- : undefined,
136
- );
137
- },
194
+ // Compares with the attributes: after a refused change, `olds` are
195
+ // the refused props. Otherwise undefined: the engine updates when any
196
+ // prop changed and compares a Redacted value by content.
197
+ diff: Effect.fn(function* ({ news, output }) {
198
+ if (!isResolved(news) || output === undefined) return undefined;
199
+ yield* checkIdentity(output, news);
200
+ return undefined;
201
+ }),
138
202
 
139
- read: Effect.fn(function* ({ olds }) {
140
- // 404 ProjectNotFound: the project no longer exists, so neither does the default.
141
- const list = yield* absentAsUndefined(
142
- listDefaults(api, olds.projectId, olds.name, olds.deploymentType),
143
- );
144
- if (list === undefined) return undefined;
145
- return findDefault(list, olds.projectId, olds.name, olds.deploymentType);
203
+ read: Effect.fn(function* ({ olds, output }) {
204
+ const at = output ?? olds;
205
+ const found = yield* readDefault(api, at.projectId, at.name, at.deploymentType);
206
+ if (found === undefined) return undefined;
207
+ // No state: the default belongs to someone else until the user passes --adopt.
208
+ return output ?? Unowned(found);
146
209
  }),
147
210
 
148
- reconcile: Effect.fn(function* ({ news }) {
149
- yield* updateDefault(api, news.projectId, news.name, news.deploymentType, news.value);
211
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
212
+ // Also here: with an unresolved prop in plan, diff could not check.
213
+ if (output !== undefined) yield* checkIdentity(output, news);
214
+ const write = writeDefault(
215
+ api,
216
+ news.projectId,
217
+ news.name,
218
+ news.deploymentType,
219
+ news.value,
220
+ );
221
+ if (output !== undefined || (yield* shouldAdopt(fqn))) {
222
+ // In state, or adopted: the default is ours to set.
223
+ yield* retryIdempotentWrite(write);
224
+ } else {
225
+ // Not in state: overwriting a default that exists loses its
226
+ // value, so that needs adoption. Each attempt checks first, so a
227
+ // retry after a write conflict never overwrites a default that
228
+ // another writer created in the meantime.
229
+ yield* retryIdempotentWrite(
230
+ Effect.gen(function* () {
231
+ const existing = yield* readDefault(
232
+ api,
233
+ news.projectId,
234
+ news.name,
235
+ news.deploymentType,
236
+ );
237
+ if (existing !== undefined) {
238
+ return yield* new OwnedBySomeoneElse({
239
+ message: `Project ${news.projectId} already has a ${news.deploymentType} default environment variable ${news.name}. Re-run with --adopt to take it over and overwrite its value, or use a different name.`,
240
+ resourceType: DefaultEnvironmentVariable.Type,
241
+ physicalName: news.name,
242
+ });
243
+ }
244
+ yield* write;
245
+ }),
246
+ );
247
+ }
150
248
  return {
151
249
  projectId: news.projectId,
152
250
  name: news.name,