@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.
- package/README.md +85 -17
- package/lib/index.d.ts +352 -9
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +962 -10
- package/lib/index.js.map +1 -1
- package/package.json +7 -4
- package/src/Adoption.ts +30 -0
- package/src/DefaultEnvironmentVariable.ts +127 -29
- package/src/Deploy.ts +37 -15
- package/src/DeployKey.ts +221 -60
- package/src/EnvironmentVariable.ts +174 -41
- package/src/ManagementApi.ts +57 -1
- package/src/PreviewDeployKey.ts +65 -38
- package/src/Project.ts +236 -52
- package/src/index.ts +9 -0
- package/lib/Credentials.d.ts +0 -39
- package/lib/Credentials.d.ts.map +0 -1
- package/lib/Credentials.js +0 -49
- package/lib/Credentials.js.map +0 -1
- package/lib/DefaultEnvironmentVariable.d.ts +0 -58
- package/lib/DefaultEnvironmentVariable.d.ts.map +0 -1
- package/lib/DefaultEnvironmentVariable.js +0 -93
- package/lib/DefaultEnvironmentVariable.js.map +0 -1
- package/lib/Deploy.d.ts +0 -62
- package/lib/Deploy.d.ts.map +0 -1
- package/lib/Deploy.js +0 -92
- package/lib/Deploy.js.map +0 -1
- package/lib/DeployKey.d.ts +0 -48
- package/lib/DeployKey.d.ts.map +0 -1
- package/lib/DeployKey.js +0 -104
- package/lib/DeployKey.js.map +0 -1
- package/lib/EnvironmentVariable.d.ts +0 -37
- package/lib/EnvironmentVariable.d.ts.map +0 -1
- package/lib/EnvironmentVariable.js +0 -65
- package/lib/EnvironmentVariable.js.map +0 -1
- package/lib/ManagementApi.d.ts +0 -50
- package/lib/ManagementApi.d.ts.map +0 -1
- package/lib/ManagementApi.js +0 -103
- package/lib/ManagementApi.js.map +0 -1
- package/lib/PreviewDeployKey.d.ts +0 -34
- package/lib/PreviewDeployKey.d.ts.map +0 -1
- package/lib/PreviewDeployKey.js +0 -78
- package/lib/PreviewDeployKey.js.map +0 -1
- package/lib/Project.d.ts +0 -29
- package/lib/Project.d.ts.map +0 -1
- package/lib/Project.js +0 -114
- package/lib/Project.js.map +0 -1
- package/lib/Providers.d.ts +0 -13
- package/lib/Providers.d.ts.map +0 -1
- package/lib/Providers.js +0 -30
- 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 {
|
|
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
|
-
*
|
|
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
|
-
):
|
|
48
|
+
): { readonly deployment: string; readonly name: string } | undefined =>
|
|
27
49
|
Object.hasOwn(list.environmentVariables, name) ? { deployment, name } : undefined;
|
|
28
50
|
|
|
29
51
|
/**
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
}),
|
package/src/ManagementApi.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
);
|
package/src/PreviewDeployKey.ts
CHANGED
|
@@ -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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
15
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
}),
|