@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/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
- // - `team`, `deploymentType`, and `deploymentRegion` cannot change on an
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/{team}/projects/{slug}. It never creates, renames, or
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 props change the team of an existing project. Convex cannot move a project. */
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}. Set team back to ${this.teamId}. To use the other team, create a new resource with a new logical id and move the data yourself.`;
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
- team: Schema.String,
125
+ teamId: Schema.Number,
122
126
  slug: Schema.String,
123
127
  }) {
124
128
  override get message() {
125
- return `Team ${this.team} 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.`;
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. Official endpoint; the team can be a slug. */
342
- const getBySlug = (team: string | number, slug: string) =>
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(team), project_slug: slug } },
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
- if ("slug" in news) {
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 "name" in olds && olds.name === news.name
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
- "slug" in olds
438
- ? yield* getBySlug(olds.team, olds.slug)
439
- : yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
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
- if ("slug" in news) {
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(news.team, news.slug)
435
+ ? yield* getBySlug(teamId, news.slug)
449
436
  : yield* getById(output.projectId);
450
437
  if (details === undefined) {
451
- return yield* new ProjectNotFound({ team: String(news.team), slug: news.slug });
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 (!isTeamOf(details, news.team)) {
447
+ if (details.teamId !== teamId) {
461
448
  return yield* new ProjectTeamChange({
462
449
  projectId: details.id,
463
450
  teamId: details.teamId,
464
- requestedTeamId: yield* api.resolveTeamId(news.team),
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 && "name" in olds) {
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({ team })` in `Alchemy.Stack`.
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
- /** Team slug or numeric team id of the `alchemy-state-store` project, or undefined with a team access token, which names its team. */
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, team: string | 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 ${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.resolveTeamId(options.team);
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, team));
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 ${team} has code that is not the state store, and Convex.state does not push over it. Rename that project in the Convex dashboard.`,
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 `team`, with the team token
498
- * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
499
- * its team, so `team` is optional with one; the Convex CLI login needs the
500
- * slug or the id. The first use of the run finds or creates the project and
501
- * pushes the store when it is missing or older. `alchemy plan` on another
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 [--team <team>]
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, with a team access token, no --team. Both use CONVEX_ACCESS_TOKEN or the
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 [--team <team>]`;
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 = (options: { team?: string }) =>
59
- updateStackComment.pipe(
60
- Effect.tap((url) => Effect.sync(() => console.log(url))),
61
- Effect.provide(state(options).pipe(Layer.provide(NodeServices.layer))),
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
- subcommand === "stack-comment"
67
- ? stackComment(team === undefined ? {} : { team })
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 &&