@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.
package/lib/index.js CHANGED
@@ -1,32 +1,40 @@
1
- import { readFile } from "node:fs/promises";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
1
+ import { createRequire } from "node:module";
2
+ import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, join, relative, resolve } from "node:path";
4
5
  import * as Config from "effect/Config";
5
6
  import * as Context from "effect/Context";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Layer from "effect/Layer";
9
+ import * as Option from "effect/Option";
8
10
  import * as Redacted from "effect/Redacted";
11
+ import * as Result from "effect/Result";
9
12
  import * as Schema from "effect/Schema";
10
13
  import { Resource } from "alchemy";
11
14
  import { AdoptPolicy, OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
12
- import { havePropsChanged, isResolved } from "alchemy/Diff";
15
+ import { isResolved } from "alchemy/Diff";
13
16
  import * as Provider from "alchemy/Provider";
17
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
14
18
  import { AlchemyContext } from "alchemy/AlchemyContext";
15
19
  import { Stack } from "alchemy/Stack";
16
- import * as Option from "effect/Option";
17
20
  import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
18
21
  import * as Schedule from "effect/Schedule";
22
+ import { createHash } from "node:crypto";
19
23
  import { stripVTControlCharacters } from "node:util";
20
- import { CommandExecutor, CommandExecutorLive } from "alchemy/Command";
21
24
  import { hashDirectory } from "alchemy/Command/Memo";
22
- import { createHash } from "node:crypto";
25
+ import * as Clock from "effect/Clock";
26
+ import * as ChildProcess from "effect/process/ChildProcess";
27
+ import * as Stream from "effect/Stream";
23
28
  //#region src/Credentials.ts
24
29
  var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
25
- /** Environment variable read by {@link fromConfig}. */
30
+ /** Environment variable for the access token, read by {@link fromConfig}. */
26
31
  const ACCESS_TOKEN_ENV = "CONVEX_ACCESS_TOKEN";
32
+ /** Environment variable for the deploy key, read by {@link fromConfig}. The Convex CLI reads the same name. */
33
+ const DEPLOY_KEY_ENV = "CONVEX_DEPLOY_KEY";
27
34
  /** The Convex CLI stores its device login here after `npx convex login`. */
28
35
  const convexCliConfigPath = () => join(homedir(), ".convex", "config.json");
29
36
  const CliConfig = Schema.Struct({ accessToken: Schema.String });
37
+ var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { reason: Schema.String }) {};
30
38
  /**
31
39
  * Token the Convex CLI saved on this machine. It works on the Management API
32
40
  * for the teams the logged-in user belongs to. Fails when nobody is logged in.
@@ -35,7 +43,7 @@ const readConvexCliLogin = Effect.gen(function* () {
35
43
  const path = convexCliConfigPath();
36
44
  const text = yield* Effect.tryPromise({
37
45
  try: () => readFile(path, "utf8"),
38
- catch: () => new CredentialsError({ reason: `no Convex CLI login at ${path}` })
46
+ catch: () => new CredentialsError({ reason: `This step needs the Convex Management API. Set ${ACCESS_TOKEN_ENV} to a team access token, or run \`npx convex login\`. There is no Convex CLI login at ${path}.` })
39
47
  });
40
48
  const parsed = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CliConfig))(text).pipe(Effect.mapError(() => new CredentialsError({ reason: `${path} has no accessToken` })));
41
49
  return {
@@ -43,22 +51,35 @@ const readConvexCliLogin = Effect.gen(function* () {
43
51
  source: "convex-cli-login"
44
52
  };
45
53
  });
46
- var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { reason: Schema.String }) {};
47
54
  /**
48
- * Default resolution: `CONVEX_ACCESS_TOKEN` from the stack ConfigProvider
49
- * (process env, `.env`, stack secrets), then the Convex CLI login on this
50
- * machine. CI sets the variable; a developer who ran `npx convex login` needs
51
- * nothing.
55
+ * Default resolution through the stack ConfigProvider (process env, `.env`,
56
+ * stack secrets): `CONVEX_ACCESS_TOKEN`, then the Convex CLI login on this
57
+ * machine, for the access token; `CONVEX_DEPLOY_KEY` for the deploy key. A
58
+ * developer who ran `npx convex login` needs nothing. CI sets only the
59
+ * deploy key when the stack pushes to deployments that exist, and the access
60
+ * token when the stack also creates projects or deployments.
52
61
  */
