@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
@@ -5,32 +5,54 @@
5
5
  // deploy key, not with the Management API token. Setting a variable and
6
6
  // removing it are the same call: update_environment_variables with
7
7
  // `value: null` removes the variable.
8
+ //
9
+ // A variable that exists but is not in state belongs to someone else: its
10
+ // old value is lost when this resource writes it. Alchemy takes it over only
11
+ // with --adopt.
12
+ //
13
+ // The variable's name and deployment cannot change. A change fails with
14
+ // VariableIdentityChange: use a new resource with a new logical id. A
15
+ // replacement could let the engine delete an old generation that names the
16
+ // live variable, and a rename in place needs a cleanup that can fail and
17
+ // leave a variable or a revoked key behind.
18
+ //
19
+ // The attributes keep the deploy key of the variable's deployment. After a
20
+ // refused change, the props can name another deployment and its key, so
21
+ // read and delete use the attributes, not the props.
8
22
  import { createDeploymentClient } from "@convex-dev/platform";
9
23
  import { Resource } from "alchemy";
10
24
  import { isResolved } from "alchemy/Diff";
25
+ import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
11
26
  import * as Provider from "alchemy/Provider";
12
27
  import * as Effect from "effect/Effect";
13
28
  import * as Redacted from "effect/Redacted";
14
29
  import * as Schema from "effect/Schema";
15
- import { absentAsUndefined, ManagementApi, settleVoid } from "./ManagementApi.ts";
30
+ import { shouldAdopt } from "./Adoption.ts";
31
+ import {
32
+ absentAsUndefined,
33
+ ManagementApi,
34
+ type ManagementApiService,
35
+ retryIdempotentWrite,
36
+ settleVoid,
37
+ } from "./ManagementApi.ts";
16
38
  import type { Providers } from "./Providers.ts";
17
39
 
18
40
  /**
19
- * Attributes when the listing has `name`. Own keys only: a valid variable
20
- * name such as `toString` must not match the object prototype.
41
+ * The variable's identity when the listing has `name`. Own keys only: a
42
+ * valid variable name such as `toString` must not match the object prototype.
21
43
  */
22
44
  export const findVariable = (
23
45
  list: typeof EnvironmentVariableList.Type,
24
46
  deployment: string,
25
47
  name: string,
26
- ): EnvironmentVariableAttributes | undefined =>
48
+ ): { readonly deployment: string; readonly name: string } | undefined =>
27
49
  Object.hasOwn(list.environmentVariables, name) ? { deployment, name } : undefined;
28
50
 
29
51
  /**
30
- * Sets one variable, or removes it when `value` is null. The deployment
31
- * answers 200 with an empty body, so this reads no response data.
52
+ * One attempt to set one variable, or to remove it when `value` is null. The
53
+ * deployment answers 200 with an empty body, so this reads no response data.
32
54
  */
