@samebase/alchemy-convex 0.27.3 → 0.29.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 +57 -51
- package/lib/{HubClient-Bs5_j18U.js → HubClient-HeGoVOLO.js} +92 -69
- package/lib/HubClient-HeGoVOLO.js.map +1 -0
- package/lib/cli.js +4 -4
- package/lib/cli.js.map +1 -1
- package/lib/index.d.ts +29 -25
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +1 -1
- package/package.json +1 -1
- package/src/Deployment.ts +17 -7
- package/src/ManagementApi.ts +84 -26
- package/src/Project.ts +39 -53
- package/src/State.ts +13 -19
- package/src/cli.ts +11 -11
- package/lib/HubClient-Bs5_j18U.js.map +0 -1
package/src/Project.ts
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
// Convex.Project: a project in a Convex team, with its default production deployment.
|
|
2
2
|
//
|
|
3
|
+
// The team is the team of the credential (ManagementApi.teamId): the team that
|
|
4
|
+
// a team access token or an OAuth token names, or with the Convex CLI login,
|
|
5
|
+
// the team of the deployment that the CLI selected.
|
|
6
|
+
//
|
|
3
7
|
// Deleting a project deletes all of its deployments and data, so this
|
|
4
8
|
// resource never replaces a project and keeps it by default:
|
|
5
9
|
//
|
|
6
10
|
// - A name change is an update in place: PATCH /projects/{project_id} with
|
|
7
11
|
// the new name. The slug stays the same.
|
|
8
|
-
// -
|
|
12
|
+
// - The team, `deploymentType`, and `deploymentRegion` cannot change on an
|
|
9
13
|
// existing project. A change fails with a typed error that names the fix.
|
|
10
14
|
// - The removal policy is `retain`. Alchemy deletes the project only when the
|
|
11
15
|
// resource has `RemovalPolicy.destroy()`.
|
|
@@ -13,7 +17,7 @@
|
|
|
13
17
|
// a resource without state looks a project up by name, and it takes over a
|
|
14
18
|
// project that it finds only when adoption is on.
|
|
15
19
|
// - With `slug` instead of `name`, the resource is an existing project,
|
|
16
|
-
// found by GET /teams/{
|
|
20
|
+
// found by GET /teams/{team_id}/projects/{slug}. It never creates, renames, or
|
|
17
21
|
// (by default) deletes that project. A stack that only pushes to an
|
|
18
22
|
// existing project can pass `{ projectId }` to Convex.Deployment instead
|
|
19
23
|
// and needs no Management API token for the project.
|
|
@@ -36,22 +40,22 @@ import type { Providers } from "./Providers.ts";
|
|
|
36
40
|
/** Region names the Management API accepts, from the generated schema. */
|
|
37
41
|
export type DeploymentRegion = components["schemas"]["RegionName"];
|
|
38
42
|
|
|
43
|
+
// Each form names the key of the other as `never`. Alchemy gives a provider `Props | undefined`
|
|
44
|
+
// when the forms share no key, and the check of a form is `slug !== undefined`.
|
|
39
45
|
export type ProjectProps =
|
|
40
46
|
| {
|
|
41
|
-
/** Team slug or numeric team id. A slug costs one extra lookup per run. Cannot change. */
|
|
42
|
-
readonly team: string | number;
|
|
43
47
|
/** Display name. Convex derives the slug from it at creation. A change renames the project. */
|
|
44
48
|
readonly name: string;
|
|
49
|
+
readonly slug?: never;
|
|
45
50
|
/** Region of the deployment created with the project. Defaults to the team default. Cannot change. */
|
|
46
51
|
readonly deploymentRegion?: DeploymentRegion;
|
|
47
52
|
/** Deployment created with the project. Defaults to `prod`. Cannot change. */
|
|
48
53
|
readonly deploymentType?: "prod" | "dev";
|
|
49
54
|
}
|
|
50
55
|
| {
|
|
51
|
-
/** Team slug or numeric team id. Cannot change. */
|
|
52
|
-
readonly team: string | number;
|
|
53
56
|
/** Slug of an existing project, such as `score-four`. The resource never creates a project. Cannot change. */
|
|
54
57
|
readonly slug: string;
|
|
58
|
+
readonly name?: never;
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
export interface ProjectAttributes {
|
|
@@ -66,13 +70,13 @@ export interface ProjectAttributes {
|
|
|
66
70
|
export type Project = Resource<"Convex.Project", ProjectProps, ProjectAttributes, never, Providers>;
|
|
67
71
|
export const Project = Resource<Project>("Convex.Project", { defaultRemovalPolicy: "retain" });
|
|
68
72
|
|
|
69
|
-
/** The
|
|
73
|
+
/** The credential is of another team than the project. Convex cannot move a project. */
|
|
70
74
|
export class ProjectTeamChange extends Schema.TaggedError<ProjectTeamChange>()(
|
|
71
75
|
"ProjectTeamChange",
|
|
72
76
|
{ projectId: Schema.Number, teamId: Schema.Number, requestedTeamId: Schema.Number },
|
|
73
77
|
) {
|
|
74
78
|
override get message() {
|
|
75
|
-
return `Convex.Project cannot move project ${this.projectId} from team ${this.teamId} to team ${this.requestedTeamId}.
|
|
79
|
+
return `Convex.Project cannot move project ${this.projectId} from team ${this.teamId} to team ${this.requestedTeamId}, the team of the credential. Use a token of team ${this.teamId}, or with the Convex CLI login, select a deployment of team ${this.teamId}. To use the other team, create a new resource with a new logical id and move the data yourself.`;
|
|
76
80
|
}
|
|
77
81
|
}
|
|
78
82
|
|
|
@@ -118,11 +122,11 @@ export class CreatedProjectNotFound extends Schema.TaggedError<CreatedProjectNot
|
|
|
118
122
|
|
|
119
123
|
/** No project in the team has the slug. A `slug` resource never creates one. */
|
|
120
124
|
export class ProjectNotFound extends Schema.TaggedError<ProjectNotFound>()("ProjectNotFound", {
|
|
121
|
-
|
|
125
|
+
teamId: Schema.Number,
|
|
122
126
|
slug: Schema.String,
|
|
123
127
|
}) {
|
|
124
128
|
override get message() {
|
|
125
|
-
return `Team ${this.
|
|
129
|
+
return `Team ${this.teamId} has no project with the slug "${this.slug}", and a Convex.Project with a slug never creates one. Check the slug in the project URL of the Convex dashboard, or use name to create a project.`;
|
|
126
130
|
}
|
|
127
131
|
}
|
|
128
132
|
|
|
@@ -203,12 +207,6 @@ const checkCreateTimeProps = (
|
|
|
203
207
|
return Effect.void;
|
|
204
208
|
};
|
|
205
209
|
|
|
206
|
-
/** True when `team`, a slug or a numeric id, names the team of the project. */
|
|
207
|
-
const isTeamOf = (details: ProjectDetails, team: string | number) =>
|
|
208
|
-
typeof team === "number" || /^\d+$/.test(team)
|
|
209
|
-
? details.teamId === Number(team)
|
|
210
|
-
: details.teamSlug === team;
|
|
211
|
-
|
|
212
210
|
/** The row of the project's default production deployment, or undefined. */
|
|
213
211
|
export const prodDeploymentOf = (api: ManagementApiService, projectId: number) =>
|
|
214
212
|
api
|
|
@@ -338,12 +336,12 @@ export const ProjectProvider = () =>
|
|
|
338
336
|
|
|
339
337
|
const getById = (projectId: number) => getProjectById(api, projectId);
|
|
340
338
|
|
|
341
|
-
/** The project with this slug in the team, or undefined.
|
|
342
|
-
const getBySlug = (
|
|
339
|
+
/** The project with this slug in the team, or undefined. */
|
|
340
|
+
const getBySlug = (teamId: number, slug: string) =>
|
|
343
341
|
absentAsUndefined(
|
|
344
342
|
api.request("get project by slug", (client) =>
|
|
345
343
|
client.GET("/teams/{team_id_or_slug}/projects/{project_slug}", {
|
|
346
|
-
params: { path: { team_id_or_slug: String(
|
|
344
|
+
params: { path: { team_id_or_slug: String(teamId), project_slug: slug } },
|
|
347
345
|
}),
|
|
348
346
|
),
|
|
349
347
|
).pipe(
|
|
@@ -384,7 +382,16 @@ export const ProjectProvider = () =>
|
|
|
384
382
|
if (!isResolved(news)) return undefined;
|
|
385
383
|
// Nothing exists yet, so nothing can be lost.
|
|
386
384
|
if (output === undefined) return undefined;
|
|
387
|
-
|
|
385
|
+
// A credential of another team cannot reach the project: fail in plan.
|
|
386
|
+
const teamId = yield* api.teamId;
|
|
387
|
+
if (teamId !== output.teamId) {
|
|
388
|
+
return yield* new ProjectTeamChange({
|
|
389
|
+
projectId: output.projectId,
|
|
390
|
+
teamId: output.teamId,
|
|
391
|
+
requestedTeamId: teamId,
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
if (news.slug !== undefined) {
|
|
388
395
|
if (news.slug !== output.slug) {
|
|
389
396
|
return yield* new ProjectSlugChange({
|
|
390
397
|
projectId: output.projectId,
|
|
@@ -392,16 +399,6 @@ export const ProjectProvider = () =>
|
|
|
392
399
|
requestedSlug: news.slug,
|
|
393
400
|
});
|
|
394
401
|
}
|
|
395
|
-
if (olds.team !== news.team) {
|
|
396
|
-
const details = yield* getById(output.projectId);
|
|
397
|
-
if (details !== undefined && !isTeamOf(details, news.team)) {
|
|
398
|
-
return yield* new ProjectTeamChange({
|
|
399
|
-
projectId: output.projectId,
|
|
400
|
-
teamId: output.teamId,
|
|
401
|
-
requestedTeamId: yield* api.resolveTeamId(news.team),
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
402
|
// The resource changes nothing on the project.
|
|
406
403
|
return { action: "noop" } as const;
|
|
407
404
|
}
|
|
@@ -409,21 +406,9 @@ export const ProjectProvider = () =>
|
|
|
409
406
|
// before any API call. Reconcile compares with the props of the last
|
|
410
407
|
// successful deploy. Here, `olds` can be the props of a refused
|
|
411
408
|
// update, so a check here would also refuse the props that fix it.
|
|
412
|
-
if (olds.team !== news.team) {
|
|
413
|
-
const requestedTeamId = yield* api.resolveTeamId(news.team);
|
|
414
|
-
if (requestedTeamId !== output.teamId) {
|
|
415
|
-
return yield* new ProjectTeamChange({
|
|
416
|
-
projectId: output.projectId,
|
|
417
|
-
teamId: output.teamId,
|
|
418
|
-
requestedTeamId,
|
|
419
|
-
});
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
409
|
// Never "replace". A name change is an update; reconcile renames
|
|
423
410
|
// the project with PATCH. Otherwise the engine compares the props.
|
|
424
|
-
return
|
|
425
|
-
? undefined
|
|
426
|
-
: ({ action: "update" } as const);
|
|
411
|
+
return olds.name === news.name ? undefined : ({ action: "update" } as const);
|
|
427
412
|
}),
|
|
428
413
|
|
|
429
414
|
read: Effect.fn(function* ({ olds, output }) {
|
|
@@ -433,22 +418,24 @@ export const ProjectProvider = () =>
|
|
|
433
418
|
}
|
|
434
419
|
// No state: an existing project belongs to someone else until the
|
|
435
420
|
// user passes --adopt. Convex has no ownership tags.
|
|
421
|
+
const teamId = yield* api.teamId;
|
|
436
422
|
const existing =
|
|
437
|
-
|
|
438
|
-
? yield* getBySlug(
|
|
439
|
-
: yield* findByName(
|
|
423
|
+
olds.slug !== undefined
|
|
424
|
+
? yield* getBySlug(teamId, olds.slug)
|
|
425
|
+
: yield* findByName(teamId, olds.name);
|
|
440
426
|
return existing === undefined ? undefined : Unowned(yield* attributesOf(existing));
|
|
441
427
|
}),
|
|
442
428
|
|
|
443
429
|
reconcile: Effect.fn(function* ({ fqn, olds, news, output }) {
|
|
444
|
-
|
|
430
|
+
const teamId = yield* api.teamId;
|
|
431
|
+
if (news.slug !== undefined) {
|
|
445
432
|
// An existing project: found, checked, never created or changed.
|
|
446
433
|
const details =
|
|
447
434
|
output === undefined
|
|
448
|
-
? yield* getBySlug(
|
|
435
|
+
? yield* getBySlug(teamId, news.slug)
|
|
449
436
|
: yield* getById(output.projectId);
|
|
450
437
|
if (details === undefined) {
|
|
451
|
-
return yield* new ProjectNotFound({
|
|
438
|
+
return yield* new ProjectNotFound({ teamId, slug: news.slug });
|
|
452
439
|
}
|
|
453
440
|
if (details.slug !== news.slug) {
|
|
454
441
|
return yield* new ProjectSlugChange({
|
|
@@ -457,11 +444,11 @@ export const ProjectProvider = () =>
|
|
|
457
444
|
requestedSlug: news.slug,
|
|
458
445
|
});
|
|
459
446
|
}
|
|
460
|
-
if (
|
|
447
|
+
if (details.teamId !== teamId) {
|
|
461
448
|
return yield* new ProjectTeamChange({
|
|
462
449
|
projectId: details.id,
|
|
463
450
|
teamId: details.teamId,
|
|
464
|
-
requestedTeamId:
|
|
451
|
+
requestedTeamId: teamId,
|
|
465
452
|
});
|
|
466
453
|
}
|
|
467
454
|
if (output === undefined && !(yield* shouldAdopt(fqn))) {
|
|
@@ -473,10 +460,9 @@ export const ProjectProvider = () =>
|
|
|
473
460
|
}
|
|
474
461
|
return yield* attributesOf(details);
|
|
475
462
|
}
|
|
476
|
-
const teamId = yield* api.resolveTeamId(news.team);
|
|
477
463
|
if (output !== undefined) {
|
|
478
464
|
// State names the project. Never look it up by name here.
|
|
479
|
-
if (olds !== undefined &&
|
|
465
|
+
if (olds !== undefined && olds.name !== undefined) {
|
|
480
466
|
yield* checkCreateTimeProps(output.projectId, olds, news);
|
|
481
467
|
}
|
|
482
468
|
if (output.teamId !== teamId) {
|
package/src/State.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Alchemy state in Convex: `state: Convex.state(
|
|
1
|
+
// Alchemy state in Convex: `state: Convex.state()` in `Alchemy.Stack`.
|
|
2
2
|
//
|
|
3
3
|
// One project for each Convex team, `alchemy-state-store`, holds the state of
|
|
4
4
|
// every stack and stage of the team in its production deployment: the
|
|
@@ -268,8 +268,7 @@ export const storeDirectory = () =>
|
|
|
268
268
|
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
|
|
269
269
|
|
|
270
270
|
export interface StoreOptions {
|
|
271
|
-
/**
|
|
272
|
-
readonly team: string | number | undefined;
|
|
271
|
+
/** The store is in the team of its credential: ManagementApi.teamId. */
|
|
273
272
|
readonly api: ManagementApiService;
|
|
274
273
|
readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
|
|
275
274
|
/** The key of the run is deleted when this scope closes. */
|
|
@@ -351,10 +350,10 @@ const ensureSealed = (
|
|
|
351
350
|
* the run whose project is not the oldest deletes its own, which holds
|
|
352
351
|
* nothing yet, and uses the oldest, so that every run ends on one store.
|
|
353
352
|
*/
|
|
354
|
-
const createStoreProject = (api: ManagementApiService, teamId: number
|
|
353
|
+
const createStoreProject = (api: ManagementApiService, teamId: number) =>
|
|
355
354
|
Effect.gen(function* () {
|
|
356
355
|
yield* Effect.logInfo(
|
|
357
|
-
`Convex state: creating the project ${STORE_PROJECT_NAME} in team ${
|
|
356
|
+
`Convex state: creating the project ${STORE_PROJECT_NAME} in team ${teamId}`,
|
|
358
357
|
);
|
|
359
358
|
const created = yield* createProject(api, teamId, {
|
|
360
359
|
name: STORE_PROJECT_NAME,
|
|
@@ -387,10 +386,9 @@ const createStoreProject = (api: ManagementApiService, teamId: number, team: str
|
|
|
387
386
|
export const makeConvexState = (options: StoreOptions) =>
|
|
388
387
|
Effect.gen(function* () {
|
|
389
388
|
const { api } = options;
|
|
390
|
-
const teamId = yield* api.
|
|
391
|
-
const team = options.team ?? teamId;
|
|
389
|
+
const teamId = yield* api.teamId;
|
|
392
390
|
const found = yield* findProjectByName(api, teamId, STORE_PROJECT_NAME);
|
|
393
|
-
const project = found ?? (yield* createStoreProject(api, teamId
|
|
391
|
+
const project = found ?? (yield* createStoreProject(api, teamId));
|
|
394
392
|
const deployment = yield* prodDeploymentOf(api, project.id);
|
|
395
393
|
if (deployment === undefined) {
|
|
396
394
|
return yield* new ProductionDeploymentNotFound({ projectId: project.id });
|
|
@@ -435,7 +433,7 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
435
433
|
// Code without `state:version` is another app with the name of the store.
|
|
436
434
|
if (version === undefined && deployment.lastDeployTime) {
|
|
437
435
|
return yield* failure(
|
|
438
|
-
`project ${STORE_PROJECT_NAME} (${project.id}) of team ${
|
|
436
|
+
`project ${STORE_PROJECT_NAME} (${project.id}) of team ${teamId} has code that is not the state store, and Convex.state does not push over it. Rename that project in the Convex dashboard.`,
|
|
439
437
|
);
|
|
440
438
|
}
|
|
441
439
|
// Another run can push a newer store in the meantime: read again right
|
|
@@ -494,16 +492,13 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
494
492
|
});
|
|
495
493
|
|
|
496
494
|
/**
|
|
497
|
-
* Alchemy state in the `alchemy-state-store` project of
|
|
498
|
-
* in CONVEX_ACCESS_TOKEN or the Convex CLI login
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
*
|
|
502
|
-
* stack never touches Convex.
|
|
495
|
+
* Alchemy state in the `alchemy-state-store` project of the team of the credential: the team token
|
|
496
|
+
* in CONVEX_ACCESS_TOKEN, which names its team, or the Convex CLI login, with the team of the
|
|
497
|
+
* deployment that the CLI selected (CONVEX_DEPLOYMENT, see ManagementApi.teamId). The first use of
|
|
498
|
+
* the run finds or creates the project and pushes the store when it is missing or older.
|
|
499
|
+
* `alchemy plan` on another stack never touches Convex.
|
|
503
500
|
*/
|
|
504
|
-
export const state = (
|
|
505
|
-
options: { readonly team?: string | number } = {},
|
|
506
|
-
): Layer.Layer<State, never, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
501
|
+
export const state = (): Layer.Layer<State, never, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
507
502
|
Layer.effect(
|
|
508
503
|
State,
|
|
509
504
|
Effect.gen(function* () {
|
|
@@ -529,7 +524,6 @@ export const state = (
|
|
|
529
524
|
),
|
|
530
525
|
);
|
|
531
526
|
return yield* makeConvexState({
|
|
532
|
-
team: options.team,
|
|
533
527
|
api,
|
|
534
528
|
spawner,
|
|
535
529
|
scope,
|
package/src/cli.ts
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
// The commands of the package:
|
|
3
3
|
//
|
|
4
4
|
// alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
|
|
5
|
-
// alchemy-convex stack-comment
|
|
5
|
+
// alchemy-convex stack-comment
|
|
6
6
|
//
|
|
7
7
|
// hub-client runs in the directory of a Convex app at each dev start, and renews the client of the
|
|
8
8
|
// identity hub of the team that the development deployment of that directory has, or gives it a new
|
|
9
9
|
// one: see registerDevelopmentClient. stack-comment runs as a step of a pull request workflow after
|
|
10
10
|
// the deploys, and keeps one comment with the stacks of the repository: see src/StackComment.ts. It
|
|
11
|
-
// needs GITHUB_TOKEN and
|
|
12
|
-
// Convex CLI login.
|
|
11
|
+
// needs GITHUB_TOKEN, and reads the store of the team of the token. Both use CONVEX_ACCESS_TOKEN or
|
|
12
|
+
// the Convex CLI login.
|
|
13
13
|
import { execFileSync } from "node:child_process";
|
|
14
14
|
import { parseArgs } from "node:util";
|
|
15
15
|
import * as NodeServices from "@effect/platform-node/NodeServices";
|
|
@@ -34,7 +34,7 @@ const { positionals, values } = parseArgs({
|
|
|
34
34
|
const { team, app, "callback-path": callbackPath, "env-prefix": envPrefix } = values;
|
|
35
35
|
const USAGE = `Usage:
|
|
36
36
|
alchemy-convex hub-client --team <team> --app <app> --callback-path <path> [--env-prefix HUB]
|
|
37
|
-
alchemy-convex stack-comment
|
|
37
|
+
alchemy-convex stack-comment`;
|
|
38
38
|
|
|
39
39
|
const hubClient = (hub: { team: string; app: string; callbackPath: string; envPrefix?: string }) =>
|
|
40
40
|
Effect.gen(function* () {
|
|
@@ -55,16 +55,16 @@ const hubClient = (hub: { team: string; app: string; callbackPath: string; envPr
|
|
|
55
55
|
);
|
|
56
56
|
}).pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
|
|
57
57
|
|
|
58
|
-
const stackComment = (
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
);
|
|
58
|
+
const stackComment = updateStackComment.pipe(
|
|
59
|
+
Effect.tap((url) => Effect.sync(() => console.log(url))),
|
|
60
|
+
Effect.provide(state().pipe(Layer.provide(NodeServices.layer))),
|
|
61
|
+
);
|
|
63
62
|
|
|
64
63
|
const subcommand = positionals.join(" ");
|
|
65
64
|
const program: Effect.Effect<unknown, unknown> | undefined =
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
// stack-comment reads the store of the team of the credential, so a --team would be ignored.
|
|
66
|
+
subcommand === "stack-comment" && team === undefined
|
|
67
|
+
? stackComment
|
|
68
68
|
: subcommand === "hub-client" &&
|
|
69
69
|
team !== undefined &&
|
|
70
70
|
app !== undefined &&
|