53
- const fromConfig = () => Layer.succeed(Credentials, Effect.cached(Config.Redacted(ACCESS_TOKEN_ENV).pipe(Effect.map((accessToken) => ({
54
- accessToken,
55
- source: "env"
56
- })), Effect.catch(() => readConvexCliLogin), Effect.orDie)).pipe(Effect.flatten));
57
- /** Explicit token, for tests or callers that already hold a value. */
58
- const fromToken = (accessToken) => Layer.succeed(Credentials, Effect.succeed({
59
- accessToken,
60
- source: "explicit"
62
+ const fromConfig = () => Layer.effect(Credentials, Effect.gen(function* () {
63
+ const resolved = yield* Effect.cached(Config.Redacted(ACCESS_TOKEN_ENV).pipe(Effect.map((token) => ({
64
+ accessToken: token,
65
+ source: "env"
66
+ })), Effect.catch(() => readConvexCliLogin), Effect.result));
67
+ const deployKey = yield* Effect.cached(Config.option(Config.Redacted(DEPLOY_KEY_ENV)).pipe(Effect.map(Option.getOrUndefined), Effect.orDie));
68
+ return {
69
+ accessToken: resolved.pipe(Effect.flatMap(Effect.fromResult), Effect.orDie),
70
+ hasAccessToken: resolved.pipe(Effect.map(Result.isSuccess)),
71
+ deployKey
72
+ };
61
73
  }));
74
+ /** Explicit values, for tests or callers that already hold them. */
75
+ const fromValues = (values) => Layer.succeed(Credentials, {
76
+ accessToken: values.accessToken === void 0 ? Effect.die(new CredentialsError({ reason: "This step needs the Convex Management API, and fromValues got no accessToken." })) : Effect.succeed({
77
+ accessToken: values.accessToken,
78
+ source: "explicit"
79
+ }),
80
+ hasAccessToken: Effect.succeed(values.accessToken !== void 0),
81
+ deployKey: Effect.succeed(values.deployKey)
82
+ });
62
83
  //#endregion
63
84
  //#region src/Adoption.ts
64
85
  /** True when the resource `fqn` may take over an object that is not in state. */
@@ -145,8 +166,8 @@ const TeamRow = Schema.Struct({
145
166
  slug: Schema.String
146
167
  });
147
168
  const TeamRows = Schema.Array(TeamRow);
148
- const ManagementApiLive = () => Layer.effect(ManagementApi, Effect.gen(function* () {
149
- const credentials = yield* Credentials;
169
+ /** The service for one way to get the bearer token. */
170
+ const makeService = (credentials) => {
150
171
  const client = Effect.cached(credentials.pipe(Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))))).pipe(Effect.flatten);
151
172
  const request = (operation, run) => client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
152
173
  const requestVoid = (operation, run) => client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
@@ -175,240 +196,123 @@ const ManagementApiLive = () => Layer.effect(ManagementApi, Effect.gen(function*
175
196
  return match.id;
176
197
  });
177
198
  const isCredentialToken = (secret) => credentials.pipe(Effect.map((resolved) => tokenPart(Redacted.value(resolved.accessToken)) === tokenPart(secret)));
199
+ const withToken = (token) => makeService(Effect.succeed({
200
+ accessToken: token,
201
+ source: "explicit"
202
+ }));
178
203
  return {
179
204
  request,
180
205
  requestVoid,
181
206
  deploymentRequest,
182
207
  resolveTeamId,
183
- isCredentialToken
208
+ isCredentialToken,
209
+ withToken
184
210
  };
211
+ };
212
+ const ManagementApiLive = () => Layer.effect(ManagementApi, Effect.gen(function* () {
213
+ const credentials = yield* Credentials;
214
+ return makeService(credentials.accessToken);
185
215
  }));
186
216
  //#endregion
187
- //#region src/EnvironmentVariable.ts
188
- /**
189
- * The variable's identity when the listing has `name`. Own keys only: a
190
- * valid variable name such as `toString` must not match the object prototype.
191
- */
192
- const findVariable = (list, deployment, name) => Object.hasOwn(list.environmentVariables, name) ? {
193
- deployment,
194
- name
195
- } : void 0;
196
- /**
197
- * One attempt to set one variable, or to remove it when `value` is null. The
198
- * deployment answers 200 with an empty body, so this reads no response data.
199
- */
200
- const writeVariable = (deployment, deployKey, name, value) => settleVoid("update environment variables", () => createDeploymentClient(deployment, Redacted.value(deployKey)).POST("/update_environment_variables", { body: { changes: [{
201
- name,
202
- value: Redacted.isRedacted(value) ? Redacted.value(value) : value
203
- }] } }));
204
- /**
205
- * Sets one variable, or removes it when `value` is null. The call sets a
206
- * value, so it runs again after a write conflict or a 5xx answer, for example
207
- * when another resource writes a variable of the same deployment at the same
208
- * time. Use it only where the variable is already ours to set.
209
- */
210
- const updateVariable = (deployment, deployKey, name, value) => retryIdempotentWrite(writeVariable(deployment, deployKey, name, value));
217
+ //#region src/Push.ts
218
+ const sourceOf = (source) => typeof source === "string" ? {
219
+ path: source,
220
+ command: void 0,
221
+ include: void 0
222
+ } : {
223
+ path: source.path,
224
+ command: source.command,
225
+ include: source.include
226
+ };
227
+ /** Lifetime of a push key. Long enough for a slow push with index backfills. */
228
+ const PUSH_KEY_LIFETIME_MS = 72e5;
229
+ /** The only actions a push key gets. */
230
+ const PUSH_KEY_ACTIONS = [
231
+ "deployment:deploy",
232
+ "deployment:env:view",
233
+ "deployment:env:write"
234
+ ];
235
+ /** POST /deployments/{deployment_name}/create_deploy_key */
236
+ const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
211
237
  /** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
212
238
  const EnvironmentVariableList = Schema.Struct({ environmentVariables: Schema.Record(Schema.String, Schema.String) });
213
- /** The variable's identity, or undefined when the variable or the deployment is absent. */
214
- const readVariable = (api, deployment, deployKey, name) => absentAsUndefined(api.deploymentRequest("list environment variables", deployment, deployKey, (client) => client.GET("/list_environment_variables"))).pipe(Effect.map((body) => body === void 0 ? void 0 : findVariable(Schema.decodeUnknownSync(EnvironmentVariableList)(body), deployment, name)));
215
- const EnvironmentVariable = Resource("Convex.EnvironmentVariable");
216
- /**
217
- * The props name another variable than the one in state. Used by
218
- * Convex.EnvironmentVariable and Convex.DefaultEnvironmentVariable.
219
- */
220
- var VariableIdentityChange = class extends Schema.TaggedError()("VariableIdentityChange", {
221
- resourceType: Schema.String,
222
- field: Schema.Literals([
223
- "name",
224
- "deployment",
225
- "projectId",
226
- "deploymentType"
227
- ]),
228
- current: Schema.String,
229
- requested: Schema.String
239
+ /** No `convex` package resolves from the app directory. */
240
+ var ConvexCliNotFound = class extends Schema.TaggedError()("ConvexCliNotFound", { cwd: Schema.String }) {
241
+ get message() {
242
+ return `Convex.Deployment found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), or set source to the functions directory of the app.`;
243
+ }
244
+ };
245
+ /** No directory at or above the parent of `source` has a package.json. */
246
+ var ConvexAppNotFound = class extends Schema.TaggedError()("ConvexAppNotFound", { source: Schema.String }) {
247
+ get message() {
248
+ return `Convex.Deployment found no package.json at or above the parent of ${this.source}. Set source to the functions directory of a Convex app, such as "./convex".`;
249
+ }
250
+ };
251
+ /** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
252
+ var CodePushFailed = class extends Schema.TaggedError()("CodePushFailed", {
253
+ /** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
254
+ exitCode: Schema.UndefinedOr(Schema.Number),
255
+ /** The last lines of the CLI output, with the deploy key removed. */
256
+ outputTail: Schema.String
230
257
  }) {
231
258
  get message() {
232
- return `${this.resourceType} cannot change its ${this.field} from ${this.current} to ${this.requested}. Create a new resource with a new logical id for the new name or deployment, and remove this one.`;
259
+ return this.exitCode === 0 ? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output, with the deploy key removed:\n${this.outputTail}` : `convex deploy failed${this.exitCode === void 0 ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
233
260
  }
234
261
  };
235
- /** Fails with {@link VariableIdentityChange} when the props name another variable than the one in state. */
236
- const checkIdentity$1 = (output, news) => output.name !== news.name ? Effect.fail(new VariableIdentityChange({
237
- resourceType: EnvironmentVariable.Type,
238
- field: "name",
239
- current: output.name,
240
- requested: news.name
241
- })) : output.deployment !== news.deployment ? Effect.fail(new VariableIdentityChange({
242
- resourceType: EnvironmentVariable.Type,
243
- field: "deployment",
244
- current: output.deployment,
245
- requested: news.deployment
246
- })) : Effect.void;
247
- /**
248
- * The deploy key for the variable in state: from the attributes, or, for
249
- * state from 0.1.x, from props that name the same deployment.
250
- */
251
- const keyOf = (output, olds) => output.deployKey ?? (olds?.deployment === output.deployment ? olds.deployKey : void 0);
252
- const EnvironmentVariableProvider = () => Provider.effect(EnvironmentVariable, Effect.gen(function* () {
253
- const api = yield* ManagementApi;
254
- return {
255
- diff: Effect.fn(function* ({ news, output }) {
256
- if (!isResolved(news) || output === void 0) return void 0;
257
- yield* checkIdentity$1(output, news);
258
- }),
259
- read: Effect.fn(function* ({ olds, output }) {
260
- if (output !== void 0) {
261
- const key = keyOf(output, olds);
262
- if (key === void 0) return output;
263
- return (yield* readVariable(api, output.deployment, key, output.name)) === void 0 ? void 0 : output;
264
- }
265
- const found = yield* readVariable(api, olds.deployment, olds.deployKey, olds.name);
266
- return found === void 0 ? void 0 : Unowned({
267
- ...found,
268
- deployKey: olds.deployKey
269
- });
270
- }),
271
- reconcile: Effect.fn(function* ({ fqn, news, output }) {
272
- if (output !== void 0) yield* checkIdentity$1(output, news);
273
- const write = writeVariable(news.deployment, news.deployKey, news.name, news.value);
274
- if (output !== void 0 || (yield* shouldAdopt(fqn))) yield* retryIdempotentWrite(write);
275
- else yield* retryIdempotentWrite(Effect.gen(function* () {
276
- if ((yield* readVariable(api, news.deployment, news.deployKey, news.name)) !== void 0) return yield* new OwnedBySomeoneElse({
277
- message: `Environment variable ${news.name} already exists on deployment ${news.deployment}. Re-run with --adopt to take it over and overwrite its value, or use a different name.`,
278
- resourceType: EnvironmentVariable.Type,
279
- physicalName: news.name
280
- });
281
- yield* write;
282
- }));
283
- return {
284
- deployment: news.deployment,
285
- name: news.name,
286
- deployKey: news.deployKey
287
- };
288
- }),
289
- delete: Effect.fn(function* ({ olds, output }) {
290
- const key = keyOf(output, olds);
291
- if (key === void 0) {
292
- yield* Effect.logWarning(`Environment variable ${output.name} on deployment ${output.deployment} stays: Alchemy has no deploy key for that deployment. Remove it in the Convex dashboard.`);
293
- return;
294
- }
295
- yield* absentAsUndefined(updateVariable(output.deployment, key, output.name, null));
296
- })
297
- };
298
- }));
299
- //#endregion
300
- //#region src/DefaultEnvironmentVariable.ts
301
- /**
302
- * Attributes when the listing has `name` for `deploymentType`. An unfiltered
303
- * listing merges the deployment types that share one value into one row, so
304
- * the match checks membership in `deploymentTypes`.
305
- */
306
- const findDefault = (list, projectId, name, deploymentType) => list.items.some((item) => item.name === name && item.deploymentTypes.includes(deploymentType)) ? {
307
- projectId,
308
- name,
309
- deploymentType
310
- } : void 0;
311
- /**
312
- * Lists the defaults named `name` for `deploymentType`. Convex filters on
313
- * both, so the listing has at most one row and needs no pagination.
314
- */
315
- const listDefaults = (api, projectId, name, deploymentType) => api.request("list default environment variables", (client) => client.GET("/projects/{project_id}/list_default_environment_variables", { params: {
316
- path: { project_id: projectId },
317
- query: {
318
- name,
319
- deploymentType
262
+ /** The CLI pushed to another deployment than the one of the resource. Checked after the push. */
263
+ var PushTargetMismatch = class extends Schema.TaggedError()("PushTargetMismatch", {
264
+ expected: Schema.String,
265
+ actual: Schema.String
266
+ }) {
267
+ get message() {
268
+ return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Deployment expected ${this.expected}. Check that convex.json does not select another deployment.`;
320
269
  }
321
- } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(DefaultEnvironmentVariableList)(body)));
322
- /** One attempt to set one default, or to remove it when `value` is null. */
323
- const writeDefault = (api, projectId, name, deploymentType, value) => api.requestVoid("update default environment variables", (client) => client.POST("/projects/{project_id}/update_default_environment_variables", {
324
- params: { path: { project_id: projectId } },
325
- body: { changes: [{
326
- name,
327
- deploymentType,
328
- value: Redacted.isRedacted(value) ? Redacted.value(value) : value
329
- }] }
330
- }));
331
- /**
332
- * Sets one default, or removes it when `value` is null. Convex answers 200
333
- * with an empty body, so this reads no response data. The call sets a value,
334
- * so it runs again after a write conflict or a 5xx answer, for example when
335
- * another resource writes a default of the same project at the same time.
336
- */
337
- const updateDefault = (api, projectId, name, deploymentType, value) => retryIdempotentWrite(writeDefault(api, projectId, name, deploymentType, value));
338
- /** The default's identity, or undefined when the default or the project is absent. */
339
- const readDefault = (api, projectId, name, deploymentType) => absentAsUndefined(listDefaults(api, projectId, name, deploymentType)).pipe(Effect.map((list) => list === void 0 ? void 0 : findDefault(list, projectId, name, deploymentType)));
270
+ };
271
+ /** Variables in `env` exist on the deployment with other values, and adoption is off. */
272
+ var VariablesOwnedBySomeoneElse = class extends Schema.TaggedError()("VariablesOwnedBySomeoneElse", {
273
+ deployment: Schema.String,
274
+ names: Schema.Array(Schema.String)
275
+ }) {
276
+ get message() {
277
+ return `Environment variables ${this.names.join(", ")} already exist on deployment ${this.deployment} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`;
278
+ }
279
+ };
280
+ /** A preview deploy key cannot set variables: the deployment API takes only a deploy key of the deployment. */
281
+ var EnvironmentNeedsDeployKey = class extends Schema.TaggedError()("EnvironmentNeedsDeployKey", { deployment: Schema.String }) {
282
+ get message() {
283
+ return `Convex.Deployment cannot set or remove env variables on preview deployment ${this.deployment} with a preview deploy key: Convex lets that key create, push to, and delete preview deployments, but not change their variables. Set CONVEX_ACCESS_TOKEN for this stage, or set the values as project default environment variables for preview deployments in the Convex dashboard.`;
284
+ }
285
+ };
340
286
  /**
341
- * GET /projects/{project_id}/list_default_environment_variables. Only the
342
- * fields the provider uses: the row's `value` is the secret in plain text.
287
+ * What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
288
+ * (or `<deployment>|<secret>` in old keys) for one deployment, and
289
+ * `preview:<team>:<project>|<secret>` for the preview deployments of a
290
+ * project. Same rules as convex 1.46 `cli/lib/deployment.js`.
343
291
  */
344
- const DefaultEnvironmentVariableList = Schema.Struct({ items: Schema.Array(Schema.Struct({
345
- name: Schema.String,
346
- deploymentTypes: Schema.Array(Schema.String)
347
- })) });
348
- const DefaultEnvironmentVariable = Resource("Convex.DefaultEnvironmentVariable");
349
- /** Fails with VariableIdentityChange when the props name another default than the one in state. */
350
- const checkIdentity = (output, news) => {
351
- const changed = output.projectId !== news.projectId ? {
352
- field: "projectId",
353
- current: `${output.projectId}`,
354
- requested: `${news.projectId}`
355
- } : output.name !== news.name ? {
356
- field: "name",
357
- current: output.name,
358
- requested: news.name
359
- } : output.deploymentType !== news.deploymentType ? {
360
- field: "deploymentType",
361
- current: output.deploymentType,
362
- requested: news.deploymentType
363
- } : void 0;
364
- return changed === void 0 ? Effect.void : Effect.fail(new VariableIdentityChange({
365
- resourceType: DefaultEnvironmentVariable.Type,
366
- ...changed
367
- }));
368
- };
369
- const DefaultEnvironmentVariableProvider = () => Provider.effect(DefaultEnvironmentVariable, Effect.gen(function* () {
370
- const api = yield* ManagementApi;
371
- return {
372
- diff: Effect.fn(function* ({ news, output }) {
373
- if (!isResolved(news) || output === void 0) return void 0;
374
- yield* checkIdentity(output, news);
375
- }),
376
- read: Effect.fn(function* ({ olds, output }) {
377
- const at = output ?? olds;
378
- const found = yield* readDefault(api, at.projectId, at.name, at.deploymentType);
379
- if (found === void 0) return void 0;
380
- return output ?? Unowned(found);
381
- }),
382
- reconcile: Effect.fn(function* ({ fqn, news, output }) {
383
- if (output !== void 0) yield* checkIdentity(output, news);
384
- const write = writeDefault(api, news.projectId, news.name, news.deploymentType, news.value);
385
- if (output !== void 0 || (yield* shouldAdopt(fqn))) yield* retryIdempotentWrite(write);
386
- else yield* retryIdempotentWrite(Effect.gen(function* () {
387
- if ((yield* readDefault(api, news.projectId, news.name, news.deploymentType)) !== void 0) return yield* new OwnedBySomeoneElse({
388
- 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.`,
389
- resourceType: DefaultEnvironmentVariable.Type,
390
- physicalName: news.name
391
- });
392
- yield* write;
393
- }));
394
- return {
395
- projectId: news.projectId,
396
- name: news.name,
397
- deploymentType: news.deploymentType
398
- };
399
- }),
400
- delete: Effect.fn(function* ({ output }) {
401
- yield* absentAsUndefined(updateDefault(api, output.projectId, output.name, output.deploymentType, null));
402
- })
292
+ const keyTargetOf = (key) => {
293
+ const bar = key.indexOf("|");
294
+ if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "other" };
295
+ const [first, second, third, ...rest] = key.slice(0, bar).split(":");
296
+ if (rest.length > 0) return { kind: "other" };
297
+ if (first === "preview" && second && third) return {
298
+ kind: "preview",
299
+ team: second,
300
+ project: third
403
301
  };
404
- }));
405
- //#endregion
406
- //#region src/Deploy.ts
407
- const Deploy = Resource("Convex.Deploy");
302
+ if (third !== void 0) return { kind: "other" };
303
+ if (second) return {
304
+ kind: "deployment",
305
+ deployment: second
306
+ };
307
+ if (first && second === void 0) return {
308
+ kind: "deployment",
309
+ deployment: first
310
+ };
311
+ return { kind: "other" };
312
+ };
408
313
  /**
409
314
  * The CLI prints this line with `logFinishedStep` after the push succeeds, to
410
- * stderr (convex 1.45 `cli/lib/deploy2.js` for existing deployments,
411
- * `cli/deploy.js` for preview deployments). The host is
315
+ * stderr (convex 1.46 `cli/lib/deploy2.js`). The host is
412
316
  * `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
413
317
  */