33
- export const updateVariable = (
55
+ const writeVariable = (
34
56
  deployment: string,
35
57
  deployKey: Redacted.Redacted<string>,
36
58
  name: string,
@@ -47,11 +69,44 @@ export const updateVariable = (
47
69
  ),
48
70
  );
49
71
 
72
+ /**
73
+ * Sets one variable, or removes it when `value` is null. The call sets a
74
+ * value, so it runs again after a write conflict or a 5xx answer, for example
75
+ * when another resource writes a variable of the same deployment at the same
76
+ * time. Use it only where the variable is already ours to set.
77
+ */
78
+ export const updateVariable = (
79
+ deployment: string,
80
+ deployKey: Redacted.Redacted<string>,
81
+ name: string,
82
+ value: string | Redacted.Redacted<string> | null,
83
+ ) => retryIdempotentWrite(writeVariable(deployment, deployKey, name, value));
84
+
50
85
  /** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
51
86
  export const EnvironmentVariableList = Schema.Struct({
52
87
  environmentVariables: Schema.Record(Schema.String, Schema.String),
53
88
  });
54
89
 
90
+ /** The variable's identity, or undefined when the variable or the deployment is absent. */
91
+ const readVariable = (
92
+ api: ManagementApiService,
93
+ deployment: string,
94
+ deployKey: Redacted.Redacted<string>,
95
+ name: string,
96
+ ) =>
97
+ // 404: the deployment no longer exists, so neither does the variable.
98
+ absentAsUndefined(
99
+ api.deploymentRequest("list environment variables", deployment, deployKey, (client) =>
100
+ client.GET("/list_environment_variables"),
101
+ ),
102
+ ).pipe(
103
+ Effect.map((body) =>
104
+ body === undefined
105
+ ? undefined
106
+ : findVariable(Schema.decodeUnknownSync(EnvironmentVariableList)(body), deployment, name),
107
+ ),
108
+ );
109
+
55
110
  export interface EnvironmentVariableProps {
56
111
  /** Deployment name, or its URL for deployments outside US East. */
57
112
  readonly deployment: string;
@@ -64,6 +119,8 @@ export interface EnvironmentVariableProps {
64
119
  export interface EnvironmentVariableAttributes {
65
120
  readonly deployment: string;
66
121
  readonly name: string;
122
+ /** Deploy key for `deployment`, for read and delete. Undefined in state from 0.1.x. */
123
+ readonly deployKey: Redacted.Redacted<string> | undefined;
67
124
  }
68
125
 
69
126
  export type EnvironmentVariable = Resource<
@@ -75,6 +132,56 @@ export type EnvironmentVariable = Resource<
75
132
  >;
76
133
  export const EnvironmentVariable = Resource<EnvironmentVariable>("Convex.EnvironmentVariable");
77
134
 
135
+ /**
136
+ * The props name another variable than the one in state. Used by
137
+ * Convex.EnvironmentVariable and Convex.DefaultEnvironmentVariable.
138
+ */
139
+ export class VariableIdentityChange extends Schema.TaggedError<VariableIdentityChange>()(
140
+ "VariableIdentityChange",
141
+ {
142
+ resourceType: Schema.String,
143
+ field: Schema.Literals(["name", "deployment", "projectId", "deploymentType"]),
144
+ current: Schema.String,
145
+ requested: Schema.String,
146
+ },
147
+ ) {
148
+ override get message() {
149
+ 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.`;
150
+ }
151
+ }
152
+
153
+ /** Fails with {@link VariableIdentityChange} when the props name another variable than the one in state. */
154
+ const checkIdentity = (output: EnvironmentVariableAttributes, news: EnvironmentVariableProps) =>
155
+ output.name !== news.name
156
+ ? Effect.fail(
157
+ new VariableIdentityChange({
158
+ resourceType: EnvironmentVariable.Type,
159
+ field: "name",
160
+ current: output.name,
161
+ requested: news.name,
162
+ }),
163
+ )
164
+ : output.deployment !== news.deployment
165
+ ? Effect.fail(
166
+ new VariableIdentityChange({
167
+ resourceType: EnvironmentVariable.Type,
168
+ field: "deployment",
169
+ current: output.deployment,
170
+ requested: news.deployment,
171
+ }),
172
+ )
173
+ : Effect.void;
174
+
175
+ /**
176
+ * The deploy key for the variable in state: from the attributes, or, for
177
+ * state from 0.1.x, from props that name the same deployment.
178
+ */
179
+ const keyOf = (
180
+ output: EnvironmentVariableAttributes,
181
+ olds: EnvironmentVariableProps | undefined,
182
+ ): Redacted.Redacted<string> | undefined =>
183
+ output.deployKey ?? (olds?.deployment === output.deployment ? olds.deployKey : undefined);
184
+
78
185
  export const EnvironmentVariableProvider = () =>
79
186
  Provider.effect(
80
187
  EnvironmentVariable,
@@ -82,47 +189,73 @@ export const EnvironmentVariableProvider = () =>
82
189
  const api = yield* ManagementApi;
83
190
 
84
191
  return {
85
- stables: ["deployment", "name"],
86
-
87
- diff: ({ olds, news }) => {
88
- if (!isResolved(news)) return Effect.succeed(undefined);
89
- // Otherwise undefined: the engine updates when any prop changed and
90
- // compares Redacted values, such as the value and the deploy key, by content.
91
- return Effect.succeed(
92
- olds.deployment !== news.deployment || olds.name !== news.name
93
- ? ({ action: "replace" } as const)
94
- : undefined,
95
- );
96
- },
192
+ // Compares with the attributes: after a refused change, `olds` are
193
+ // the refused props. Otherwise undefined: the engine updates when any
194
+ // prop changed and compares Redacted values, such as the value and
195
+ // the deploy key, by content.
196
+ diff: Effect.fn(function* ({ news, output }) {
197
+ if (!isResolved(news) || output === undefined) return undefined;
198
+ yield* checkIdentity(output, news);
199
+ return undefined;
200
+ }),
97
201
 
98
- read: Effect.fn(function* ({ olds }) {
99
- // 404: the deployment no longer exists, so neither does the variable.
100
- const body = yield* absentAsUndefined(
101
- api.deploymentRequest(
102
- "list environment variables",
103
- olds.deployment,
104
- olds.deployKey,
105
- (client) => client.GET("/list_environment_variables"),
106
- ),
107
- );
108
- if (body === undefined) return undefined;
109
- return findVariable(
110
- Schema.decodeUnknownSync(EnvironmentVariableList)(body),
111
- olds.deployment,
112
- olds.name,
113
- );
202
+ read: Effect.fn(function* ({ olds, output }) {
203
+ if (output !== undefined) {
204
+ const key = keyOf(output, olds);
205
+ // Without a key for the deployment in state, keep the state as it is.
206
+ if (key === undefined) return output;
207
+ const found = yield* readVariable(api, output.deployment, key, output.name);
208
+ return found === undefined ? undefined : output;
209
+ }
210
+ const found = yield* readVariable(api, olds.deployment, olds.deployKey, olds.name);
211
+ // No state: the variable belongs to someone else until the user passes --adopt.
212
+ return found === undefined ? undefined : Unowned({ ...found, deployKey: olds.deployKey });
114
213
  }),
115
214
 
116
- reconcile: Effect.fn(function* ({ news }) {
117
- yield* updateVariable(news.deployment, news.deployKey, news.name, news.value);
118
- return { deployment: news.deployment, name: news.name };
215
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
216
+ // Also here: with an unresolved prop in plan, diff could not check.
217
+ if (output !== undefined) yield* checkIdentity(output, news);
218
+ const write = writeVariable(news.deployment, news.deployKey, news.name, news.value);
219
+ if (output !== undefined || (yield* shouldAdopt(fqn))) {
220
+ // In state, or adopted: the variable is ours to set.
221
+ yield* retryIdempotentWrite(write);
222
+ } else {
223
+ // Not in state: overwriting a variable that exists loses its
224
+ // value, so that needs adoption. Each attempt checks first, so a
225
+ // retry after a write conflict never overwrites a variable that
226
+ // another writer created in the meantime.
227
+ yield* retryIdempotentWrite(
228
+ Effect.gen(function* () {
229
+ const existing = yield* readVariable(
230
+ api,
231
+ news.deployment,
232
+ news.deployKey,
233
+ news.name,
234
+ );
235
+ if (existing !== undefined) {
236
+ return yield* new OwnedBySomeoneElse({
237
+ 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.`,
238
+ resourceType: EnvironmentVariable.Type,
239
+ physicalName: news.name,
240
+ });
241
+ }
242
+ yield* write;
243
+ }),
244
+ );
245
+ }
246
+ return { deployment: news.deployment, name: news.name, deployKey: news.deployKey };
119
247
  }),
