@samebase/alchemy-convex 0.1.0 → 0.2.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 (51) hide show
  1. package/README.md +85 -17
  2. package/lib/index.d.ts +352 -9
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +962 -10
  5. package/lib/index.js.map +1 -1
  6. package/package.json +7 -4
  7. package/src/Adoption.ts +30 -0
  8. package/src/DefaultEnvironmentVariable.ts +127 -29
  9. package/src/Deploy.ts +37 -15
  10. package/src/DeployKey.ts +221 -60
  11. package/src/EnvironmentVariable.ts +174 -41
  12. package/src/ManagementApi.ts +57 -1
  13. package/src/PreviewDeployKey.ts +65 -38
  14. package/src/Project.ts +236 -52
  15. package/src/index.ts +9 -0
  16. package/lib/Credentials.d.ts +0 -39
  17. package/lib/Credentials.d.ts.map +0 -1
  18. package/lib/Credentials.js +0 -49
  19. package/lib/Credentials.js.map +0 -1
  20. package/lib/DefaultEnvironmentVariable.d.ts +0 -58
  21. package/lib/DefaultEnvironmentVariable.d.ts.map +0 -1
  22. package/lib/DefaultEnvironmentVariable.js +0 -93
  23. package/lib/DefaultEnvironmentVariable.js.map +0 -1
  24. package/lib/Deploy.d.ts +0 -62
  25. package/lib/Deploy.d.ts.map +0 -1
  26. package/lib/Deploy.js +0 -92
  27. package/lib/Deploy.js.map +0 -1
  28. package/lib/DeployKey.d.ts +0 -48
  29. package/lib/DeployKey.d.ts.map +0 -1
  30. package/lib/DeployKey.js +0 -104
  31. package/lib/DeployKey.js.map +0 -1
  32. package/lib/EnvironmentVariable.d.ts +0 -37
  33. package/lib/EnvironmentVariable.d.ts.map +0 -1
  34. package/lib/EnvironmentVariable.js +0 -65
  35. package/lib/EnvironmentVariable.js.map +0 -1
  36. package/lib/ManagementApi.d.ts +0 -50
  37. package/lib/ManagementApi.d.ts.map +0 -1
  38. package/lib/ManagementApi.js +0 -103
  39. package/lib/ManagementApi.js.map +0 -1
  40. package/lib/PreviewDeployKey.d.ts +0 -34
  41. package/lib/PreviewDeployKey.d.ts.map +0 -1
  42. package/lib/PreviewDeployKey.js +0 -78
  43. package/lib/PreviewDeployKey.js.map +0 -1
  44. package/lib/Project.d.ts +0 -29
  45. package/lib/Project.d.ts.map +0 -1
  46. package/lib/Project.js +0 -114
  47. package/lib/Project.js.map +0 -1
  48. package/lib/Providers.d.ts +0 -13
  49. package/lib/Providers.d.ts.map +0 -1
  50. package/lib/Providers.js +0 -30
  51. package/lib/Providers.js.map +0 -1
package/src/Deploy.ts CHANGED
@@ -12,6 +12,7 @@ import { havePropsChanged, isResolved } from "alchemy/Diff";
12
12
  import * as Provider from "alchemy/Provider";
13
13
  import * as Effect from "effect/Effect";
14
14
  import type * as Redacted from "effect/Redacted";
15
+ import * as Schema from "effect/Schema";
15
16
  import type { Providers } from "./Providers.ts";
16
17
 