414
318
  const DEPLOYED_LINE = /Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
@@ -422,298 +326,632 @@ const parseDeployOutput = (text) => {
422
326
  deploymentName
423
327
  };
424
328
  };
425
- /** The `convex deploy` argv, starting with `npx`. */
426
- const deployArgs = (props) => [
427
- "npx",
428
- "convex",
429
- "deploy",
430
- ...props.previewName === void 0 ? [] : ["--preview-name", props.previewName],
431
- ...props.previewCreate === void 0 ? [] : ["--preview-create", props.previewCreate],
432
- ...props.previewRun === void 0 ? [] : ["--preview-run", props.previewRun],
433
- ...props.extraArgs ?? []
434
- ];
435
- /** A `convex deploy` argument contains whitespace, which Alchemy's command runner splits on. */
436
- var DeployArgumentError = class extends Schema.TaggedError()("DeployArgumentError", { argument: Schema.String }) {
437
- get message() {
438
- return `Convex.Deploy argument "${this.argument}" contains whitespace, and Alchemy splits the command on whitespace. Use the --flag=value form, such as --typecheck=disable, and names without spaces.`;
439
- }
440
- };
441
- /** `npx convex deploy` exited 0 but did not print the deployment URL. */
442
- var DeployOutputError = class extends Schema.TaggedError()("DeployOutputError", { outputTail: Schema.String }) {
329
+ /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
330
+ const outputTail = (text, secrets) => secrets.reduce((redacted, secret) => redacted.split(secret).join("[REDACTED]"), stripVTControlCharacters(text)).split(/\r?\n|\r/).filter((line) => line.trim() !== "").slice(-20).join("\n");
331
+ const valueOf = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
332
+ /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
333
+ const appDirectoryOf = (source) => Effect.tryPromise({
334
+ try: async () => {
335
+ let directory = dirname(resolve(source));
336
+ while (true) try {
337
+ await access(join(directory, "package.json"));
338
+ return directory;
339
+ } catch {
340
+ const parent = dirname(directory);
341
+ if (parent === directory) throw new Error("no package.json");
342
+ directory = parent;
343
+ }
344
+ },
345
+ catch: () => new ConvexAppNotFound({ source })
346
+ });
347
+ /** `source` is not the functions directory that the Convex CLI pushes from this app. */
348
+ var SourceMismatch = class extends Schema.TaggedError()("SourceMismatch", {
349
+ source: Schema.String,
350
+ functions: Schema.String
351
+ }) {
443
352
  get message() {
444
- return `npx convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line, so Convex.Deploy has no deployment URL. Remove --dry-run from extraArgs if it is there, and read the CLI output. Last output:\n${this.outputTail}`;
353
+ return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} in this app (the "functions" field of convex.json, or convex/ by default). Set source to ${this.functions}.`;
445
354
  }
446
355
  };
447
- const DeployProvider = () => Provider.effect(Deploy, Effect.gen(function* () {
448
- const { run } = yield* CommandExecutor;
449
- const hashOf = (props) => props.memo === false ? Effect.succeed(void 0) : hashDirectory({
450
- ...props.cwd === void 0 ? {} : { cwd: props.cwd },
451
- memo: props.memo === true || props.memo === void 0 ? {} : props.memo
356
+ const ConvexJson = Schema.Struct({ functions: Schema.optionalKey(Schema.String) });
357
+ /** Fails with {@link SourceMismatch} when `path` is not the functions directory of the app in `cwd`. */
358
+ const checkFunctionsDirectory = (cwd, path) => Effect.gen(function* () {
359
+ const text = yield* Effect.promise(() => readFile(join(cwd, "convex.json"), "utf8").catch(() => void 0));
360
+ const configured = text === void 0 ? void 0 : Schema.decodeUnknownSync(Schema.fromJsonString(ConvexJson))(text).functions;
361
+ const functions = resolve(cwd, configured ?? "convex");
362
+ if (functions !== resolve(path)) return yield* new SourceMismatch({
363
+ source: resolve(path),
364
+ functions
452
365
  });
453
- return {
454
- diff: Effect.fn(function* ({ olds, news, output }) {
455
- if (!isResolved(news)) return void 0;
456
- if (news.previewName !== void 0 || news.previewCreate !== void 0 || output === void 0 || output.hash === void 0 || havePropsChanged(olds, news)) return { action: "update" };
457
- return { action: (yield* hashOf(news)) === output.hash ? "noop" : "update" };
458
- }),
459
- reconcile: Effect.fn(function* ({ news, session }) {
460
- const args = deployArgs(news);
461
- const spaced = args.find((arg) => /\s/.test(arg));
462
- if (spaced !== void 0) return yield* new DeployArgumentError({ argument: spaced });
463
- const { stdout, stderr } = yield* run({
464
- command: args.join(" "),
465
- ...news.cwd === void 0 ? {} : { cwd: news.cwd },
466
- ...news.timeout === void 0 ? {} : { timeout: news.timeout },
467
- env: {
468
- ...news.env,
469
- CONVEX_DEPLOY_KEY: news.deployKey
470
- }
471
- }, session);
472
- const output = `${stdout}\n${stderr}`;
473
- const deployed = parseDeployOutput(output);
474
- if (deployed === void 0) return yield* new DeployOutputError({ outputTail: stripVTControlCharacters(output).split(/\r?\n|\r/).filter((line) => line.trim() !== "").slice(-20).join("\n") });
475
- return {
476
- ...deployed,
477
- hash: yield* hashOf(news)
478
- };
479
- }),
480
- delete: () => Effect.void
481
- };
482
- }));
483
- //#endregion
484
- //#region src/DeployKey.ts
366
+ });
367
+ const CliManifest = Schema.Struct({ bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]) });
368
+ /** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
369
+ const installedCli = (cwd) => Effect.tryPromise({
370
+ try: async () => {
371
+ const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
372
+ const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(await readFile(manifestPath, "utf8"));
373
+ const entry = typeof bin === "string" ? bin : bin["convex"];
374
+ if (entry === void 0) throw new Error("the convex package has no convex bin");
375
+ return [process.execPath, resolve(dirname(manifestPath), entry)];
376
+ },
377
+ catch: () => new ConvexCliNotFound({ cwd })
378
+ });
485
379
  /**
486
- * True when `listedName` is how Convex lists a key created as `name`: the
487
- * requested name itself, or the requested name, a space, and one
488
- * parenthesized suffix without spaces. The suffix check keeps `my-key` from
489
- * matching a key created as `my-key (old)`.
380
+ * The content identity of a push: the files of the app directory that git
381
+ * does not ignore (or `path`, the manifests, and the `include` globs), the
382
+ * nearest lockfile, the target deployment, and the `env` names and values.
383
+ * A change in a workspace package outside the app directory is not seen.
490
384
  */
491
- const isListedAs = (listedName, name) => {
492
- const prefix = `${name} (`;
493
- return listedName === name || listedName.startsWith(prefix) && /^[^\s()]+\)$/.test(listedName.slice(prefix.length));
494
- };
385
+ const pushHash = (options) => Effect.gen(function* () {
386
+ const source = options.source === void 0 ? void 0 : sourceOf(options.source);
387
+ const files = source === void 0 ? void 0 : yield* appDirectoryOf(source.path).pipe(Effect.flatMap((cwd) => hashDirectory({
388
+ cwd,
389
+ memo: source.include === void 0 ? {} : {
390
+ include: [
391
+ `${relative(cwd, resolve(source.path)).split("\\").join("/")}/**`,
392
+ "package.json",
393
+ "convex.json",
394
+ ...source.include
395
+ ],
396
+ lockfile: true
397
+ }
398
+ })));
399
+ const env = Object.entries(options.env).map(([name, value]) => [name, valueOf(value)]).sort(([a], [b]) => a < b ? -1 : 1);
400
+ return createHash("sha256").update(JSON.stringify({
401
+ files,
402
+ deployment: options.deployment,
403
+ env
404
+ })).digest("hex");
405
+ });
495
406
  /**
496
- * True when the listing still has the key in state: by numeric id, or by
497
- * listed name for state written by 0.1.x, which has no id.
407
+ * Runs `use` with an env file that holds the key, in a new temporary
408
+ * directory, and removes the directory afterwards: on success, on failure,
409
+ * and on interruption.
498
410
  */
499
- const isKeyListed = (entries, key) => entries.some((entry) => key.keyId === void 0 ? entry.name === key.uniqueName : entry.id === key.keyId);
500
- /** The listed keys that Convex shows for a key created as `name`. */
501
- const keysListedAs = (entries, name) => entries.filter((entry) => isListedAs(entry.name, name));
411
+ const withKeyFile = (key, use) => Effect.acquireUseRelease(Effect.promise(() => mkdtemp(join(tmpdir(), "alchemy-convex-"))), (home) => Effect.promise(() => writeFile(join(home, "deploy.env"), `CONVEX_DEPLOY_KEY=${key}\n`, { mode: 384 })).pipe(Effect.andThen(use({
412
+ envFile: join(home, "deploy.env"),
413
+ home
414
+ }))), (home) => Effect.promise(() => rm(home, {
415
+ recursive: true,
416
+ force: true
417
+ })));
418
+ /** This process's variables without `CONVEX_*`, with `home` as the home directory. */
419
+ const cliEnvironment = (home) => ({
420
+ ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_"))),
421
+ HOME: home,
422
+ USERPROFILE: home,
423
+ XDG_CONFIG_HOME: home
424
+ });
502
425
  /**
503
- * The name that a resource instance requests: `name`, a dash, and the first
504
- * 12 hex characters of sha256("<fqn>:<instanceId>"). Two resources, or the
505
- * old and the new generation of one replaced resource, never request the
506
- * same name. Pattern from Confect `packages/alchemy/src/internal/ResourceIdentity.ts`
507
- * (https://github.com/rjdellecese/confect, ISC license).
426
+ * Creates a deploy key for one push, runs `use` with it, and deletes it
427
+ * afterwards. Without a push command, the key can only deploy and read and
428
+ * write variables. The delete is skipped for a key that is the OAuth credential.
429
+ * A failed delete is logged: the key expires anyway.
508
430
  */
509
- const requestedKeyName = (name, fqn, instanceId) => `${name}-${createHash("sha256").update(`${fqn}:${instanceId}`).digest("hex").slice(0, 12)}`;
510
- /** One row of list_deploy_keys and list_preview_deploy_keys. Only the fields the providers use. */
511
- const ListedKey = Schema.Struct({
512
- id: Schema.Number,
513
- name: Schema.String
431
+ const withPushKey = (api, deployment, source, use) => Effect.acquireUseRelease(Effect.gen(function* () {
432
+ const now = yield* Clock.currentTimeMillis;
433
+ const created = yield* api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
434
+ params: { path: { deployment_name: deployment } },
435
+ body: {
436
+ name: "alchemy-push",
437
+ ...source === void 0 || sourceOf(source).command === void 0 ? { allowedActions: [...PUSH_KEY_ACTIONS] } : {},
438
+ expiresAt: now + PUSH_KEY_LIFETIME_MS
439
+ }
440
+ }));
441
+ return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
442
+ }), use, (key) => Effect.gen(function* () {
443
+ if (yield* api.isCredentialToken(Redacted.value(key))) return;
444
+ yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
445
+ params: { path: { deployment_name: deployment } },
446
+ body: { id: Redacted.value(key) }
447
+ }))));
448
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not delete its push key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 6e4} minutes.`))));
449
+ /** Every variable of the deployment with its value. */
450
+ const listVariables = (url, key) => settle("list environment variables", () => createDeploymentClient(url, Redacted.value(key)).GET("/list_environment_variables")).pipe(Effect.map((body) => Schema.decodeUnknownSync(EnvironmentVariableList)(body).environmentVariables));
451
+ /** One call that sets variables, or removes those whose value is null. */
452
+ const writeVariables = (url, key, changes) => settleVoid("update environment variables", () => createDeploymentClient(url, Redacted.value(key)).POST("/update_environment_variables", { body: { changes: changes.map(({ name, value }) => ({
453
+ name,
454
+ value
455
+ })) } }));
456
+ /**
457
+ * The app directory and the program of a push. Fails before any change when
458
+ * `source` is not the functions directory of the app, or when the app has no
459
+ * installed Convex CLI and no push command.
460
+ */
461
+ const checkSource = (input) => Effect.gen(function* () {
462
+ const source = sourceOf(input);
463
+ const cwd = yield* appDirectoryOf(source.path);
464
+ yield* checkFunctionsDirectory(cwd, source.path);
465
+ return {
466
+ cwd,
467
+ program: source.command ?? (yield* installedCli(cwd))
468
+ };
514
469
  });
515
- /** GET /deployments/{deployment_name}/list_deploy_keys */
516
- const DeployKeyList = Schema.Array(ListedKey);
517
- /** POST /deployments/{deployment_name}/create_deploy_key */
518
- const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
519
470
  /**
520
- * A key cannot be tied to one resource: before the create, listed keys
521
- * already have the requested name, or after the create, not exactly one has it.
471
+ * Sets the `env` variables and pushes the functions of `source` to one
472
+ * deployment with `key`: a deploy key of the deployment, or a preview deploy
473
+ * key of its project with `previewName`. Returns the URL that the CLI
474
+ * printed, or undefined without `source`.
522
475
  */
523
- var DeployKeyRecoveryRequired = class extends Schema.TaggedError()("DeployKeyRecoveryRequired", {
524
- /** Where the keys live, such as `deployment happy-otter-123` or `project 3145389`. */
525
- target: Schema.String,
526
- /** The requested name. */
527
- name: Schema.String,
528
- /** The listed keys with the requested name. */
529
- keys: Schema.Array(ListedKey),
530
- /** True when this run created a key before the listing check failed. */
531
- afterCreate: Schema.Boolean
476
+ const push = (spawner, key, options) => Effect.gen(function* () {
477
+ const source = options.source === void 0 ? void 0 : sourceOf(options.source);
478
+ const resolved = options.source === void 0 ? void 0 : yield* checkSource(options.source);
479
+ const cwd = resolved?.cwd;
480
+ const program = resolved?.program;
481
+ const env = Object.entries(options.env).sort(([a], [b]) => a < b ? -1 : 1);
482
+ const removed = options.previousEnvNames.filter((name) => !Object.hasOwn(options.env, name));
483
+ const written = env.length === 0 && removed.length === 0 ? {
484
+ current: {},
485
+ changes: []
486
+ } : yield* retryIdempotentWrite(Effect.gen(function* () {
487
+ const current = yield* listVariables(options.url, key);
488
+ if (!options.adopt) {
489
+ const taken = env.filter(([name, value]) => !options.previousEnvNames.includes(name) && Object.hasOwn(current, name) && current[name] !== valueOf(value)).map(([name]) => name);
490
+ if (taken.length > 0) return yield* new VariablesOwnedBySomeoneElse({
491
+ deployment: options.deployment,
492
+ names: taken
493
+ });
494
+ }
495
+ const changes = [...env.filter(([name, value]) => current[name] !== valueOf(value)).map(([name, value]) => ({
496
+ name,
497
+ value: valueOf(value)
498
+ })), ...removed.filter((name) => Object.hasOwn(current, name)).map((name) => ({
499
+ name,
500
+ value: null
501
+ }))];
502
+ if (changes.length > 0) yield* writeVariables(options.url, key, changes);
503
+ return {
504
+ current,
505
+ changes
506
+ };
507
+ }));
508
+ /**
509
+ * After a failed push, the variables go back to their earlier values, so
510
+ * that the deployment matches the hash in state. A failed rollback is
511
+ * logged: the push error is the one to report.
512
+ */
513
+ const rollback = written.changes.length === 0 ? Effect.void : retryIdempotentWrite(writeVariables(options.url, key, written.changes.map(({ name }) => ({
514
+ name,
515
+ value: Object.hasOwn(written.current, name) ? written.current[name] ?? null : null
516
+ })))).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not set the env variables of ${options.deployment} back after the failed push (${error.message}). Run the deploy again.`)));
517
+ if (source === void 0 || cwd === void 0 || program === void 0) return void 0;
518
+ return yield* runPush(spawner, key, {
519
+ ...options,
520
+ cwd,
521
+ program,
522
+ command: source.command
523
+ }).pipe(Effect.onError(() => rollback));
524
+ });
525
+ /** Runs the installed CLI or the push command once, and checks the deployment that it names. */
526
+ const runPush = (spawner, key, options) => Effect.gen(function* () {
527
+ const { cwd, program } = options;
528
+ const secret = Redacted.value(key);
529
+ const secrets = [secret, secret.slice(secret.indexOf("|") + 1)].filter((value) => value.length >= 8);
530
+ const result = yield* withKeyFile(secret, ({ envFile, home }) => Effect.gen(function* () {
531
+ const [command, ...programArgs] = program;
532
+ const args = options.command === void 0 ? [
533
+ ...programArgs,
534
+ "deploy",
535
+ ...options.previewName === void 0 ? [] : ["--preview-name", options.previewName],
536
+ "--env-file",
537
+ envFile
538
+ ] : programArgs;
539
+ const child = yield* spawner.spawn(ChildProcess.make(command, args, {
540
+ cwd,
541
+ env: {
542
+ ...cliEnvironment(home),
543
+ ...options.command === void 0 ? {} : { CONVEX_DEPLOY_KEY: secret }
544
+ },
545
+ extendEnv: false,
546
+ stdin: "ignore",
547
+ stdout: "pipe",
548
+ stderr: "pipe"
549
+ }));
550
+ const [exitCode, stdout, stderr] = yield* Effect.all([
551
+ child.exitCode,
552
+ Stream.mkString(Stream.decodeText(child.stdout)),
553
+ Stream.mkString(Stream.decodeText(child.stderr))
554
+ ], { concurrency: "unbounded" });
555
+ return {
556
+ exitCode: Number(exitCode),
557
+ output: `${stdout}\n${stderr}`
558
+ };
559
+ }).pipe(Effect.scoped)).pipe(Effect.mapError((error) => new CodePushFailed({
560
+ exitCode: void 0,
561
+ outputTail: outputTail(error.message, secrets)
562
+ })));
563
+ const deployed = parseDeployOutput(result.output);
564
+ if (result.exitCode !== 0 || deployed === void 0) return yield* new CodePushFailed({
565
+ exitCode: result.exitCode,
566
+ outputTail: outputTail(result.output, secrets)
567
+ });
568
+ if (deployed.deploymentName !== options.deployment) return yield* new PushTargetMismatch({
569
+ expected: options.deployment,
570
+ actual: deployed.deploymentName
571
+ });
572
+ return deployed.url;
573
+ });
574
+ //#endregion
575
+ //#region src/Deployment.ts
576
+ const Deployment = Resource("Convex.Deployment", { defaultRemovalPolicy: "retain" });
577
+ /** The props name another deployment than the one in state. */
578
+ var DeploymentIdentityChange = class extends Schema.TaggedError()("DeploymentIdentityChange", {
579
+ /** Name of the deployment in state, such as `happy-animal-123`. */
580
+ deployment: Schema.String,
581
+ field: Schema.Literals([
582
+ "projectId",
583
+ "type",
584
+ "name"
585
+ ]),
586
+ current: Schema.String,
587
+ requested: Schema.String
532
588
  }) {
533
589
  get message() {
534
- const listed = this.keys.length === 0 ? "no key" : this.keys.map((key) => `"${key.name}" (id ${key.id})`).join(", ");
535
- return this.afterCreate ? `Convex created deploy key "${this.name}" on ${this.target}, but the listing shows ${listed} with that name, not exactly one, so this resource cannot track the new key. Alchemy does not revoke it. Delete the keys with that name in the Convex dashboard, then run the deploy again.` : `${this.target} already lists ${listed} with the name "${this.name}", but Alchemy state has no such key. An interrupted run probably created it, and its secret is lost. Delete these keys in the Convex dashboard, then run the deploy again.`;
590
+ 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.`;
536
591
  }
537
592
  };
538
- /**
539
- * Convex returned the Management API credential itself as the new key. This
540
- * happens with an OAuth token: Convex creates no key. A delete of the
541
- * returned secret would revoke the credential, so the resource does not keep it.
542
- */
543
- var DeployKeyIsCredential = class extends Schema.TaggedError()("DeployKeyIsCredential", {
544
- target: Schema.String,
545
- name: Schema.String
593
+ /** The project has no default production deployment for a `prod` resource to take over. */
594
+ var ProductionDeploymentNotFound = class extends Schema.TaggedError()("ProductionDeploymentNotFound", { projectId: Schema.Number }) {
595
+ get message() {
596
+ 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.`;
597
+ }
598
+ };
599
+ /** More than one deployment of the project has the name, so the lookup cannot choose one. */
600
+ var AmbiguousDeployment = class extends Schema.TaggedError()("AmbiguousDeployment", {
601
+ projectId: Schema.Number,
602
+ type: Schema.Literals(["dev", "preview"]),
603
+ name: Schema.String,
604
+ deployments: Schema.Array(Schema.String)
546
605
  }) {
547
606
  get message() {
548
- return `Convex returned the Management API credential itself as deploy key "${this.name}" on ${this.target}. With an OAuth token, Convex creates no new key, and deleting the returned key would revoke the OAuth token. Alchemy does not keep it. Use a team access token in CONVEX_ACCESS_TOKEN, or the Convex CLI login.`;
607
+ 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.`;
549
608
  }
550
609
  };
551
610
  /**
552
- * Creates a key named `name` and ties it to the caller. Fails before the
553
- * create when keys with that name already exist. After the create, fails
554
- * when the secret is the Management API credential itself, or when the
555
- * listing does not show exactly one key with that name. It never revokes a
556
- * key: the returned secret can be a shared credential.
611
+ * The URL of a Convex cloud deployment: `https://<name>.convex.cloud`, or
612
+ * `https://<name>.<region>.convex.cloud` outside US East.
557
613
  */
558
- const createTrackedKey = (options) => Effect.gen(function* () {
559
- const { target, name } = options;
560
- const existing = keysListedAs(yield* options.list, name);
561
- if (existing.length > 0) return yield* new DeployKeyRecoveryRequired({
562
- target,
563
- name,
564
- keys: existing,
565
- afterCreate: false
566
- });
567
- const secret = yield* options.create;
568
- if (yield* options.isCredentialToken(secret)) return yield* new DeployKeyIsCredential({
569
- target,
570
- name
571
- });
572
- const listed = keysListedAs(yield* options.list, name);
573
- const [only] = listed;
574
- if (only === void 0 || listed.length !== 1) return yield* new DeployKeyRecoveryRequired({
575
- target,
576
- name,
577
- keys: listed,
578
- afterCreate: true
579
- });
580
- return {
581
- keyId: only.id,
582
- uniqueName: only.name,
583
- secret: Redacted.make(secret)
584
- };
614
+ const CLOUD_URL = /^https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud$/;
615
+ /**
616
+ * The HTTP actions URL of a deployment: the client URL with `.convex.site`
617
+ * for `.convex.cloud`, as Convex sets CONVEX_SITE_URL. Custom domains are
618
+ * not covered.
619
+ */
620
+ const siteUrlOf = (url) => url.replace(/\.convex\.cloud$/, ".convex.site");
621
+ /** One row of list_deployments, GET /deployments/{name}, and create_deployment. Only the fields the provider uses. */
622
+ const DeploymentRow$1 = Schema.Struct({
623
+ name: Schema.String,
624
+ projectId: Schema.Number,
625
+ deploymentType: Schema.String,
626
+ isDefault: Schema.Boolean,
627
+ reference: Schema.String,
628
+ /** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
629
+ previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
630
+ deploymentUrl: Schema.String.check(Schema.isPattern(CLOUD_URL)),
631
+ /** Absent or null until the first push: list_deployments leaves it out for a new deployment. */
632
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
633
+ });
634
+ const DeploymentRows$1 = Schema.Array(DeploymentRow$1);
635
+ /** GET https://<deployment>/api/v1/deployment_info, for a cloud deployment. Only the fields the provider uses. */
636
+ const DeploymentInfo = Schema.Struct({
637
+ kind: Schema.Literal("cloud"),
638
+ projectId: Schema.Number,
639
+ deploymentType: Schema.String,
640
+ reference: Schema.optionalKey(Schema.NullOr(Schema.String))
641
+ });
642
+ /** GET https://<deployment>/api/v1/get_canonical_urls */
643
+ const CanonicalUrls = Schema.Struct({
644
+ convexCloudUrl: Schema.String,
645
+ convexSiteUrl: Schema.String
585
646
  });
647
+ const targetOf = (row, type, previewName) => ({
648
+ projectId: row.projectId,
649
+ type,
650
+ name: row.name,
651
+ reference: row.reference,
652
+ previewName: type === "preview" ? previewName : void 0,
653
+ url: row.deploymentUrl,
654
+ siteUrl: siteUrlOf(row.deploymentUrl)
655
+ });
656
+ /** True when the deployment has received a push. */
657
+ const isDeployed = (row) => row.lastDeployTime !== void 0 && row.lastDeployTime !== null;
658
+ /** The `name` prop of a deployment: the dev reference or the preview name. */
659
+ const nameOf = (target) => target.type === "dev" ? target.reference : target.type === "preview" ? target.previewName : void 0;
586
660
  /**
587
- * The secret to send to a delete call, or undefined when a delete is not
588
- * safe. Only a key with a `keyId` in state is known to be a new key that
589
- * this resource owns: createTrackedKey found exactly one new listed key, and
590
- * the secret was not the credential. State from 0.1.x has no `keyId`, and its
591
- * secret can be an OAuth token that Convex returned as a key, so a delete
592
- * could revoke that OAuth token. Such a key stays, and a warning names it.
661
+ * Fails with {@link DeploymentIdentityChange} when the props name another
662
+ * deployment than `current`: the attributes in state, or a live deployment.
593
663
  */
594
- const secretToDelete = (key, label) => Effect.gen(function* () {
595
- if (key.keyId === void 0 || !Redacted.isRedacted(key.secret)) {
596
- yield* Effect.logWarning(`${label} has no verified key id and secret in state (state from 0.1.x, for example), so Alchemy does not delete it. Delete it in the Convex dashboard.`);
597
- return;
598
- }
599
- return Redacted.value(key.secret);
664
+ const checkIdentity = (current, news) => {
665
+ const change = (field, from, to) => Effect.fail(new DeploymentIdentityChange({
666
+ deployment: current.name,
667
+ field,
668
+ current: from,
669
+ requested: to
670
+ }));
671
+ if (current.projectId !== news.project.projectId) return change("projectId", String(current.projectId), String(news.project.projectId));
672
+ if (current.type !== news.type) return change("type", current.type, news.type);
673
+ const currentName = nameOf(current);
674
+ if (news.type !== "prod" && currentName !== news.name) return change("name", currentName ?? "(none)", news.name);
675
+ return Effect.void;
676
+ };
677
+ const addressOf = (props) => ({
678
+ projectId: props.project.projectId,
679
+ type: props.type,
680
+ name: props.type === "prod" ? void 0 : props.name
600
681
  });
601
- const DeployKey = Resource("Convex.DeployKey");
602
- const DeployKeyProvider = () => Provider.effect(DeployKey, Effect.gen(function* () {
682
+ /** True when an update writes or removes variables: `env` has names, or state has names to remove. */
683
+ const changesEnv = (news, output) => Object.keys(news.env ?? {}).length > 0 || envNamesOf(output).length > 0;
684
+ /** The `env` names in state. State from 0.3 has none. */
685
+ const envNamesOf = (output) => output?.envNames ?? [];
686
+ /** The address of the deployment in state. */
687
+ const addressInState = (output) => ({
688
+ projectId: output.projectId,
689
+ type: output.type,
690
+ name: nameOf(output)
691
+ });
692
+ const ProjectSlugs = Schema.Struct({
693
+ slug: Schema.String,
694
+ teamSlug: Schema.String
695
+ });
696
+ /** True for an answer that says this credential cannot read the object. */
697
+ const isRefused = (error) => error.status === 400 || error.status === 401 || error.status === 403 || error.status === 404;
698
+ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function* () {
603
699
  const api = yield* ManagementApi;
604
- const listKeys = (deployment) => api.request("list deploy keys", (client) => client.GET("/deployments/{deployment_name}/list_deploy_keys", { params: { path: { deployment_name: deployment } } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(DeployKeyList)(body)));
700
+ const credentials = yield* Credentials;
701
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
702
+ /** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
703
+ const getByName = (management, name) => absentAsUndefined(management.request("get deployment", (client) => client.GET("/deployments/{deployment_name}", { params: { path: { deployment_name: name } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(DeploymentRow$1)(body)));
704
+ const listDeployments = (management, projectId, type, isDefault) => management.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: {
705
+ path: { project_id: projectId },
706
+ query: {
707
+ deploymentType: type,
708
+ ...isDefault === void 0 ? {} : { isDefault }
709
+ }
710
+ } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows$1)(body)));
711
+ /** The row of the project's default production deployment, or undefined. */
712
+ const productionRow = (management, projectId) => listDeployments(management, projectId, "prod", true).pipe(Effect.map((rows) => rows.find((candidate) => candidate.deploymentType === "prod" && candidate.isDefault)));
713
+ /** The project's default production deployment, or undefined. */
714
+ const production = (management, projectId) => productionRow(management, projectId).pipe(Effect.map((row) => row === void 0 ? void 0 : targetOf(row, "prod", void 0)));
605
715
  /**
606
- * `id` is the secret: Convex accepts it, and it matches only the key
607
- * that this resource created. A second delete answers 404.
716
+ * The one dev deployment with this reference, or the one preview
717
+ * deployment with this preview name, or undefined.
608
718
  */
609
- const deleteKey = (deployment, secret) => retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
610
- params: { path: { deployment_name: deployment } },
611
- body: { id: secret }
612
- })));
719
+ const findByName = (management, projectId, type, name) => Effect.gen(function* () {
720
+ const matches = (yield* listDeployments(management, projectId, type)).filter((row) => row.deploymentType === type && (type === "dev" ? row.reference === name : row.previewIdentifier === name));
721
+ if (matches.length > 1) return yield* new AmbiguousDeployment({
722
+ projectId,
723
+ type,
724
+ name,
725
+ deployments: matches.map((row) => row.name)
726
+ });
727
+ return matches[0];
728
+ });
729
+ /** The deployment that the address names, through the Management API, or undefined. */
730
+ const lookup = (management, address) => address.type === "prod" || address.name === void 0 ? production(management, address.projectId) : findByName(management, address.projectId, address.type, address.name).pipe(Effect.map((row) => row === void 0 ? void 0 : targetOf(row, address.type, address.name)));
731
+ /** The deployment of a deploy key, when it is the deployment that the address names. */
732
+ const deployKeyTarget = (key, deployment, address) => Effect.gen(function* () {
733
+ const info = Schema.decodeUnknownSync(DeploymentInfo)(yield* api.deploymentRequest("get deployment info", deployment, key, (client) => client.GET("/deployment_info")));
734
+ const reference = info.reference ?? "";
735
+ if (info.projectId !== address.projectId || info.deploymentType !== address.type || address.type === "dev" && reference !== address.name) return;
736
+ const urls = Schema.decodeUnknownSync(CanonicalUrls)(yield* api.deploymentRequest("get canonical urls", deployment, key, (client) => client.GET("/get_canonical_urls")));
737
+ return {
738
+ projectId: info.projectId,
739
+ type: address.type,
740
+ name: deployment,
741
+ reference,
742
+ previewName: void 0,
743
+ url: urls.convexCloudUrl,
744
+ siteUrl: urls.convexSiteUrl
745
+ };
746
+ }).pipe(Effect.catchIf(isRefused, () => Effect.succeed(void 0)));
747
+ /** True when the preview deploy key belongs to the project. */
748
+ const previewKeyFits = (management, slugs, projectId) => management.request("get project", (client) => client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } })).pipe(Effect.map((body) => {
749
+ const project = Schema.decodeUnknownSync(ProjectSlugs)(body);
750
+ return project.slug === slugs.project && project.teamSlug === slugs.team;
751
+ }), Effect.catchIf(isRefused, () => Effect.succeed(false)));
752
+ /**
753
+ * The narrowest credential that reaches the deployment. See {@link Access}.
754
+ * When `changesEnv`, a preview deploy key gives way to the access token,
755
+ * because it cannot change variables.
756
+ */
757
+ const accessFor = (address, changesEnv) => Effect.gen(function* () {
758
+ const key = yield* credentials.deployKey;
759
+ if (key !== void 0) {
760
+ const target = keyTargetOf(Redacted.value(key));
761
+ if (target.kind === "deployment" && address.type !== "preview") {
762
+ const found = yield* deployKeyTarget(key, target.deployment, address);
763
+ if (found !== void 0) return {
764
+ kind: "deployKey",
765
+ key,
766
+ target: found
767
+ };
768
+ }
769
+ if (target.kind === "preview" && address.type === "preview") {
770
+ const scoped = api.withToken(key);
771
+ if (!(changesEnv && (yield* credentials.hasAccessToken)) && (yield* previewKeyFits(scoped, target, address.projectId))) return {
772
+ kind: "previewKey",
773
+ key,
774
+ api: scoped
775
+ };
776
+ }
777
+ }
778
+ return {
779
+ kind: "token",
780
+ api
781
+ };
782
+ });
783
+ /** The live deployment of a resource in state, or undefined when it is gone. */
784
+ const live = (access, output) => Effect.gen(function* () {
785
+ if (access.kind === "deployKey") return access.target;
786
+ if (output.type === "prod") return yield* absentAsUndefined(production(access.api, output.projectId));
787
+ const row = yield* getByName(access.api, output.name);
788
+ return row === void 0 ? void 0 : targetOf(row, output.type, output.previewName);
789
+ });
790
+ /** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
791
+ const wantedHash = (news, deployment) => news.source === void 0 && Object.keys(news.env ?? {}).length === 0 ? Effect.succeed(void 0) : pushHash({
792
+ source: news.source,
793
+ deployment,
794
+ env: news.env ?? {}
795
+ });
613
796
  return {
614
- stables: ["uniqueName", "deployment"],
615
- diff: ({ olds, news, output }) => {
616
- if (!isResolved(news)) return Effect.succeed(void 0);
617
- return Effect.succeed(output !== void 0 && output.deployKey === void 0 || havePropsChanged(olds, news) ? { action: "replace" } : { action: "noop" });
618
- },
619
- read: Effect.fn(function* ({ output }) {
620
- if (output === void 0) return void 0;
621
- const entries = yield* absentAsUndefined(listKeys(output.deployment));
622
- return entries !== void 0 && isKeyListed(entries, output) ? output : void 0;
797
+ stables: [
798
+ "projectId",
799
+ "type",
800
+ "previewName"
801
+ ],
802
+ diff: Effect.fn(function* ({ news, output }) {
803
+ if (!isResolved(news) || output === void 0) return void 0;
804
+ yield* checkIdentity(output, news);
805
+ const access = yield* accessFor(addressOf(news), changesEnv(news, output));
806
+ if ((yield* live(access, output))?.name !== output.name) return { action: "update" };
807
+ return (yield* wantedHash(news, output.name)) === output.hash ? { action: "noop" } : { action: "update" };
623
808
  }),
624
- reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
809
+ read: Effect.fn(function* ({ olds, output }) {
625
810
  if (output !== void 0) {
626
- if (isKeyListed(yield* listKeys(news.deployment), output)) return output;
811
+ const access = yield* accessFor(addressInState(output), false);
812
+ const current = yield* live(access, output);
813
+ return current === void 0 ? void 0 : {
814
+ ...output,
815
+ ...current
816
+ };
817
+ }
818
+ const address = addressOf(olds);
819
+ const access = yield* accessFor(address, false);
820
+ if (access.kind === "deployKey") return Unowned({
821
+ ...access.target,
822
+ hash: void 0,
823
+ envNames: []
824
+ });
825
+ if (olds.type === "prod") {
826
+ const row = yield* productionRow(access.api, address.projectId);
827
+ if (row === void 0) return void 0;
828
+ const attributes = {
829
+ ...targetOf(row, "prod", void 0),
830
+ hash: void 0,
831
+ envNames: []
832
+ };
833
+ return isDeployed(row) ? Unowned(attributes) : attributes;
627
834
  }
628
- const name = requestedKeyName(news.name, fqn, instanceId);
629
- const { keyId, uniqueName, secret } = yield* createTrackedKey({
630
- target: `deployment ${news.deployment}`,
631
- name,
632
- list: listKeys(news.deployment),
633
- create: api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
634
- params: { path: { deployment_name: news.deployment } },
835
+ const found = yield* lookup(access.api, address);
836
+ if (found === void 0) return void 0;
837
+ return Unowned({
838
+ ...found,
839
+ hash: void 0,
840
+ envNames: []
841
+ });
842
+ }),
843
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
844
+ if (output !== void 0) yield* checkIdentity(output, news);
845
+ if (news.source !== void 0) yield* checkSource(news.source);
846
+ const address = addressOf(news);
847
+ const access = yield* accessFor(address, changesEnv(news, output));
848
+ const env = news.env ?? {};
849
+ if (access.kind === "previewKey" && changesEnv(news, output)) return yield* new EnvironmentNeedsDeployKey({ deployment: output?.name ?? address.name ?? "(new)" });
850
+ const adopt = yield* shouldAdopt(fqn);
851
+ const refuse = (name) => new OwnedBySomeoneElse({
852
+ 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"}.`,
853
+ resourceType: Deployment.Type,
854
+ physicalName: name
855
+ });
856
+ const { target, created } = yield* Effect.gen(function* () {
857
+ if (access.kind === "deployKey") {
858
+ if (output === void 0 && !adopt) return yield* refuse(access.target.name);
859
+ return {
860
+ target: access.target,
861
+ created: false
862
+ };
863
+ }
864
+ if (news.type === "prod") {
865
+ const row = yield* productionRow(access.api, address.projectId);
866
+ if (row === void 0) return yield* new ProductionDeploymentNotFound({ projectId: address.projectId });
867
+ if (output === void 0 && isDeployed(row) && !adopt) return yield* refuse(row.name);
868
+ return {
869
+ target: targetOf(row, "prod", void 0),
870
+ created: false
871
+ };
872
+ }
873
+ if (output !== void 0) {
874
+ const row = yield* getByName(access.api, output.name);
875
+ if (row !== void 0) {
876
+ if (row.deploymentType !== output.type) return yield* new DeploymentIdentityChange({
877
+ deployment: row.name,
878
+ field: "type",
879
+ current: row.deploymentType,
880
+ requested: news.type
881
+ });
882
+ const current = targetOf(row, output.type, output.previewName);
883
+ yield* checkIdentity(current, news);
884
+ return {
885
+ target: current,
886
+ created: false
887
+ };
888
+ }
889
+ }
890
+ const existing = yield* lookup(access.api, address);
891
+ if (existing !== void 0) {
892
+ if (!adopt) return yield* refuse(existing.name);
893
+ return {
894
+ target: existing,
895
+ created: false
896
+ };
897
+ }
898
+ const row = yield* access.api.request("create deployment", (client) => client.POST("/projects/{project_id}/create_deployment", {
899
+ params: { path: { project_id: address.projectId } },
635
900
  body: {
636
- name,
637
- ...news.allowedActions === void 0 ? {} : { allowedActions: [...news.allowedActions] }
901
+ type: news.type,
902
+ reference: news.name
638
903
  }
639
- })).pipe(Effect.map((body) => Schema.decodeUnknownSync(CreatedDeployKey)(body).deployKey)),
640
- isCredentialToken: api.isCredentialToken
904
+ }));
905
+ return {
906
+ target: targetOf(Schema.decodeUnknownSync(DeploymentRow$1)(row), news.type, news.name),
907
+ created: true
908
+ };
641
909
  });
642
- return {
643
- uniqueName,
644
- keyId,
645
- deployment: news.deployment,
646
- deployKey: secret
910
+ const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
911
+ const before = yield* wantedHash(news, target.name);
912
+ if (before === void 0 && previousEnvNames.length === 0) return {
913
+ ...target,
914
+ hash: void 0,
915
+ envNames: []
647
916
  };
648
- }),
649
- delete: Effect.fn(function* ({ output }) {
650
- const secret = yield* secretToDelete({
651
- keyId: output.keyId,
652
- secret: output.deployKey
653
- }, `Convex.DeployKey "${output.uniqueName}" on ${output.deployment}`);
654
- if (secret === void 0) return;
655
- yield* absentAsUndefined(deleteKey(output.deployment, secret));
656
- })
657
- };
658
- }));
659
- //#endregion
660
- //#region src/PreviewDeployKey.ts
661
- /** GET /projects/{project_id}/list_preview_deploy_keys */
662
- const PreviewDeployKeyList = Schema.Struct({ items: Schema.Array(ListedKey) });
663
- /** POST /projects/{project_id}/create_preview_deploy_key */
664
- const CreatedPreviewDeployKey = Schema.Struct({ previewDeployKey: Schema.String });
665
- const PreviewDeployKey = Resource("Convex.PreviewDeployKey");
666
- const PreviewDeployKeyProvider = () => Provider.effect(PreviewDeployKey, Effect.gen(function* () {
667
- const api = yield* ManagementApi;
668
- const listKeys = (projectId) => api.request("list preview deploy keys", (client) => client.GET("/projects/{project_id}/list_preview_deploy_keys", { params: { path: { project_id: projectId } } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(PreviewDeployKeyList)(body).items));
669
- /**
670
- * `id` is the secret: Convex accepts it, and it matches only the key
671
- * that this resource created. A second delete answers 404.
672
- */
673
- const deleteKey = (projectId, secret) => retryIdempotentWrite(api.requestVoid("delete preview deploy key", (client) => client.POST("/projects/{project_id}/delete_preview_deploy_key", {
674
- params: { path: { project_id: projectId } },
675
- body: { id: secret }
676
- })));
677
- return {
678
- stables: ["uniqueName", "projectId"],
679
- diff: ({ olds, news, output }) => {
680
- if (!isResolved(news)) return Effect.succeed(void 0);
681
- return Effect.succeed(output !== void 0 && output.previewDeployKey === void 0 || havePropsChanged(olds, news) ? { action: "replace" } : { action: "noop" });
682
- },
683
- read: Effect.fn(function* ({ output }) {
684
- if (output === void 0) return void 0;
685
- const entries = yield* absentAsUndefined(listKeys(output.projectId));
686
- return entries !== void 0 && isKeyListed(entries, output) ? output : void 0;
687
- }),
688
- reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
689
- if (output !== void 0) {
690
- if (isKeyListed(yield* listKeys(news.projectId), output)) return output;
691
- }
692
- const name = requestedKeyName(news.name, fqn, instanceId);
693
- const { keyId, uniqueName, secret } = yield* createTrackedKey({
694
- target: `project ${news.projectId}`,
695
- name,
696
- list: listKeys(news.projectId),
697
- create: api.request("create preview deploy key", (client) => client.POST("/projects/{project_id}/create_preview_deploy_key", {
698
- params: { path: { project_id: news.projectId } },
699
- body: { name }
700
- })).pipe(Effect.map((body) => Schema.decodeUnknownSync(CreatedPreviewDeployKey)(body).previewDeployKey)),
701
- isCredentialToken: api.isCredentialToken
702
- });
917
+ if (output?.name === target.name && before === output.hash) return {
918
+ ...target,
919
+ hash: output.hash,
920
+ envNames: previousEnvNames
921
+ };
922
+ const options = {
923
+ deployment: target.name,
924
+ url: target.url,
925
+ source: news.source,
926
+ env,
927
+ previousEnvNames,
928
+ adopt
929
+ };
930
+ yield* (access.kind === "deployKey" ? push(spawner, access.key, {
931
+ ...options,
932
+ previewName: void 0
933
+ }) : access.kind === "previewKey" ? push(spawner, access.key, {
934
+ ...options,
935
+ previewName: target.previewName
936
+ }) : withPushKey(access.api, target.name, news.source, (key) => push(spawner, key, {
937
+ ...options,
938
+ previewName: void 0
939
+ }))).pipe(Effect.onError(() => created && access.kind !== "deployKey" ? absentAsUndefined(retryIdempotentWrite(access.api.requestVoid("delete deployment", (client) => client.POST("/deployments/{deployment_name}/delete", { params: { path: { deployment_name: target.name } } })))).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not delete ${target.name} after the failed push (${error.message}). The next run takes it over only with --adopt.`))) : Effect.void));
703
940
  return {
704
- uniqueName,
705
- keyId,
706
- projectId: news.projectId,
707
- previewDeployKey: secret
941
+ ...target,
942
+ hash: yield* wantedHash(news, target.name),
943
+ envNames: Object.keys(env).sort()
708
944
  };
709
945
  }),
710
946
  delete: Effect.fn(function* ({ output }) {
711
- const secret = yield* secretToDelete({
712
- keyId: output.keyId,
713
- secret: output.previewDeployKey
714
- }, `Convex.PreviewDeployKey "${output.uniqueName}" in project ${output.projectId}`);
715
- if (secret === void 0) return;
716
- yield* absentAsUndefined(deleteKey(output.projectId, secret));
947
+ const keep = Effect.logWarning(`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.`);
948
+ if (output.type === "prod") return yield* keep;
949
+ const access = yield* accessFor(addressInState(output), false);
950
+ const management = access.kind === "deployKey" ? api : access.api;
951
+ const row = yield* getByName(management, output.name);
952
+ if (row === void 0) return;
953
+ if (row.deploymentType === "prod") return yield* keep;
954
+ yield* absentAsUndefined(retryIdempotentWrite(management.requestVoid("delete deployment", (client) => client.POST("/deployments/{deployment_name}/delete", { params: { path: { deployment_name: output.name } } }))));
717
955
  })
718
956
  };
719
957
  }));
@@ -761,12 +999,32 @@ var CreatedProjectNotFound = class extends Schema.TaggedError()("CreatedProjectN
761
999
  return `Convex created project ${this.projectId} but GET /projects/${this.projectId} did not return it. Run the deploy again with --adopt to take over project ${this.projectId}.`;
762
1000
  }
763
1001
  };
1002
+ /** No project in the team has the slug. A `slug` resource never creates one. */
1003
+ var ProjectNotFound = class extends Schema.TaggedError()("ProjectNotFound", {
1004
+ team: Schema.String,
1005
+ slug: Schema.String
1006
+ }) {
1007
+ get message() {
1008
+ return `Team ${this.team} has no project with the slug "${this.slug}", and a Convex.Project with a slug never creates one. Check the slug in the project URL of the Convex dashboard, or use name to create a project.`;
1009
+ }
1010
+ };
1011
+ /** The props change the slug of a `slug` resource: that is another project. */
1012
+ var ProjectSlugChange = class extends Schema.TaggedError()("ProjectSlugChange", {
1013
+ projectId: Schema.Number,
1014
+ slug: Schema.String,
1015
+ requestedSlug: Schema.String
1016
+ }) {
1017
+ get message() {
1018
+ return `Convex.Project ${this.projectId} has the slug "${this.slug}", and the props ask for "${this.requestedSlug}", which is another project. Set slug back to "${this.slug}". For the other project, create a new resource with a new logical id.`;
1019
+ }
1020
+ };
764
1021
  /** Fields of GET /projects/{id}, PATCH /projects/{id}, and GET /teams/{team}/projects rows that the provider uses. */
765
1022
  const ProjectDetails = Schema.Struct({
766
1023
  id: Schema.Number,
767
1024
  name: Schema.String,
768
1025
  slug: Schema.String,
769
1026
  teamId: Schema.Number,
1027
+ teamSlug: Schema.String,
770
1028
  prodDeploymentName: Schema.NullOr(Schema.String)
771
1029
  });
772
1030
  /** GET /teams/{team_id}/projects: one page of projects. */
@@ -807,6 +1065,8 @@ const checkCreateTimeProps = (projectId, olds, news) => {
807
1065
  }));
808
1066
  return Effect.void;
809
1067
  };
1068
+ /** True when `team`, a slug or a numeric id, names the team of the project. */
1069
+ const isTeamOf = (details, team) => typeof team === "number" || /^\d+$/.test(team) ? details.teamId === Number(team) : details.teamSlug === team;
810
1070
  const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
811
1071
  const api = yield* ManagementApi;
812
1072
  const prodDeployment = (projectId) => api.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: { path: { project_id: projectId } } })).pipe(Effect.map((rows) => Schema.decodeUnknownSync(DeploymentRows)(rows).find((row) => row.deploymentType === "prod" && row.isDefault)));
@@ -819,6 +1079,11 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
819
1079
  prodDeploymentUrl: prod?.deploymentUrl
820
1080
  })));
821
1081
  const getById = (projectId) => absentAsUndefined(api.request("get project", (client) => client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1082
+ /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
1083
+ const getBySlug = (team, slug) => absentAsUndefined(api.request("get project by slug", (client) => client.GET("/teams/{team_id_or_slug}/projects/{project_slug}", { params: { path: {
1084
+ team_id_or_slug: String(team),
1085
+ project_slug: slug
1086
+ } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
822
1087
  /**
823
1088
  * The one project in the team with exactly this name, or undefined.
824
1089
  * Reads every page. `q` narrows the listing on the server, and the
@@ -884,6 +1149,22 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
884
1149
  diff: Effect.fn(function* ({ olds, news, output }) {
885
1150
  if (!isResolved(news)) return void 0;
886
1151
  if (output === void 0) return void 0;
1152
+ if ("slug" in news) {
1153
+ if (news.slug !== output.slug) return yield* new ProjectSlugChange({
1154
+ projectId: output.projectId,
1155
+ slug: output.slug,
1156
+ requestedSlug: news.slug
1157
+ });
1158
+ if (olds.team !== news.team) {
1159
+ const details = yield* getById(output.projectId);
1160
+ if (details !== void 0 && !isTeamOf(details, news.team)) return yield* new ProjectTeamChange({
1161
+ projectId: output.projectId,
1162
+ teamId: output.teamId,
1163
+ requestedTeamId: yield* api.resolveTeamId(news.team)
1164
+ });
1165
+ }
1166
+ return { action: "noop" };
1167
+ }
887
1168
  if (olds.team !== news.team) {
888
1169
  const requestedTeamId = yield* api.resolveTeamId(news.team);
889
1170
  if (requestedTeamId !== output.teamId) return yield* new ProjectTeamChange({
@@ -892,20 +1173,43 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
892
1173
  requestedTeamId
893
1174
  });
894
1175
  }
895
- return olds.name === news.name ? void 0 : { action: "update" };
1176
+ return "name" in olds && olds.name === news.name ? void 0 : { action: "update" };
896
1177
  }),
897
1178
  read: Effect.fn(function* ({ olds, output }) {
898
1179
  if (output !== void 0) {
899
1180
  const details = yield* getById(output.projectId);
900
1181
  return details === void 0 ? void 0 : yield* attributesOf(details);
901
1182
  }
902
- const existing = yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
1183
+ const existing = "slug" in olds ? yield* getBySlug(olds.team, olds.slug) : yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
903
1184
  return existing === void 0 ? void 0 : Unowned(yield* attributesOf(existing));
904
1185
  }),
905
1186
  reconcile: Effect.fn(function* ({ fqn, olds, news, output }) {
1187
+ if ("slug" in news) {
1188
+ const details = output === void 0 ? yield* getBySlug(news.team, news.slug) : yield* getById(output.projectId);
1189
+ if (details === void 0) return yield* new ProjectNotFound({
1190
+ team: String(news.team),
1191
+ slug: news.slug
1192
+ });
1193
+ if (details.slug !== news.slug) return yield* new ProjectSlugChange({
1194
+ projectId: details.id,
1195
+ slug: details.slug,
1196
+ requestedSlug: news.slug
1197
+ });
1198
+ if (!isTeamOf(details, news.team)) return yield* new ProjectTeamChange({
1199
+ projectId: details.id,
1200
+ teamId: details.teamId,
1201
+ requestedTeamId: yield* api.resolveTeamId(news.team)
1202
+ });
1203
+ if (output === void 0 && !(yield* shouldAdopt(fqn))) return yield* new OwnedBySomeoneElse({
1204
+ message: `Convex project ${details.id} ("${news.slug}") exists and is not in this stack's state. Re-run with --adopt to take it over.`,
1205
+ resourceType: Project.Type,
1206
+ physicalName: news.slug
1207
+ });
1208
+ return yield* attributesOf(details);
1209
+ }
906
1210
  const teamId = yield* api.resolveTeamId(news.team);
907
1211
  if (output !== void 0) {
908
- if (olds !== void 0) yield* checkCreateTimeProps(output.projectId, olds, news);
1212
+ if (olds !== void 0 && "name" in olds) yield* checkCreateTimeProps(output.projectId, olds, news);
909
1213
  if (output.teamId !== teamId) return yield* new ProjectTeamChange({
910
1214
  projectId: output.projectId,
911
1215
  teamId: output.teamId,
@@ -946,18 +1250,12 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
946
1250
  //#region src/Providers.ts
947
1251
  var Providers = class extends Provider.ProviderCollection()("Convex") {};
948
1252
  /**
949
- * All Convex resource providers. Pass a credentials layer to override the
950
- * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login).
1253
+ * The Convex resource providers. Pass a credentials layer to override the
1254
+ * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login, and
1255
+ * `CONVEX_DEPLOY_KEY`).
951
1256
  */
952
- const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([
953
- Project,
954
- DeployKey,
955
- PreviewDeployKey,
956
- EnvironmentVariable,
957
- DefaultEnvironmentVariable,
958
- Deploy
959
- ])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeployKeyProvider(), PreviewDeployKeyProvider(), EnvironmentVariableProvider(), DefaultEnvironmentVariableProvider(), DeployProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.provide(CommandExecutorLive()), Layer.orDie);
1257
+ const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([Project, Deployment])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.orDie);
960
1258
  //#endregion
961
- export { ACCESS_TOKEN_ENV, AmbiguousProject, ConvexApiError, CreatedProjectNotFound, Credentials, CredentialsError, DefaultEnvironmentVariable, Deploy, DeployArgumentError, DeployKey, DeployKeyIsCredential, DeployKeyRecoveryRequired, DeployOutputError, EnvironmentVariable, ManagementApi, PreviewDeployKey, Project, ProjectDeploymentChange, ProjectTeamChange, Providers, VariableIdentityChange, deployArgs, fromConfig, fromToken, parseDeployOutput, providers };
1259
+ export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers };
962
1260
 
963
1261
  //# sourceMappingURL=index.js.map