120
248
 
121
249
  delete: Effect.fn(function* ({ olds, output }) {
250
+ const key = keyOf(output, olds);
251
+ if (key === undefined) {
252
+ yield* Effect.logWarning(
253
+ `Environment variable ${output.name} on deployment ${output.deployment} stays: Alchemy has no deploy key for that deployment. Remove it in the Convex dashboard.`,
254
+ );
255
+ return;
256
+ }
122
257
  // Removing an absent variable succeeds; 404 means the deployment is gone.
123
- yield* absentAsUndefined(
124
- updateVariable(output.deployment, olds.deployKey, output.name, null),
125
- );
258
+ yield* absentAsUndefined(updateVariable(output.deployment, key, output.name, null));
126
259
  }),
127
260
  };
128
261
  }),
@@ -9,6 +9,7 @@ import * as Context from "effect/Context";
9
9
  import * as Effect from "effect/Effect";
10
10
  import * as Layer from "effect/Layer";
11
11
  import * as Redacted from "effect/Redacted";
12
+ import * as Schedule from "effect/Schedule";
12
13
  import * as Schema from "effect/Schema";
13
14
  import { Credentials } from "./Credentials.ts";
14
15
 
@@ -70,8 +71,19 @@ export interface ManagementApiService {
70
71
  ) => Effect.Effect<T, ConvexApiError>;
