@samebase/alchemy-convex 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +169 -46
- package/lib/index.d.ts +168 -10
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +494 -45
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Code.ts +463 -0
- package/src/Deploy.ts +10 -20
- package/src/Deployment.ts +409 -0
- package/src/EnvironmentVariable.ts +33 -6
- package/src/Providers.ts +9 -1
- package/src/index.ts +20 -1
package/lib/index.js
CHANGED
|
@@ -1,25 +1,42 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import * as Effect from "effect/Effect";
|
|
7
|
-
import * as Layer from "effect/Layer";
|
|
8
|
-
import * as Redacted from "effect/Redacted";
|
|
9
|
-
import * as Schema from "effect/Schema";
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { stripVTControlCharacters } from "node:util";
|
|
10
6
|
import { Resource } from "alchemy";
|
|
11
7
|
import { AdoptPolicy, OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
|
|
12
|
-
import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
13
8
|
import * as Provider from "alchemy/Provider";
|
|
9
|
+
import * as Clock from "effect/Clock";
|
|
10
|
+
import * as Effect from "effect/Effect";
|
|
11
|
+
import * as ChildProcess from "effect/process/ChildProcess";
|
|
12
|
+
import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
|
|
13
|
+
import * as Redacted from "effect/Redacted";
|
|
14
|
+
import * as Schema from "effect/Schema";
|
|
15
|
+
import * as Stream from "effect/Stream";
|
|
14
16
|
import { AlchemyContext } from "alchemy/AlchemyContext";
|
|
15
17
|
import { Stack } from "alchemy/Stack";
|
|
16
18
|
import * as Option from "effect/Option";
|
|
19
|
+
import { havePropsChanged, isResolved } from "alchemy/Diff";
|
|
17
20
|
import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
|
|
21
|
+
import * as Context from "effect/Context";
|
|
22
|
+
import * as Layer from "effect/Layer";
|
|
18
23
|
import * as Schedule from "effect/Schedule";
|
|
19
|
-
import
|
|
24
|
+
import * as Config from "effect/Config";
|
|
20
25
|
import { CommandExecutor, CommandExecutorLive } from "alchemy/Command";
|
|
21
26
|
import { hashDirectory } from "alchemy/Command/Memo";
|
|
22
27
|
import { createHash } from "node:crypto";
|
|
28
|
+
//#region src/Adoption.ts
|
|
29
|
+
/** True when the resource `fqn` may take over an object that is not in state. */
|
|
30
|
+
const shouldAdopt = (fqn) => Effect.gen(function* () {
|
|
31
|
+
const stack = yield* Effect.serviceOption(Stack);
|
|
32
|
+
const own = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : void 0;
|
|
33
|
+
if (own !== void 0) return own;
|
|
34
|
+
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
35
|
+
if (Option.isSome(policy)) return policy.value;
|
|
36
|
+
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
37
|
+
return Option.isSome(context) && context.value.adopt;
|
|
38
|
+
});
|
|
39
|
+
//#endregion
|
|
23
40
|
//#region src/Credentials.ts
|
|
24
41
|
var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
|
|
25
42
|
/** Environment variable read by {@link fromConfig}. */
|
|
@@ -60,18 +77,6 @@ const fromToken = (accessToken) => Layer.succeed(Credentials, Effect.succeed({
|
|
|
60
77
|
source: "explicit"
|
|
61
78
|
}));
|
|
62
79
|
//#endregion
|
|
63
|
-
//#region src/Adoption.ts
|
|
64
|
-
/** True when the resource `fqn` may take over an object that is not in state. */
|
|
65
|
-
const shouldAdopt = (fqn) => Effect.gen(function* () {
|
|
66
|
-
const stack = yield* Effect.serviceOption(Stack);
|
|
67
|
-
const own = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : void 0;
|
|
68
|
-
if (own !== void 0) return own;
|
|
69
|
-
const policy = yield* Effect.serviceOption(AdoptPolicy);
|
|
70
|
-
if (Option.isSome(policy)) return policy.value;
|
|
71
|
-
const context = yield* Effect.serviceOption(AlchemyContext);
|
|
72
|
-
return Option.isSome(context) && context.value.adopt;
|
|
73
|
-
});
|
|
74
|
-
//#endregion
|
|
75
80
|
//#region src/ManagementApi.ts
|
|
76
81
|
/**
|
|
77
82
|
* One error for every control-plane and deployment call. Resources branch on
|
|
@@ -184,6 +189,196 @@ const ManagementApiLive = () => Layer.effect(ManagementApi, Effect.gen(function*
|
|
|
184
189
|
};
|
|
185
190
|
}));
|
|
186
191
|
//#endregion
|
|
192
|
+
//#region src/Deployment.ts
|
|
193
|
+
const Deployment = Resource("Convex.Deployment", { defaultRemovalPolicy: "retain" });
|
|
194
|
+
/** The props name another deployment than the one in state. */
|
|
195
|
+
var DeploymentIdentityChange = class extends Schema.TaggedError()("DeploymentIdentityChange", {
|
|
196
|
+
/** Name of the deployment in state, such as `happy-animal-123`. */
|
|
197
|
+
deployment: Schema.String,
|
|
198
|
+
field: Schema.Literals([
|
|
199
|
+
"projectId",
|
|
200
|
+
"type",
|
|
201
|
+
"name"
|
|
202
|
+
]),
|
|
203
|
+
current: Schema.String,
|
|
204
|
+
requested: Schema.String
|
|
205
|
+
}) {
|
|
206
|
+
get message() {
|
|
207
|
+
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.`;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
/** The project has no default production deployment for a `prod` resource to take over. */
|
|
211
|
+
var ProductionDeploymentNotFound = class extends Schema.TaggedError()("ProductionDeploymentNotFound", { projectId: Schema.Number }) {
|
|
212
|
+
get message() {
|
|
213
|
+
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.`;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
/** More than one deployment of the project has the name, so the lookup cannot choose one. */
|
|
217
|
+
var AmbiguousDeployment = class extends Schema.TaggedError()("AmbiguousDeployment", {
|
|
218
|
+
projectId: Schema.Number,
|
|
219
|
+
type: Schema.Literals(["dev", "preview"]),
|
|
220
|
+
name: Schema.String,
|
|
221
|
+
deployments: Schema.Array(Schema.String)
|
|
222
|
+
}) {
|
|
223
|
+
get message() {
|
|
224
|
+
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.`;
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
/**
|
|
228
|
+
* The URL of a Convex cloud deployment: `https://<name>.convex.cloud`, or
|
|
229
|
+
* `https://<name>.<region>.convex.cloud` outside US East.
|
|
230
|
+
*/
|
|
231
|
+
const CLOUD_URL = /^https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud$/;
|
|
232
|
+
/**
|
|
233
|
+
* The HTTP actions URL of a deployment: the client URL with `.convex.site`
|
|
234
|
+
* for `.convex.cloud`, as Convex sets CONVEX_SITE_URL. Custom domains are
|
|
235
|
+
* not covered.
|
|
236
|
+
*/
|
|
237
|
+
const siteUrlOf = (url) => url.replace(/\.convex\.cloud$/, ".convex.site");
|
|
238
|
+
/** One row of list_deployments, GET /deployments/{name}, and create_deployment. Only the fields the provider uses. */
|
|
239
|
+
const DeploymentRow$1 = Schema.Struct({
|
|
240
|
+
name: Schema.String,
|
|
241
|
+
projectId: Schema.Number,
|
|
242
|
+
deploymentType: Schema.String,
|
|
243
|
+
isDefault: Schema.Boolean,
|
|
244
|
+
reference: Schema.String,
|
|
245
|
+
/** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
|
|
246
|
+
previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
|
|
247
|
+
deploymentUrl: Schema.String.check(Schema.isPattern(CLOUD_URL))
|
|
248
|
+
});
|
|
249
|
+
const DeploymentRows$1 = Schema.Array(DeploymentRow$1);
|
|
250
|
+
const attributesOf = (row, type, previewName) => ({
|
|
251
|
+
projectId: row.projectId,
|
|
252
|
+
type,
|
|
253
|
+
name: row.name,
|
|
254
|
+
reference: row.reference,
|
|
255
|
+
previewName: type === "preview" ? previewName : void 0,
|
|
256
|
+
url: row.deploymentUrl,
|
|
257
|
+
siteUrl: siteUrlOf(row.deploymentUrl)
|
|
258
|
+
});
|
|
259
|
+
/** The `name` prop of the deployment in state: the dev reference or the preview name. */
|
|
260
|
+
const nameOf = (attributes) => attributes.type === "dev" ? attributes.reference : attributes.type === "preview" ? attributes.previewName : void 0;
|
|
261
|
+
/**
|
|
262
|
+
* Fails with {@link DeploymentIdentityChange} when the props name another
|
|
263
|
+
* deployment than `current`: the attributes in state, or a live row.
|
|
264
|
+
*/
|
|
265
|
+
const checkIdentity$2 = (current, news) => {
|
|
266
|
+
const change = (field, from, to) => Effect.fail(new DeploymentIdentityChange({
|
|
267
|
+
deployment: current.name,
|
|
268
|
+
field,
|
|
269
|
+
current: from,
|
|
270
|
+
requested: to
|
|
271
|
+
}));
|
|
272
|
+
if (current.projectId !== news.projectId) return change("projectId", String(current.projectId), String(news.projectId));
|
|
273
|
+
if (current.type !== news.type) return change("type", current.type, news.type);
|
|
274
|
+
const currentName = nameOf(current);
|
|
275
|
+
if (news.type !== "prod" && currentName !== news.name) return change("name", currentName ?? "(none)", news.name);
|
|
276
|
+
return Effect.void;
|
|
277
|
+
};
|
|
278
|
+
const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function* () {
|
|
279
|
+
const api = yield* ManagementApi;
|
|
280
|
+
/** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
|
|
281
|
+
const getByName = (name) => absentAsUndefined(api.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)));
|
|
282
|
+
const listDeployments = (projectId, type, isDefault) => api.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: {
|
|
283
|
+
path: { project_id: projectId },
|
|
284
|
+
query: {
|
|
285
|
+
deploymentType: type,
|
|
286
|
+
...isDefault === void 0 ? {} : { isDefault }
|
|
287
|
+
}
|
|
288
|
+
} })).pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows$1)(body)));
|
|
289
|
+
/** The project's default production deployment, or undefined. */
|
|
290
|
+
const production = (projectId) => listDeployments(projectId, "prod", true).pipe(Effect.map((rows) => {
|
|
291
|
+
const row = rows.find((candidate) => candidate.deploymentType === "prod" && candidate.isDefault);
|
|
292
|
+
return row === void 0 ? void 0 : attributesOf(row, "prod", void 0);
|
|
293
|
+
}));
|
|
294
|
+
/**
|
|
295
|
+
* The one dev deployment with this reference, or the one preview
|
|
296
|
+
* deployment with this preview name, or undefined.
|
|
297
|
+
*/
|
|
298
|
+
const findByName = (projectId, type, name) => Effect.gen(function* () {
|
|
299
|
+
const matches = (yield* listDeployments(projectId, type)).filter((row) => row.deploymentType === type && (type === "dev" ? row.reference === name : row.previewIdentifier === name));
|
|
300
|
+
if (matches.length > 1) return yield* new AmbiguousDeployment({
|
|
301
|
+
projectId,
|
|
302
|
+
type,
|
|
303
|
+
name,
|
|
304
|
+
deployments: matches.map((row) => row.name)
|
|
305
|
+
});
|
|
306
|
+
return matches[0];
|
|
307
|
+
});
|
|
308
|
+
/** The deployment that the props name, for a resource without state. */
|
|
309
|
+
const lookup = (news) => news.type === "prod" ? production(news.projectId) : findByName(news.projectId, news.type, news.name).pipe(Effect.map((row) => row === void 0 ? void 0 : attributesOf(row, news.type, news.name)));
|
|
310
|
+
return {
|
|
311
|
+
stables: [
|
|
312
|
+
"projectId",
|
|
313
|
+
"type",
|
|
314
|
+
"previewName"
|
|
315
|
+
],
|
|
316
|
+
diff: Effect.fn(function* ({ news, output }) {
|
|
317
|
+
if (!isResolved(news) || output === void 0) return void 0;
|
|
318
|
+
yield* checkIdentity$2(output, news);
|
|
319
|
+
if (news.type === "prod") return (yield* absentAsUndefined(production(news.projectId)))?.name === output.name ? void 0 : { action: "update" };
|
|
320
|
+
return (yield* getByName(output.name)) === void 0 ? { action: "update" } : void 0;
|
|
321
|
+
}),
|
|
322
|
+
read: Effect.fn(function* ({ olds, output }) {
|
|
323
|
+
if (output?.type === "prod") return yield* absentAsUndefined(production(output.projectId));
|
|
324
|
+
if (output !== void 0) {
|
|
325
|
+
const live = yield* getByName(output.name);
|
|
326
|
+
return live === void 0 ? void 0 : attributesOf(live, output.type, output.previewName);
|
|
327
|
+
}
|
|
328
|
+
const found = yield* lookup(olds);
|
|
329
|
+
if (found === void 0) return void 0;
|
|
330
|
+
return olds.type === "prod" ? found : Unowned(found);
|
|
331
|
+
}),
|
|
332
|
+
reconcile: Effect.fn(function* ({ fqn, news, output }) {
|
|
333
|
+
if (output !== void 0) yield* checkIdentity$2(output, news);
|
|
334
|
+
if (news.type === "prod") {
|
|
335
|
+
const current = yield* production(news.projectId);
|
|
336
|
+
if (current === void 0) return yield* new ProductionDeploymentNotFound({ projectId: news.projectId });
|
|
337
|
+
return current;
|
|
338
|
+
}
|
|
339
|
+
if (output !== void 0) {
|
|
340
|
+
const live = yield* getByName(output.name);
|
|
341
|
+
if (live !== void 0) {
|
|
342
|
+
if (live.deploymentType !== output.type) return yield* new DeploymentIdentityChange({
|
|
343
|
+
deployment: live.name,
|
|
344
|
+
field: "type",
|
|
345
|
+
current: live.deploymentType,
|
|
346
|
+
requested: news.type
|
|
347
|
+
});
|
|
348
|
+
const current = attributesOf(live, output.type, output.previewName);
|
|
349
|
+
yield* checkIdentity$2(current, news);
|
|
350
|
+
return current;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const existing = yield* lookup(news);
|
|
354
|
+
if (existing !== void 0) {
|
|
355
|
+
if (!(yield* shouldAdopt(fqn))) return yield* new OwnedBySomeoneElse({
|
|
356
|
+
message: `Project ${news.projectId} already has ${news.type} deployment ${existing.name} named "${news.name}". Re-run with --adopt to take it over, or use another name.`,
|
|
357
|
+
resourceType: Deployment.Type,
|
|
358
|
+
physicalName: existing.name
|
|
359
|
+
});
|
|
360
|
+
return existing;
|
|
361
|
+
}
|
|
362
|
+
const created = yield* api.request("create deployment", (client) => client.POST("/projects/{project_id}/create_deployment", {
|
|
363
|
+
params: { path: { project_id: news.projectId } },
|
|
364
|
+
body: {
|
|
365
|
+
type: news.type,
|
|
366
|
+
reference: news.name
|
|
367
|
+
}
|
|
368
|
+
}));
|
|
369
|
+
return attributesOf(Schema.decodeUnknownSync(DeploymentRow$1)(created), news.type, news.name);
|
|
370
|
+
}),
|
|
371
|
+
delete: Effect.fn(function* ({ output }) {
|
|
372
|
+
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.`);
|
|
373
|
+
if (output.type === "prod") return yield* keep;
|
|
374
|
+
const live = yield* getByName(output.name);
|
|
375
|
+
if (live === void 0) return;
|
|
376
|
+
if (live.deploymentType === "prod") return yield* keep;
|
|
377
|
+
yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deployment", (client) => client.POST("/deployments/{deployment_name}/delete", { params: { path: { deployment_name: output.name } } }))));
|
|
378
|
+
})
|
|
379
|
+
};
|
|
380
|
+
}));
|
|
381
|
+
//#endregion
|
|
187
382
|
//#region src/EnvironmentVariable.ts
|
|
188
383
|
/**
|
|
189
384
|
* The variable's identity when the listing has `name`. Own keys only: a
|
|
@@ -194,13 +389,19 @@ const findVariable = (list, deployment, name) => Object.hasOwn(list.environmentV
|
|
|
194
389
|
name
|
|
195
390
|
} : void 0;
|
|
196
391
|
/**
|
|
197
|
-
* One attempt to set
|
|
198
|
-
* deployment answers 200 with an empty body, so this reads no
|
|
392
|
+
* One attempt to set variables, or to remove those whose `value` is null, in
|
|
393
|
+
* one call. The deployment answers 200 with an empty body, so this reads no
|
|
394
|
+
* response data.
|
|
199
395
|
*/
|
|
200
|
-
const
|
|
396
|
+
const writeVariables = (deployment, deployKey, changes) => settleVoid("update environment variables", () => createDeploymentClient(deployment, Redacted.value(deployKey)).POST("/update_environment_variables", { body: { changes: changes.map(({ name, value }) => ({
|
|
201
397
|
name,
|
|
202
398
|
value: Redacted.isRedacted(value) ? Redacted.value(value) : value
|
|
203
|
-
}
|
|
399
|
+
})) } }));
|
|
400
|
+
/** One attempt to set one variable, or to remove it when `value` is null. */
|
|
401
|
+
const writeVariable = (deployment, deployKey, name, value) => writeVariables(deployment, deployKey, [{
|
|
402
|
+
name,
|
|
403
|
+
value
|
|
404
|
+
}]);
|
|
204
405
|
/**
|
|
205
406
|
* Sets one variable, or removes it when `value` is null. The call sets a
|
|
206
407
|
* value, so it runs again after a write conflict or a 5xx answer, for example
|
|
@@ -210,6 +411,8 @@ const writeVariable = (deployment, deployKey, name, value) => settleVoid("update
|
|
|
210
411
|
const updateVariable = (deployment, deployKey, name, value) => retryIdempotentWrite(writeVariable(deployment, deployKey, name, value));
|
|
211
412
|
/** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
|
|
212
413
|
const EnvironmentVariableList = Schema.Struct({ environmentVariables: Schema.Record(Schema.String, Schema.String) });
|
|
414
|
+
/** Every variable of the deployment with its value. Fails with a 404 when the deployment is gone. */
|
|
415
|
+
const listVariables = (deployment, deployKey) => settle("list environment variables", () => createDeploymentClient(deployment, Redacted.value(deployKey)).GET("/list_environment_variables")).pipe(Effect.map((body) => Schema.decodeUnknownSync(EnvironmentVariableList)(body).environmentVariables));
|
|
213
416
|
/** The variable's identity, or undefined when the variable or the deployment is absent. */
|
|
214
417
|
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
418
|
const EnvironmentVariable = Resource("Convex.EnvironmentVariable");
|
|
@@ -297,6 +500,261 @@ const EnvironmentVariableProvider = () => Provider.effect(EnvironmentVariable, E
|
|
|
297
500
|
};
|
|
298
501
|
}));
|
|
299
502
|
//#endregion
|
|
503
|
+
//#region src/Code.ts
|
|
504
|
+
const Code = Resource("Convex.Code");
|
|
505
|
+
/**
|
|
506
|
+
* The CLI prints this line with `logFinishedStep` after the push succeeds, to
|
|
507
|
+
* stderr (convex 1.46 `cli/lib/deploy2.js` for existing deployments,
|
|
508
|
+
* `cli/deploy.js` for preview deployments). The host is
|
|
509
|
+
* `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
|
|
510
|
+
*/
|
|
511
|
+
const DEPLOYED_LINE = /Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
|
|
512
|
+
/** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
|
|
513
|
+
const parseDeployOutput = (text) => {
|
|
514
|
+
const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
|
|
515
|
+
const url = match?.[1];
|
|
516
|
+
const deploymentName = match?.[2];
|
|
517
|
+
return url === void 0 || deploymentName === void 0 ? void 0 : {
|
|
518
|
+
url,
|
|
519
|
+
deploymentName
|
|
520
|
+
};
|
|
521
|
+
};
|
|
522
|
+
/**
|
|
523
|
+
* What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
|
|
524
|
+
* (or `<deployment>|<secret>` in old keys) for one deployment,
|
|
525
|
+
* `preview:<team>:<project>|<secret>` for preview deployments of a project,
|
|
526
|
+
* `project:<team>:<project>|<secret>` for the project's production
|
|
527
|
+
* deployment. Same rules as convex 1.46 `cli/lib/deployment.js`.
|
|
528
|
+
*/
|
|
529
|
+
const keyTargetOf = (key) => {
|
|
530
|
+
const bar = key.indexOf("|");
|
|
531
|
+
if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "unknown" };
|
|
532
|
+
const parts = key.slice(0, bar).split(":");
|
|
533
|
+
const [first, second] = parts;
|
|
534
|
+
if (parts.length === 3 && first === "preview") return { kind: "preview" };
|
|
535
|
+
if (parts.length === 3 && first === "project") return { kind: "project" };
|
|
536
|
+
if (parts.length === 2 && second) return {
|
|
537
|
+
kind: "deployment",
|
|
538
|
+
deployment: second
|
|
539
|
+
};
|
|
540
|
+
if (parts.length === 1 && first) return {
|
|
541
|
+
kind: "deployment",
|
|
542
|
+
deployment: first
|
|
543
|
+
};
|
|
544
|
+
return { kind: "unknown" };
|
|
545
|
+
};
|
|
546
|
+
/** The deploy key does not fit the deployment. Checked before the push. */
|
|
547
|
+
var DeployKeyMismatch = class extends Schema.TaggedError()("DeployKeyMismatch", {
|
|
548
|
+
/** The deployment that should receive the push. */
|
|
549
|
+
deployment: Schema.String,
|
|
550
|
+
/** What the key targets. */
|
|
551
|
+
key: Schema.Literals([
|
|
552
|
+
"deployment",
|
|
553
|
+
"preview",
|
|
554
|
+
"project",
|
|
555
|
+
"unknown"
|
|
556
|
+
]),
|
|
557
|
+
/** For a deployment key: the deployment in the key prefix. Never the secret. */
|
|
558
|
+
keyDeployment: Schema.UndefinedOr(Schema.String)
|
|
559
|
+
}) {
|
|
560
|
+
get message() {
|
|
561
|
+
switch (this.key) {
|
|
562
|
+
case "deployment": return `The deploy key is for deployment ${this.keyDeployment}, not for ${this.deployment}. Pass a deploy key of ${this.deployment}, such as Convex.DeployKey with deployment ${this.deployment}.`;
|
|
563
|
+
case "preview": return `A preview deploy key pushes to a preview deployment by its preview name, and ${this.deployment} is not a Convex.Deployment of type "preview". Pass the Convex.Deployment preview resource as deployment, or a deploy key of ${this.deployment}.`;
|
|
564
|
+
case "project": return `Convex.Code does not take a project deploy key: it cannot check before the push that the key targets ${this.deployment}. Pass a deploy key of ${this.deployment} (Convex.DeployKey).`;
|
|
565
|
+
case "unknown": return `deployKey is not a Convex deploy key ("<type>:<deployment>|<secret>" or "preview:<team>:<project>|<secret>"). Pass Convex.DeployKey(...).deployKey or Convex.PreviewDeployKey(...).previewDeployKey.`;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
/** `env` needs the deployment API, which takes only a deploy key of the deployment. */
|
|
570
|
+
var EnvironmentNeedsDeployKey = class extends Schema.TaggedError()("EnvironmentNeedsDeployKey", { deployment: Schema.String }) {
|
|
571
|
+
get message() {
|
|
572
|
+
return `Convex.Code cannot set or remove env variables on ${this.deployment} with a preview deploy key: the deployment API takes only a deploy key of the deployment. Pass a Convex.DeployKey of ${this.deployment}, or use Convex.DefaultEnvironmentVariable with deploymentType "preview" for values that every new preview deployment gets.`;
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
/** No `convex` package resolves from `cwd`. */
|
|
576
|
+
var ConvexCliNotFound = class extends Schema.TaggedError()("ConvexCliNotFound", { cwd: Schema.String }) {
|
|
577
|
+
get message() {
|
|
578
|
+
return `Convex.Code found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), set cwd to the app directory, or set the command prop.`;
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
/** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
|
|
582
|
+
var CodePushFailed = class extends Schema.TaggedError()("CodePushFailed", {
|
|
583
|
+
/** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
|
|
584
|
+
exitCode: Schema.UndefinedOr(Schema.Number),
|
|
585
|
+
/** The last lines of the CLI output, with the deploy key removed. */
|
|
586
|
+
outputTail: Schema.String
|
|
587
|
+
}) {
|
|
588
|
+
get message() {
|
|
589
|
+
return this.exitCode === 0 ? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line, so Convex.Code has no deployment URL. Last output, with the deploy key removed:\n${this.outputTail}` : `convex deploy failed${this.exitCode === void 0 ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
/** The CLI pushed to another deployment than the one in the props. Checked after the push. */
|
|
593
|
+
var PushTargetMismatch = class extends Schema.TaggedError()("PushTargetMismatch", {
|
|
594
|
+
expected: Schema.String,
|
|
595
|
+
actual: Schema.String
|
|
596
|
+
}) {
|
|
597
|
+
get message() {
|
|
598
|
+
return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Code expected ${this.expected}. With a preview deploy key, this happens when the key belongs to another project, or when the preview deployment ${this.expected} expired during the deploy and the CLI created ${this.actual} for the same preview name. Check the key. After an expiry, run the deploy again with --adopt, so that Convex.Deployment takes over ${this.actual}.`;
|
|
599
|
+
}
|
|
600
|
+
};
|
|
601
|
+
/**
|
|
602
|
+
* Checks that the key fits the deployment, and returns the preview name for
|
|
603
|
+
* `--preview-name` when the key is a preview deploy key.
|
|
604
|
+
*/
|
|
605
|
+
const previewNameFor = (target, keyTarget, changesEnvironment) => {
|
|
606
|
+
const mismatch = new DeployKeyMismatch({
|
|
607
|
+
deployment: target.name,
|
|
608
|
+
key: keyTarget.kind,
|
|
609
|
+
keyDeployment: keyTarget.kind === "deployment" ? keyTarget.deployment : void 0
|
|
610
|
+
});
|
|
611
|
+
switch (keyTarget.kind) {
|
|
612
|
+
case "deployment": return keyTarget.deployment === target.name ? Effect.succeed(void 0) : Effect.fail(mismatch);
|
|
613
|
+
case "preview":
|
|
614
|
+
if (target.type !== "preview" || target.previewName === void 0) return Effect.fail(mismatch);
|
|
615
|
+
return changesEnvironment ? Effect.fail(new EnvironmentNeedsDeployKey({ deployment: target.name })) : Effect.succeed(target.previewName);
|
|
616
|
+
case "project":
|
|
617
|
+
case "unknown": return Effect.fail(mismatch);
|
|
618
|
+
default: return keyTarget;
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
/** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
|
|
622
|
+
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");
|
|
623
|
+
const CliManifest = Schema.Struct({ bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]) });
|
|
624
|
+
/** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
|
|
625
|
+
const installedCli = (cwd) => Effect.tryPromise({
|
|
626
|
+
try: async () => {
|
|
627
|
+
const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
|
|
628
|
+
const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(await readFile(manifestPath, "utf8"));
|
|
629
|
+
const entry = typeof bin === "string" ? bin : bin["convex"];
|
|
630
|
+
if (entry === void 0) throw new Error("the convex package has no convex bin");
|
|
631
|
+
return [process.execPath, resolve(dirname(manifestPath), entry)];
|
|
632
|
+
},
|
|
633
|
+
catch: () => new ConvexCliNotFound({ cwd })
|
|
634
|
+
});
|
|
635
|
+
/**
|
|
636
|
+
* Runs `use` with an env file that holds the key, in a new temporary
|
|
637
|
+
* directory, and removes the directory afterwards: on success, on failure,
|
|
638
|
+
* and on interruption.
|
|
639
|
+
*/
|
|
640
|
+
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({
|
|
641
|
+
envFile: join(home, "deploy.env"),
|
|
642
|
+
home
|
|
643
|
+
}))), (home) => Effect.promise(() => rm(home, {
|
|
644
|
+
recursive: true,
|
|
645
|
+
force: true
|
|
646
|
+
})));
|
|
647
|
+
/**
|
|
648
|
+
* The environment of the CLI: this process's variables without `CONVEX_*`,
|
|
649
|
+
* with `home` as the home directory.
|
|
650
|
+
*/
|
|
651
|
+
const cliEnvironment = (home) => ({
|
|
652
|
+
...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_"))),
|
|
653
|
+
HOME: home,
|
|
654
|
+
USERPROFILE: home,
|
|
655
|
+
XDG_CONFIG_HOME: home
|
|
656
|
+
});
|
|
657
|
+
const CodeProvider = () => Provider.effect(Code, Effect.gen(function* () {
|
|
658
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
659
|
+
/** Runs the CLI with piped output. Interruption stops its process group. */
|
|
660
|
+
const run = (argv, cwd, home) => Effect.gen(function* () {
|
|
661
|
+
const [command, ...args] = argv;
|
|
662
|
+
const child = yield* spawner.spawn(ChildProcess.make(command, args, {
|
|
663
|
+
cwd,
|
|
664
|
+
env: cliEnvironment(home),
|
|
665
|
+
extendEnv: false,
|
|
666
|
+
stdin: "ignore",
|
|
667
|
+
stdout: "pipe",
|
|
668
|
+
stderr: "pipe"
|
|
669
|
+
}));
|
|
670
|
+
const [exitCode, stdout, stderr] = yield* Effect.all([
|
|
671
|
+
child.exitCode,
|
|
672
|
+
Stream.mkString(Stream.decodeText(child.stdout)),
|
|
673
|
+
Stream.mkString(Stream.decodeText(child.stderr))
|
|
674
|
+
], { concurrency: "unbounded" });
|
|
675
|
+
return {
|
|
676
|
+
exitCode: Number(exitCode),
|
|
677
|
+
output: `${stdout}\n${stderr}`
|
|
678
|
+
};
|
|
679
|
+
}).pipe(Effect.scoped);
|
|
680
|
+
return {
|
|
681
|
+
diff: () => Effect.succeed({ action: "update" }),
|
|
682
|
+
reconcile: Effect.fn(function* ({ fqn, news, output }) {
|
|
683
|
+
const target = typeof news.deployment === "string" ? {
|
|
684
|
+
name: news.deployment,
|
|
685
|
+
address: news.deployment,
|
|
686
|
+
type: void 0,
|
|
687
|
+
previewName: void 0
|
|
688
|
+
} : {
|
|
689
|
+
...news.deployment,
|
|
690
|
+
address: news.deployment.url
|
|
691
|
+
};
|
|
692
|
+
const key = Redacted.value(news.deployKey);
|
|
693
|
+
const secrets = [key, key.slice(key.indexOf("|") + 1)].filter((secret) => secret.length >= 8);
|
|
694
|
+
const env = Object.entries(news.env ?? {}).sort(([a], [b]) => a < b ? -1 : 1);
|
|
695
|
+
const previous = output !== void 0 && output.deploymentName === target.name ? output.envNames : [];
|
|
696
|
+
const removed = previous.filter((name) => !env.some(([envName]) => envName === name));
|
|
697
|
+
const previewName = yield* previewNameFor(target, keyTargetOf(key), env.length > 0 || removed.length > 0);
|
|
698
|
+
const cwd = resolve(news.cwd);
|
|
699
|
+
const program = news.command ?? (yield* installedCli(cwd));
|
|
700
|
+
if (env.length > 0 || removed.length > 0) {
|
|
701
|
+
const adopt = yield* shouldAdopt(fqn);
|
|
702
|
+
const fresh = env.filter(([name]) => !previous.includes(name));
|
|
703
|
+
yield* retryIdempotentWrite(Effect.gen(function* () {
|
|
704
|
+
if (fresh.length > 0 && !adopt) {
|
|
705
|
+
const current = yield* listVariables(target.address, news.deployKey);
|
|
706
|
+
const taken = fresh.filter(([name, value]) => Object.hasOwn(current, name) && current[name] !== (Redacted.isRedacted(value) ? Redacted.value(value) : value)).map(([name]) => name);
|
|
707
|
+
if (taken.length > 0) return yield* new OwnedBySomeoneElse({
|
|
708
|
+
message: `Environment variables ${taken.join(", ")} already exist on deployment ${target.name} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`,
|
|
709
|
+
resourceType: Code.Type,
|
|
710
|
+
physicalName: target.name
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
yield* writeVariables(target.address, news.deployKey, [...env.map(([name, value]) => ({
|
|
714
|
+
name,
|
|
715
|
+
value
|
|
716
|
+
})), ...removed.map((name) => ({
|
|
717
|
+
name,
|
|
718
|
+
value: null
|
|
719
|
+
}))]);
|
|
720
|
+
}));
|
|
721
|
+
}
|
|
722
|
+
const args = [
|
|
723
|
+
"deploy",
|
|
724
|
+
...previewName === void 0 ? [] : ["--preview-name", previewName],
|
|
725
|
+
...news.typecheck === void 0 ? [] : [`--typecheck=${news.typecheck}`],
|
|
726
|
+
...news.codegen === void 0 ? [] : [`--codegen=${news.codegen}`]
|
|
727
|
+
];
|
|
728
|
+
const result = yield* withKeyFile(key, ({ envFile, home }) => run([
|
|
729
|
+
...program,
|
|
730
|
+
...args,
|
|
731
|
+
"--env-file",
|
|
732
|
+
envFile
|
|
733
|
+
], cwd, home)).pipe(Effect.mapError((error) => new CodePushFailed({
|
|
734
|
+
exitCode: void 0,
|
|
735
|
+
outputTail: outputTail(error.message, secrets)
|
|
736
|
+
})));
|
|
737
|
+
const deployed = parseDeployOutput(result.output);
|
|
738
|
+
if (result.exitCode !== 0 || deployed === void 0) return yield* new CodePushFailed({
|
|
739
|
+
exitCode: result.exitCode,
|
|
740
|
+
outputTail: outputTail(result.output, secrets)
|
|
741
|
+
});
|
|
742
|
+
if (deployed.deploymentName !== target.name) return yield* new PushTargetMismatch({
|
|
743
|
+
expected: target.name,
|
|
744
|
+
actual: deployed.deploymentName
|
|
745
|
+
});
|
|
746
|
+
return {
|
|
747
|
+
deploymentName: deployed.deploymentName,
|
|
748
|
+
url: deployed.url,
|
|
749
|
+
siteUrl: siteUrlOf(deployed.url),
|
|
750
|
+
deployedAt: yield* Clock.currentTimeMillis,
|
|
751
|
+
envNames: env.map(([name]) => name)
|
|
752
|
+
};
|
|
753
|
+
}),
|
|
754
|
+
delete: () => Effect.void
|
|
755
|
+
};
|
|
756
|
+
}));
|
|
757
|
+
//#endregion
|
|
300
758
|
//#region src/DefaultEnvironmentVariable.ts
|
|
301
759
|
/**
|
|
302
760
|
* Attributes when the listing has `name` for `deploymentType`. An unfiltered
|
|
@@ -404,24 +862,13 @@ const DefaultEnvironmentVariableProvider = () => Provider.effect(DefaultEnvironm
|
|
|
404
862
|
}));
|
|
405
863
|
//#endregion
|
|
406
864
|
//#region src/Deploy.ts
|
|
407
|
-
const Deploy = Resource("Convex.Deploy");
|
|
408
865
|
/**
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
866
|
+
* @deprecated Since 0.3.0. Use Convex.Code with a Convex.Deployment: it
|
|
867
|
+
* runs the installed Convex CLI, keeps the deploy key out of the process
|
|
868
|
+
* environment, checks that the key fits the deployment, and outputs the site
|
|
869
|
+
* URL. Removing a Deploy resource changes nothing in Convex.
|
|
413
870
|
*/
|
|
414
|
-
const
|
|
415
|
-
/** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
|
|
416
|
-
const parseDeployOutput = (text) => {
|
|
417
|
-
const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
|
|
418
|
-
const url = match?.[1];
|
|
419
|
-
const deploymentName = match?.[2];
|
|
420
|
-
return url === void 0 || deploymentName === void 0 ? void 0 : {
|
|
421
|
-
url,
|
|
422
|
-
deploymentName
|
|
423
|
-
};
|
|
424
|
-
};
|
|
871
|
+
const Deploy = Resource("Convex.Deploy");
|
|
425
872
|
/** The `convex deploy` argv, starting with `npx`. */
|
|
426
873
|
const deployArgs = (props) => [
|
|
427
874
|
"npx",
|
|
@@ -951,13 +1398,15 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
|
|
|
951
1398
|
*/
|
|
952
1399
|
const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([
|
|
953
1400
|
Project,
|
|
1401
|
+
Deployment,
|
|
954
1402
|
DeployKey,
|
|
955
1403
|
PreviewDeployKey,
|
|
956
1404
|
EnvironmentVariable,
|
|
957
1405
|
DefaultEnvironmentVariable,
|
|
1406
|
+
Code,
|
|
958
1407
|
Deploy
|
|
959
|
-
])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeployKeyProvider(), PreviewDeployKeyProvider(), EnvironmentVariableProvider(), DefaultEnvironmentVariableProvider(), DeployProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.provide(CommandExecutorLive()), Layer.orDie);
|
|
1408
|
+
])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider(), DeployKeyProvider(), PreviewDeployKeyProvider(), EnvironmentVariableProvider(), DefaultEnvironmentVariableProvider(), CodeProvider(), DeployProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.provide(CommandExecutorLive()), Layer.orDie);
|
|
960
1409
|
//#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 };
|
|
1410
|
+
export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, Code, CodePushFailed, ConvexApiError, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DefaultEnvironmentVariable, Deploy, DeployArgumentError, DeployKey, DeployKeyIsCredential, DeployKeyMismatch, DeployKeyRecoveryRequired, DeployOutputError, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, EnvironmentVariable, ManagementApi, PreviewDeployKey, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectTeamChange, Providers, PushTargetMismatch, VariableIdentityChange, deployArgs, fromConfig, fromToken, parseDeployOutput, providers };
|
|
962
1411
|
|
|
963
1412
|
//# sourceMappingURL=index.js.map
|