17
18
  export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "timeout"> {
@@ -46,7 +47,9 @@ export interface DeployProps extends Pick<CommandRunProps, "cwd" | "env" | "time
46
47
  * Files hashed to skip an unchanged push. By default every non-gitignored
47
48
  * file under `cwd` and the nearest lockfile. Use
48
49
  * `{ include: ["convex/**"], lockfile: true }` to ignore frontend changes,
49
- * or `false` to push on every deploy.
50
+ * or `false` to push on every deploy. A preview deploy (`previewName` or
51
+ * `previewCreate`) pushes on every deploy: Convex can delete an expired
52
+ * preview deployment, and the hash cannot show that.
50
53
  * @default true
51
54
  */
52
55
  readonly memo?: MemoOptions | boolean;
@@ -99,6 +102,26 @@ export const deployArgs = (
99
102
  /** Number of output lines quoted when the CLI exits 0 without the deployed line. */
100
103
  const OUTPUT_TAIL_LINES = 20;
101
104
 
105
+ /** A `convex deploy` argument contains whitespace, which Alchemy's command runner splits on. */
106
+ export class DeployArgumentError extends Schema.TaggedError<DeployArgumentError>()(
107
+ "DeployArgumentError",
108
+ { argument: Schema.String },
109
+ ) {
110
+ override get message() {
111
+ 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.`;
112
+ }
113
+ }
114
+
115
+ /** `npx convex deploy` exited 0 but did not print the deployment URL. */
116
+ export class DeployOutputError extends Schema.TaggedError<DeployOutputError>()(
117
+ "DeployOutputError",
118
+ { outputTail: Schema.String },
119
+ ) {
120
+ override get message() {
121
+ 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}`;
122
+ }
123
+ }
124
+
102
125
  export const DeployProvider = () =>
103
126
  Provider.effect(
104
127
  Deploy,
@@ -120,7 +143,16 @@ export const DeployProvider = () =>
120
143
  // under the same name.
121
144
  diff: Effect.fn(function* ({ olds, news, output }) {
122
145
  if (!isResolved(news)) return undefined;
123
- if (output === undefined || output.hash === undefined || havePropsChanged(olds, news)) {
146
+ // A preview deployment can expire. The same hash then hides a
147
+ // missing deployment, so a preview always pushes. The CLI reuses
148
+ // a live preview with the same name.
149
+ const preview = news.previewName !== undefined || news.previewCreate !== undefined;
150
+ if (
151
+ preview ||
152
+ output === undefined ||
153
+ output.hash === undefined ||
154
+ havePropsChanged(olds, news)
155
+ ) {
124
156
  return { action: "update" } as const;
125
157
  }
126
158
  return { action: (yield* hashOf(news)) === output.hash ? "noop" : "update" } as const;
@@ -129,13 +161,7 @@ export const DeployProvider = () =>
129
161
  reconcile: Effect.fn(function* ({ news, session }) {
130
162
  const args = deployArgs(news);
131
163
  const spaced = args.find((arg) => /\s/.test(arg));
132
- if (spaced !== undefined) {
133
- return yield* Effect.die(
134
- new Error(
135
- `Convex.Deploy argument "${spaced}" contains whitespace; Alchemy splits commands on whitespace`,
136
- ),
137
- );
138
- }
164
+ if (spaced !== undefined) return yield* new DeployArgumentError({ argument: spaced });
139
165
  // A push is idempotent: the CLI uploads the full function set every time.
140
166
  const { stdout, stderr } = yield* run(
141
167
  {
@@ -150,16 +176,12 @@ export const DeployProvider = () =>
150
176
  const output = `${stdout}\n${stderr}`;
151
177
  const deployed = parseDeployOutput(output);
152
178
  if (deployed === undefined) {
153
- const tail = stripVTControlCharacters(output)
179
+ const outputTail = stripVTControlCharacters(output)
154
180
  .split(/\r?\n|\r/)
155
181
  .filter((line) => line.trim() !== "")
156
182
  .slice(-OUTPUT_TAIL_LINES)
157
183
  .join("\n");
158
- return yield* Effect.die(
159
- new Error(
160
- `npx convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output:\n${tail}`,
161
- ),
162
- );
184
+ return yield* new DeployOutputError({ outputTail });
163
185
  }
164
186
  return { ...deployed, hash: yield* hashOf(news) };
165
187
  }),
package/src/DeployKey.ts CHANGED
@@ -1,11 +1,27 @@
1
1
  // Convex.DeployKey: a deploy key for one deployment, such as the key
2
2
  // `npx convex deploy` reads from CONVEX_DEPLOY_KEY.
3
3
  //
4
- // Convex returns the secret only once, from create_deploy_key. The listing
5
- // shows each key under a unique name, the requested name plus a short id
6
- // suffix such as "my-key (0e71106d)", and delete_deploy_key accepts that
7
- // unique name. A key cannot be changed after creation, so every property
8
- // change is a replacement.
4
+ // Convex returns the secret only once, from create_deploy_key, and the
5
+ // response has no id. The listing shows each key with a numeric id and a
6
+ // name: the requested name, or, when a listed key already has that name, the
7
+ // requested name plus a suffix such as " (870993b4-ffe6-4911-adbd-e29f7fd712f2)".
8
+ //
9
+ // One resource must never revoke the key of another resource:
10
+ // - The requested name is the `name` prop plus a hash of the resource's fqn
11
+ // and instance id, so two resources with the same `name` request
12
+ // different names.
13
+ // - After the create, exactly one listed key must have the requested name.
14
+ // Otherwise the create fails with a typed error that lists the keys. State
15
+ // keeps that key's numeric id.
16
+ // - Delete sends the secret as `id`, so it can revoke only the key that this
17
+ // resource created. It never deletes by a listed name.
18
+ // - With an OAuth token as the Management API credential, Convex returns
19
+ // that token as the "new" key. The provider never stores, revokes, or
20
+ // deletes such a key: the create fails with DeployKeyIsCredential.
21
+ // - Delete runs only for a key with a `keyId` in state. A 0.1.x key, which
22
+ // has none, stays in Convex with a warning.
23
+ // A key cannot change after creation, so every property change is a replacement.
24
+ import { createHash } from "node:crypto";
9
25
  import { Resource } from "alchemy";
10
26
  import { havePropsChanged, isResolved } from "alchemy/Diff";
11
27
  import * as Provider from "alchemy/Provider";
@@ -13,7 +29,12 @@ import * as Effect from "effect/Effect";
13
29
  import * as Redacted from "effect/Redacted";
14
30
  import * as Schema from "effect/Schema";
15
31
  import type { components } from "@convex-dev/platform/managementApi";
16
- import { absentAsUndefined, ManagementApi } from "./ManagementApi.ts";
32
+ import {
33
+ absentAsUndefined,
34
+ type ConvexApiError,
35
+ ManagementApi,
36
+ retryIdempotentWrite,
37
+ } from "./ManagementApi.ts";
17
38
  import type { Providers } from "./Providers.ts";
18
39
 
19
40
  /** Actions a deploy key can be limited to, from the generated schema. */
@@ -23,35 +44,50 @@ export type DeployKeyAction = NonNullable<
23
44
 
24
45
  /**
25
46
  * True when `listedName` is how Convex lists a key created as `name`: the
26
- * requested name, a space, and one parenthesized id without spaces. The id
27
- * check keeps `my-key` from matching a key created as `my-key (old)`.
47
+ * requested name itself, or the requested name, a space, and one
48
+ * parenthesized suffix without spaces. The suffix check keeps `my-key` from
49
+ * matching a key created as `my-key (old)`.
28
50
  */
29
51
  const isListedAs = (listedName: string, name: string) => {
30
52
  const prefix = `${name} (`;
31
- return listedName.startsWith(prefix) && /^[^\s()]+\)$/.test(listedName.slice(prefix.length));
53
+ return (
54
+ listedName === name ||
55
+ (listedName.startsWith(prefix) && /^[^\s()]+\)$/.test(listedName.slice(prefix.length)))
56
+ );
32
57
  };
33
58
 
34
59
  /**
35
- * The listed key that a create call for `name` just made. The newest match
36
- * wins: an earlier attempt that failed before Alchemy saved state can leave
37
- * another key with the same requested name, and its secret is lost.
60
+ * True when the listing still has the key in state: by numeric id, or by
61
+ * listed name for state written by 0.1.x, which has no id.
38
62
  */
39
- export const selectCreatedKey = (
63
+ export const isKeyListed = (
64
+ entries: ReadonlyArray<typeof ListedKey.Type>,
65
+ key: { readonly keyId: number | undefined; readonly uniqueName: string },
66
+ ): boolean =>
67
+ entries.some((entry) =>
68
+ key.keyId === undefined ? entry.name === key.uniqueName : entry.id === key.keyId,
69
+ );
70
+
71
+ /** The listed keys that Convex shows for a key created as `name`. */
72
+ export const keysListedAs = (
40
73
  entries: ReadonlyArray<typeof ListedKey.Type>,
41
74
  name: string,
42
- ): typeof ListedKey.Type | undefined =>
43
- entries
44
- .filter((entry) => isListedAs(entry.name, name))
45
- .reduce<typeof ListedKey.Type | undefined>(
46
- (newest, entry) =>
47
- newest === undefined || entry.creationTime > newest.creationTime ? entry : newest,
48
- undefined,
49
- );
75
+ ): ReadonlyArray<typeof ListedKey.Type> => entries.filter((entry) => isListedAs(entry.name, name));
76
+
77
+ /**
78
+ * The name that a resource instance requests: `name`, a dash, and the first
79
+ * 12 hex characters of sha256("<fqn>:<instanceId>"). Two resources, or the
80
+ * old and the new generation of one replaced resource, never request the
81
+ * same name. Pattern from Confect `packages/alchemy/src/internal/ResourceIdentity.ts`
82
+ * (https://github.com/rjdellecese/confect, ISC license).
83
+ */
84
+ export const requestedKeyName = (name: string, fqn: string, instanceId: string): string =>
85
+ `${name}-${createHash("sha256").update(`${fqn}:${instanceId}`).digest("hex").slice(0, 12)}`;
50
86
 
51
87
  /** One row of list_deploy_keys and list_preview_deploy_keys. Only the fields the providers use. */
52
88
  export const ListedKey = Schema.Struct({
89
+ id: Schema.Number,
53
90
  name: Schema.String,
54
- creationTime: Schema.Number,
55
91
  });
56
92
 
57
93
  /** GET /deployments/{deployment_name}/list_deploy_keys */
@@ -60,18 +96,132 @@ export const DeployKeyList = Schema.Array(ListedKey);
60
96
  /** POST /deployments/{deployment_name}/create_deploy_key */
61
97
  export const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
62
98
 
99
+ /**
100
+ * A key cannot be tied to one resource: before the create, listed keys
101
+ * already have the requested name, or after the create, not exactly one has it.
102
+ */
103
+ export class DeployKeyRecoveryRequired extends Schema.TaggedError<DeployKeyRecoveryRequired>()(
104
+ "DeployKeyRecoveryRequired",
105
+ {
106
+ /** Where the keys live, such as `deployment happy-otter-123` or `project 3145389`. */
107
+ target: Schema.String,
108
+ /** The requested name. */
109
+ name: Schema.String,
110
+ /** The listed keys with the requested name. */
111
+ keys: Schema.Array(ListedKey),
112
+ /** True when this run created a key before the listing check failed. */
113
+ afterCreate: Schema.Boolean,
114
+ },
115
+ ) {
116
+ override get message() {
117
+ const listed =
118
+ this.keys.length === 0
119
+ ? "no key"
120
+ : this.keys.map((key) => `"${key.name}" (id ${key.id})`).join(", ");
121
+ return this.afterCreate
122
+ ? `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.`
123
+ : `${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.`;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Convex returned the Management API credential itself as the new key. This
129
+ * happens with an OAuth token: Convex creates no key. A delete of the
130
+ * returned secret would revoke the credential, so the resource does not keep it.
131
+ */
132
+ export class DeployKeyIsCredential extends Schema.TaggedError<DeployKeyIsCredential>()(
133
+ "DeployKeyIsCredential",
134
+ { target: Schema.String, name: Schema.String },
135
+ ) {
136
+ override get message() {
137
+ 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.`;
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Creates a key named `name` and ties it to the caller. Fails before the
143
+ * create when keys with that name already exist. After the create, fails
144
+ * when the secret is the Management API credential itself, or when the
145
+ * listing does not show exactly one key with that name. It never revokes a
146
+ * key: the returned secret can be a shared credential.
147
+ */
148
+ export const createTrackedKey = (options: {
149
+ readonly target: string;
150
+ readonly name: string;
151
+ readonly list: Effect.Effect<ReadonlyArray<typeof ListedKey.Type>, ConvexApiError>;
152
+ readonly create: Effect.Effect<string, ConvexApiError>;
153
+ readonly isCredentialToken: (secret: string) => Effect.Effect<boolean>;
154
+ }) =>
155
+ Effect.gen(function* () {
156
+ const { target, name } = options;
157
+ const existing = keysListedAs(yield* options.list, name);
158
+ if (existing.length > 0) {
159
+ return yield* new DeployKeyRecoveryRequired({
160
+ target,
161
+ name,
162
+ keys: existing,
163
+ afterCreate: false,
164
+ });
165
+ }
166
+ const secret = yield* options.create;
167
+ if (yield* options.isCredentialToken(secret)) {
168
+ return yield* new DeployKeyIsCredential({ target, name });
169
+ }
170
+ const listed = keysListedAs(yield* options.list, name);
171
+ const [only] = listed;
172
+ if (only === undefined || listed.length !== 1) {
173
+ return yield* new DeployKeyRecoveryRequired({
174
+ target,
175
+ name,
176
+ keys: listed,
177
+ afterCreate: true,
178
+ });
179
+ }
180
+ return { keyId: only.id, uniqueName: only.name, secret: Redacted.make(secret) };
181
+ });
182
+
183
+ /**
184
+ * The secret to send to a delete call, or undefined when a delete is not
185
+ * safe. Only a key with a `keyId` in state is known to be a new key that
186
+ * this resource owns: createTrackedKey found exactly one new listed key, and
187
+ * the secret was not the credential. State from 0.1.x has no `keyId`, and its
188
+ * secret can be an OAuth token that Convex returned as a key, so a delete
189
+ * could revoke that OAuth token. Such a key stays, and a warning names it.
190
+ */
191
+ export const secretToDelete = (
192
+ key: {
193
+ readonly keyId: number | undefined;
194
+ readonly secret: Redacted.Redacted<string> | undefined;
195
+ },
196
+ label: string,
197
+ ) =>
198
+ Effect.gen(function* () {
199
+ if (key.keyId === undefined || !Redacted.isRedacted(key.secret)) {
200
+ yield* Effect.logWarning(
201
+ `${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.`,
202
+ );
203
+ return undefined;
204
+ }
205
+ return Redacted.value(key.secret);
206
+ });
207
+
63
208
  export interface DeployKeyProps {
64
209
  /** Deployment name, such as `project.prodDeploymentName`. */
65
210
  readonly deployment: string;
66
- /** Requested key name. Convex appends a short id to make it unique. */
211
+ /**
212
+ * Key name. The provider requests this name, a dash, and a hash of the
213
+ * resource identity, so two resources with one `name` get different keys.
214
+ */
67
215
  readonly name: string;
68
216
  /** Limits the key to these actions. Convex grants every deployment action when omitted. */
69
217
  readonly allowedActions?: readonly DeployKeyAction[];
70
218
  }
71
219
 
72
220
  export interface DeployKeyAttributes {
73
- /** The name Convex lists the key under, such as "my-key (0e71106d)". */
221
+ /** The name Convex lists the key under, such as "ci-3f2a1b0c9d8e". */
74
222
  readonly uniqueName: string;
223
+ /** Numeric id of the key in the listing. Undefined in state written by 0.1.x. */
224
+ readonly keyId: number | undefined;
75
225
  readonly deployment: string;
76
226
  /** The secret, such as `prod:<deployment>|<token>`. Convex returns it only at creation. */
77
227
  readonly deployKey: Redacted.Redacted<string>;
@@ -101,6 +251,20 @@ export const DeployKeyProvider = () =>
101
251
  )
102
252
  .pipe(Effect.map((body) => Schema.decodeUnknownSync(DeployKeyList)(body)));
103
253
 
254
+ /**
255
+ * `id` is the secret: Convex accepts it, and it matches only the key
256
+ * that this resource created. A second delete answers 404.
257
+ */
258
+ const deleteKey = (deployment: string, secret: string) =>
259
+ retryIdempotentWrite(
260
+ api.requestVoid("delete deploy key", (client) =>
261
+ client.POST("/deployments/{deployment_name}/delete_deploy_key", {
262
+ params: { path: { deployment_name: deployment } },
263
+ body: { id: secret },
264
+ }),
265
+ ),
266
+ );
267
+
104
268
  return {
105
269
  stables: ["uniqueName", "deployment"],
106
270
 
@@ -119,52 +283,49 @@ export const DeployKeyProvider = () =>
119
283
  // A key that is not in state has no known secret, so it is never adopted.
120
284
  if (output === undefined) return undefined;
121
285
  const entries = yield* absentAsUndefined(listKeys(output.deployment));
122
- return entries?.some((entry) => entry.name === output.uniqueName) ? output : undefined;
286
+ return entries !== undefined && isKeyListed(entries, output) ? output : undefined;
123
287
  }),
124
288
 
125
- reconcile: Effect.fn(function* ({ news, output }) {
289
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
126
290
  if (output !== undefined) {
127
291
  // The secret cannot be read back. A key that still exists keeps the one in state.
128
- const entries = yield* listKeys(news.deployment);
129
- if (entries.some((entry) => entry.name === output.uniqueName)) return output;
292
+ if (isKeyListed(yield* listKeys(news.deployment), output)) return output;
130
293
  }
131
- const created = yield* api.request("create deploy key", (client) =>
132
- client.POST("/deployments/{deployment_name}/create_deploy_key", {
133
- params: { path: { deployment_name: news.deployment } },
134
- body: {
135
- name: news.name,
136
- ...(news.allowedActions === undefined
137
- ? {}
138
- : { allowedActions: [...news.allowedActions] }),
139
- },
140
- }),
141
- );
142
- const { deployKey } = Schema.decodeUnknownSync(CreatedDeployKey)(created);
143
- const listed = selectCreatedKey(yield* listKeys(news.deployment), news.name);
144
- if (listed === undefined) {
145
- return yield* Effect.die(
146
- new Error(
147
- `Convex created deploy key "${news.name}" on ${news.deployment} but does not list it`,
294
+ const name = requestedKeyName(news.name, fqn, instanceId);
295
+ const { keyId, uniqueName, secret } = yield* createTrackedKey({
296
+ target: `deployment ${news.deployment}`,
297
+ name,
298
+ list: listKeys(news.deployment),
299
+ create: api
300
+ .request("create deploy key", (client) =>
301
+ client.POST("/deployments/{deployment_name}/create_deploy_key", {
302
+ params: { path: { deployment_name: news.deployment } },
303
+ body: {
304
+ name,
305
+ ...(news.allowedActions === undefined
306
+ ? {}
307
+ : { allowedActions: [...news.allowedActions] }),
308
+ },
309
+ }),
310
+ )
311
+ .pipe(
312
+ Effect.map((body) => Schema.decodeUnknownSync(CreatedDeployKey)(body).deployKey),
148
313
  ),
149
- );
150
- }
151
- return {
152
- uniqueName: listed.name,
153
- deployment: news.deployment,
154
- deployKey: Redacted.make(deployKey),
155
- };
314
+ isCredentialToken: api.isCredentialToken,
315
+ });
316
+ return { uniqueName, keyId, deployment: news.deployment, deployKey: secret };
156
317
  }),
157
318
 
158
319
  delete: Effect.fn(function* ({ output }) {
159
- // 404 DeployKeyNotFound: an earlier attempt or someone else deleted it.
160
- yield* absentAsUndefined(
161
- api.requestVoid("delete deploy key", (client) =>
162
- client.POST("/deployments/{deployment_name}/delete_deploy_key", {
163
- params: { path: { deployment_name: output.deployment } },
164
- body: { id: output.uniqueName },
165
- }),
166
- ),
320
+ // Only the secret names this key alone: 0.1.x state can hold the
321
+ // listed name of another resource's key.
322
+ const secret = yield* secretToDelete(
323
+ { keyId: output.keyId, secret: output.deployKey },
324
+ `Convex.DeployKey "${output.uniqueName}" on ${output.deployment}`,
167
325
  );
326
+ if (secret === undefined) return;
327
+ // 404 DeployKeyNotFound: an earlier attempt or someone else deleted it.
328
+ yield* absentAsUndefined(deleteKey(output.deployment, secret));
168
329
  }),
169
330
  };
170
331
  }),