71
72
  /** Team id for a slug or numeric id. */
72
73
  readonly resolveTeamId: (team: string | number) => Effect.Effect<number, ConvexApiError>;
74
+ /**
75
+ * True when a deploy key that Convex returned carries the token of the
76
+ * Management API credential itself. With an OAuth token, create_deploy_key
77
+ * and create_preview_deploy_key create no new key: they return the OAuth
78
+ * token with a new prefix, and the part after "|" is the same. A delete of
79
+ * that key would revoke the credential.
80
+ */
81
+ readonly isCredentialToken: (secret: string) => Effect.Effect<boolean>;
73
82
  }
74
83
 
84
+ /** The part of a Convex token after the first "|", or the whole value when it has none. */
85
+ const tokenPart = (value: string) => value.slice(value.indexOf("|") + 1);
86
+
75
87
  export class ManagementApi extends Context.Service<ManagementApi, ManagementApiService>()(
76
88
  "@samebase/alchemy-convex/ManagementApi",
77
89
  ) {}
@@ -111,6 +123,43 @@ export const settleVoid = (
111
123
  ),
112
124
  );
113
125
 
126
+ /**
127
+ * True when an idempotent write can succeed on a new attempt:
128
+ *
129
+ * - A Convex write conflict. Two parallel update_environment_variables calls
130
+ * on one deployment gave HTTP 503 `OptimisticConcurrencyControlFailure`,
131
+ * "Data read or written in this mutation changed while it was being run",
132
+ * in a live run. The platform codes `OCC` and `WriteConflict` and the
133
+ * message "... changed while this mutation was being run ..." mean the same.
134
+ * - Any other 5xx answer. Two parallel delete_preview_deploy_key calls on one
135
+ * project gave HTTP 500 `InternalServerError`, "Your request couldn't be
136
+ * completed. Try again later.", in a live run. The next attempt succeeded.
137
+ */
138
+ export const isRetryableWriteError = (error: ConvexApiError): boolean =>
139
+ error.code === "OptimisticConcurrencyControlFailure" ||
140
+ error.code === "OCC" ||
141
+ error.code === "WriteConflict" ||
142
+ /changed while (it|this mutation) was being run/.test(error.message) ||
143
+ error.status >= 500;
144
+
145
+ /**
146
+ * Runs an idempotent write again after a retryable Convex error: at most
147
+ * four more attempts, with exponential backoff from 100 ms and jitter. Other
148
+ * errors fail at once, and so does the last retryable error. Use it only for
149
+ * a write that has the same result when it runs twice, such as setting a
150
+ * value or deleting by id. Never for a create that does not check first.
151
+ */
152
+ export const retryIdempotentWrite = <A, E, R>(
153
+ effect: Effect.Effect<A, E, R>,
154
+ ): Effect.Effect<A, E, R> =>
155
+ effect.pipe(
156
+ Effect.retry({
157
+ while: (error: E) => error instanceof ConvexApiError && isRetryableWriteError(error),
158
+ times: 4,
159
+ schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
160
+ }),
161
+ );
162
+
114
163
  /** Turns a 404 into `undefined` so read and delete stay idempotent. */
115
164
  export const absentAsUndefined = <A, R>(
116
165
  effect: Effect.Effect<A, ConvexApiError, R>,
@@ -188,6 +237,13 @@ export const ManagementApiLive = (): Layer.Layer<ManagementApi, never, Credentia
188
237
  return match.id;
189
238
  });
190
239
 
191
- return { request, requestVoid, deploymentRequest, resolveTeamId };
240
+ const isCredentialToken: ManagementApiService["isCredentialToken"] = (secret) =>
241
+ credentials.pipe(
242
+ Effect.map(
243
+ (resolved) => tokenPart(Redacted.value(resolved.accessToken)) === tokenPart(secret),
244
+ ),
245
+ );
246
+
247
+ return { request, requestVoid, deploymentRequest, resolveTeamId, isCredentialToken };
192
248
  }),
193
249
  );
@@ -2,17 +2,25 @@
2
2
  // deployments, such as the key CI passes to `npx convex deploy` for a branch.
3
3
  //
4
4
  // Convex returns the secret only once, from create_preview_deploy_key. The
5
- // listing shows each key under a unique name, the requested name plus a short
6
- // id suffix, and delete_preview_deploy_key accepts that unique name. A key
7
- // cannot be changed after creation, so every property change is a replacement.
5
+ // identity rules are the same as for Convex.DeployKey: a requested name that
6
+ // is unique for each resource instance, exactly one listed key after the
7
+ // create with its numeric id in state, delete by the secret only, and never
8
+ // keep a key that is the OAuth credential itself. A key cannot change after
9
+ // creation, so every property change is a replacement.
8
10
  import { Resource } from "alchemy";
9
11
  import { havePropsChanged, isResolved } from "alchemy/Diff";
10
12
  import * as Provider from "alchemy/Provider";
11
13
  import * as Effect from "effect/Effect";
12
14
  import * as Redacted from "effect/Redacted";
13
15
  import * as Schema from "effect/Schema";
14
- import { ListedKey, selectCreatedKey } from "./DeployKey.ts";
15
- import { absentAsUndefined, ManagementApi } from "./ManagementApi.ts";
16
+ import {
17
+ createTrackedKey,
18
+ isKeyListed,
19
+ ListedKey,
20
+ requestedKeyName,
21
+ secretToDelete,
22
+ } from "./DeployKey.ts";
23
+ import { absentAsUndefined, ManagementApi, retryIdempotentWrite } from "./ManagementApi.ts";
16
24
  import type { Providers } from "./Providers.ts";
17
25
 
18
26
  /** GET /projects/{project_id}/list_preview_deploy_keys */
@@ -24,13 +32,18 @@ export const CreatedPreviewDeployKey = Schema.Struct({ previewDeployKey: Schema.
24
32
  export interface PreviewDeployKeyProps {
25
33
  /** Numeric project id, such as `project.projectId`. */
26
34
  readonly projectId: number;
27
- /** Requested key name. Convex appends a short id to make it unique. */
35
+ /**
36
+ * Key name. The provider requests this name, a dash, and a hash of the
37
+ * resource identity, so two resources with one `name` get different keys.
38
+ */
28
39
  readonly name: string;
29
40
  }
30
41
 
31
42
  export interface PreviewDeployKeyAttributes {
32
- /** The name Convex lists the key under, such as "ci (6308e666)". */
43
+ /** The name Convex lists the key under, such as "ci-3f2a1b0c9d8e". */
33
44
  readonly uniqueName: string;
45
+ /** Numeric id of the key in the listing. Undefined in state written by 0.1.x. */
46
+ readonly keyId: number | undefined;
34
47
  readonly projectId: number;
35
48
  /** The secret, such as `preview:<team>:<project>|<token>`. Convex returns it only at creation. */
36
49
  readonly previewDeployKey: Redacted.Redacted<string>;
@@ -60,6 +73,20 @@ export const PreviewDeployKeyProvider = () =>
60
73
  )
61
74
  .pipe(Effect.map((body) => Schema.decodeUnknownSync(PreviewDeployKeyList)(body).items));
62
75
 
76
+ /**
77
+ * `id` is the secret: Convex accepts it, and it matches only the key
78
+ * that this resource created. A second delete answers 404.
79
+ */
80
+ const deleteKey = (projectId: number, secret: string) =>
81
+ retryIdempotentWrite(
82
+ api.requestVoid("delete preview deploy key", (client) =>
83
+ client.POST("/projects/{project_id}/delete_preview_deploy_key", {
84
+ params: { path: { project_id: projectId } },
85
+ body: { id: secret },
86
+ }),
87
+ ),
88
+ );
89
+
63
90
  return {
64
91
  stables: ["uniqueName", "projectId"],
65
92
 
@@ -79,47 +106,47 @@ export const PreviewDeployKeyProvider = () =>
79
106
  // A key that is not in state has no known secret, so it is never adopted.
80
107
  if (output === undefined) return undefined;
81
108
  const entries = yield* absentAsUndefined(listKeys(output.projectId));
82
- return entries?.some((entry) => entry.name === output.uniqueName) ? output : undefined;
109
+ return entries !== undefined && isKeyListed(entries, output) ? output : undefined;
83
110
  }),
84
111
 
85
- reconcile: Effect.fn(function* ({ news, output }) {
112
+ reconcile: Effect.fn(function* ({ fqn, instanceId, news, output }) {
86
113
  if (output !== undefined) {
87
114
  // The secret cannot be read back. A key that still exists keeps the one in state.
88
- const entries = yield* listKeys(news.projectId);
89
- if (entries.some((entry) => entry.name === output.uniqueName)) return output;
115
+ if (isKeyListed(yield* listKeys(news.projectId), output)) return output;
90
116
  }
91
- const created = yield* api.request("create preview deploy key", (client) =>
92
- client.POST("/projects/{project_id}/create_preview_deploy_key", {
93
- params: { path: { project_id: news.projectId } },
94
- body: { name: news.name },
95
- }),
96
- );
97
- const { previewDeployKey } = Schema.decodeUnknownSync(CreatedPreviewDeployKey)(created);
98
- const listed = selectCreatedKey(yield* listKeys(news.projectId), news.name);
99
- if (listed === undefined) {
100
- return yield* Effect.die(
101
- new Error(
102
- `Convex created preview deploy key "${news.name}" in project ${news.projectId} but does not list it`,
117
+ const name = requestedKeyName(news.name, fqn, instanceId);
118
+ const { keyId, uniqueName, secret } = yield* createTrackedKey({
119
+ target: `project ${news.projectId}`,
120
+ name,
121
+ list: listKeys(news.projectId),
122
+ create: api
123
+ .request("create preview deploy key", (client) =>
124
+ client.POST("/projects/{project_id}/create_preview_deploy_key", {
125
+ params: { path: { project_id: news.projectId } },
126
+ body: { name },
127
+ }),
128
+ )
129
+ .pipe(
130
+ Effect.map(
131
+ (body) =>
132
+ Schema.decodeUnknownSync(CreatedPreviewDeployKey)(body).previewDeployKey,
133
+ ),
103
134
  ),
104
- );
105
- }
106
- return {
107
- uniqueName: listed.name,
108
- projectId: news.projectId,
109
- previewDeployKey: Redacted.make(previewDeployKey),
110
- };
135
+ isCredentialToken: api.isCredentialToken,
136
+ });
137
+ return { uniqueName, keyId, projectId: news.projectId, previewDeployKey: secret };
111
138
  }),
112
139
 
113
140
  delete: Effect.fn(function* ({ output }) {
114
- // 404 PreviewDeployKeyNotFound: an earlier attempt or someone else deleted it.
115
- yield* absentAsUndefined(
116
- api.requestVoid("delete preview deploy key", (client) =>
117
- client.POST("/projects/{project_id}/delete_preview_deploy_key", {
118
- params: { path: { project_id: output.projectId } },
119
- body: { id: output.uniqueName },
120
- }),
121
- ),
141
+ // Only the secret names this key alone: 0.1.x state can hold the
142
+ // listed name of another resource's key.
143
+ const secret = yield* secretToDelete(
144
+ { keyId: output.keyId, secret: output.previewDeployKey },
145
+ `Convex.PreviewDeployKey "${output.uniqueName}" in project ${output.projectId}`,
122
146
  );
147
+ if (secret === undefined) return;
148
+ // 404 PreviewDeployKeyNotFound: an earlier attempt or someone else deleted it.
149
+ yield* absentAsUndefined(deleteKey(output.projectId, secret));
123
150
  }),
124
151
  };
125
152
  }),