@samebase/alchemy-convex 0.4.1 → 0.6.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 CHANGED
@@ -90,6 +90,32 @@ reads them through the stack's config provider (process env, `.env`, stack secre
90
90
  A key that does not fit the deployment is not used: the provider uses the access token. To pass
91
91
  values explicitly: `Convex.providers(Convex.fromValues({ accessToken, deployKey }))`.
92
92
 
93
+ ## State in Convex
94
+
95
+ `state: Convex.state({ team })` keeps the Alchemy state of every stack and stage of a Convex team in
96
+ one project of that team, `alchemy-state`, so a repository holds one secret and a stack can create
97
+ and destroy its own project. [docs/design/convex-state.md](docs/design/convex-state.md) records the
98
+ decisions and the measurements.
99
+
100
+ ```ts
101
+ export default Alchemy.Stack(
102
+ process.env["GITHUB_REPOSITORY"] ?? "my-org/my-app", // the stack name: the repository
103
+ { providers: Convex.providers(), state: Convex.state({ team: "my-team" }) },
104
+ Effect.gen(function* () {
105
+ /* ... */
106
+ }),
107
+ );
108
+ ```
109
+
110
+ Every run needs the team token in `CONVEX_ACCESS_TOKEN`, or the Convex CLI login on a laptop. The
111
+ first run of a team creates the project `alchemy-state` and pushes the store functions to its
112
+ production deployment with the Convex CLI of the app; a later version of this package pushes a
113
+ newer store when it finds an older one, and never an older one over a newer one. Each run makes a
114
+ deploy key of that deployment that can only run the store's internal functions, and deletes it at
115
+ the end. Rows are in the tables `resources` and `outputs` in the dashboard, under the stack name.
116
+ The store has no lock: run one deploy of a stage at a time, such as with a CI concurrency group.
117
+ The names of the store functions are a wire contract: never renamed or removed.
118
+
93
119
  ## Resources
94
120
 
95
121
  - `Convex.Project`: `{ team, name, deploymentRegion?, deploymentType? }` creates a project with its
package/lib/index.d.ts CHANGED
@@ -5,8 +5,10 @@ import * as Redacted from "effect/Redacted";
5
5
  import * as Schema from "effect/Schema";
6
6
  import { Resource } from "alchemy";
7
7
  import * as Provider from "alchemy/Provider";
8
- import "effect/process/ChildProcessSpawner";
8
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9
9
  import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
10
+ import "effect/Scope";
11
+ import { State, StateStoreError } from "alchemy/State";
10
12
  import { components } from "@convex-dev/platform/managementApi";
11
13
  //#region src/Credentials.d.ts
12
14
  interface ResolvedCredentials {
@@ -339,5 +341,18 @@ export declare class ProjectNotFound extends ProjectNotFound_base {
339
341
  get message(): string;
340
342
  }
341
343
  //#endregion
344
+ //#region src/State.d.ts
345
+ /** The name of the state project in each team. */
346
+ export declare const STORE_PROJECT_NAME = "alchemy-state";
347
+ /**
348
+ * Alchemy state in the `alchemy-state` project of `team`, with the team token
349
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. The first use of the run
350
+ * finds or creates the project and pushes the store when it is missing or
351
+ * older. `alchemy plan` on another stack never touches Convex.
352
+ */
353
+ export declare const state: (options: {
354
+ readonly team: string | number;
355
+ }) => Layer.Layer<State, never, ChildProcessSpawner.ChildProcessSpawner>;
356
+ //#endregion
342
357
  export type { DeploymentAttributes, DeploymentProps, DeploymentRegion, EnvValue, ProjectAttributes, ProjectProps, Source };
343
358
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Project.ts"],"mappings":";;;;;;;;;;;UA6BiB;;WAEN,aAAa,SAAS;;WAEtB;;UAGM;;WAEN,aAAa,OAAO,OAAO;;WAE3B,gBAAgB,OAAO;;WAEvB,WAAW,OAAO,OAAO,SAAS;;;qBAGhC,oBAAoB;;qBAKpB;;qBAGA;;;;qBAOA,yBAAyB;;;;;;;;;qBAiCzB,kBAAiB,MAAM,MAAM;;qBA0B7B,aAAc;WAChB,cAAc,SAAS;WACvB,YAAY,SAAS;MAC5B,MAAM,MAAM;;;KC3GJ,mBAAmB,kBAAkB;KACrC,mBAAmB,kBAAkB;;UAGhC,YAAY;WAClB,OAAO;WACP;WACA,UAAU;;;;;;;;;;;;;qBAQR,uBAAuB;UAuBnB;;WAEN,UAAU,GACjB,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,cACP,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,0BACxC,OAAO,aAAa;;WAEhB,oBAAoB,GAC3B,mBACA,6BACA,WAAW,SAAS,kBACpB,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,gBAAgB,0BAA0B,OAAO,eAAe;;;;;;;;WAQhE,oBAAoB,mBAAmB,OAAO;;;;;WAK9C,YAAY,OAAO,SAAS,qBAAqB;;;qBAM/C,sBAAsB;;;;qBClFtB,kBAAkB;;;;;;qBAOlB,YAAa,cAAa,MAAM,MAAM,iBAA2B,MAAA,MAAA,8CAAA,mCAAA,oDAAA;;;;KC8BlE,oBAAoB,SAAS;;;;;KAM7B;;WAIG;;;;;;;;WAQA;;;;;;;WAOA;;;;;;qBA2BF,0BAA0B;MAIxB;;;;;;qBAMF,0BAA0B;MAIxB;;;;;;;;;qBAMF,uBAAuB;MAMrB;;;;;;;qBAQF,2BAA2B;MAIzB;;;;;;;qBAMF,oCAAoC;MAIlC;;;;;;qBAMF,kCAAkC;MAIhC;;;qBAuCF,oBACX;EACG;EAAa;;;;UCzHR;;WAEC;aAAoB;;;;;;;;WAOpB,SAAS;;WAET,MAAM,SAAS,eAAe;;KAG7B,mBACP;;WAEU;MAEV;;WAEU;;;;;WAKA;;;UAIL;WACC;WACA,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;UAGM,6BAA6B;;WAEnC;;WAEA;;YAGC,aAAa,8BAEvB,iBACA,6BAEA;qBAEW,8BAAU,cAAA;;;;;;;;;qBAKV,iCAAiC;MAU/B;;;;;;qBAMF,qCAAqC;MAInC;;;;;;;;;qBAMF,4BAA4B;MAS1B;;;;;KChJH,mBAAmB;KAEnB;;WAGG;;WAEA;;WAEA,mBAAmB;;WAEnB;;;WAIA;;WAEA;;UAGE;WACN;WACA;WACA;WACA;WACA;WACA;;YAGC,UAAU,2BAA2B,cAAc,0BAA0B;qBAC5E,2BAAO,cAAA;;;;;;;qBAGP,0BAA0B;MAIxB;;;;;;;;;;;;qBASF,gCAAgC;MAS9B;;;;;;;;qBAOF,yBAAyB;MAKvB;;;;;;qBAMF,+BAA+B;MAI7B;;;;;;;qBAMF,wBAAwB;MAItB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Project.ts","../src/State.ts"],"mappings":";;;;;;;;;;;;;UA6BiB;;WAEN,aAAa,SAAS;;WAEtB;;UAGM;;WAEN,aAAa,OAAO,OAAO;;WAE3B,gBAAgB,OAAO;;WAEvB,WAAW,OAAO,OAAO,SAAS;;;qBAGhC,oBAAoB;;qBAKpB;;qBAGA;;;;qBAOA,yBAAyB;;;;;;;;;qBAiCzB,kBAAiB,MAAM,MAAM;;qBA0B7B,aAAc;WAChB,cAAc,SAAS;WACvB,YAAY,SAAS;MAC5B,MAAM,MAAM;;;KC3GJ,mBAAmB,kBAAkB;KACrC,mBAAmB,kBAAkB;;UAGhC,YAAY;WAClB,OAAO;WACP;WACA,UAAU;;;;;;;;;;;;;qBAQR,uBAAuB;UAuBnB;;WAEN,UAAU,GACjB,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,cACP,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,0BACxC,OAAO,aAAa;;WAEhB,oBAAoB,GAC3B,mBACA,6BACA,WAAW,SAAS,kBACpB,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,gBAAgB,0BAA0B,OAAO,eAAe;;;;;;;;WAQhE,oBAAoB,mBAAmB,OAAO;;;;;WAK9C,YAAY,OAAO,SAAS,qBAAqB;;;qBAM/C,sBAAsB;;;;qBClFtB,kBAAkB;;;;;;qBAOlB,YAAa,cAAa,MAAM,MAAM,iBAA2B,MAAA,MAAA,8CAAA,mCAAA,oDAAA;;;;KC+BlE,oBAAoB,SAAS;;;;;KAM7B;;WAIG;;;;;;;;WAQA;;;;;;;WAOA;;;;;;qBAkCF,0BAA0B;MAIxB;;;;;;qBAMF,0BAA0B;MAIxB;;;;;;;;;qBAMF,uBAAuB;MAMrB;;;;;;;qBAQF,2BAA2B;MAIzB;;;;;;;qBAMF,oCAAoC;MAIlC;;;;;;qBAMF,kCAAkC;MAIhC;;;qBAuCF,oBACX;EACG;EAAa;;;;UChIR;;WAEC;aAAoB;;;;;;;;WAOpB,SAAS;;WAET,MAAM,SAAS,eAAe;;KAG7B,mBACP;;WAEU;MAEV;;WAEU;;;;;WAKA;;;UAIL;WACC;WACA,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;UAGM,6BAA6B;;WAEnC;;WAEA;;YAGC,aAAa,8BAEvB,iBACA,6BAEA;qBAEW,8BAAU,cAAA;;;;;;;;;qBAKV,iCAAiC;MAU/B;;;;;;qBAMF,qCAAqC;MAInC;;;;;;;;;qBAMF,4BAA4B;MAS1B;;;;;KC5IH,mBAAmB;KAEnB;;WAGG;;WAEA;;WAEA,mBAAmB;;WAEnB;;;WAIA;;WAEA;;UAGE;WACN;WACA;WACA;WACA;WACA;WACA;;YAGC,UAAU,2BAA2B,cAAc,0BAA0B;qBAC5E,2BAAO,cAAA;;;;;;;qBAGP,0BAA0B;MAIxB;;;;;;;;;;;;qBASF,gCAAgC;MAS9B;;;;;;;;qBAOF,yBAAyB;MAKvB;;;;;;qBAMF,+BAA+B;MAI7B;;;;;;;qBAMF,wBAAwB;MAItB;;;;;qBChDF;;;;;;;qBA8UA,QAAS;WACX;MACP,MAAM,MAAM,cAAc,oBAAoB"}
package/lib/index.js CHANGED
@@ -25,6 +25,9 @@ import { hashDirectory } from "alchemy/Command/Memo";
25
25
  import * as Clock from "effect/Clock";
26
26
  import * as ChildProcess from "effect/process/ChildProcess";
27
27
  import * as Stream from "effect/Stream";
28
+ import { fileURLToPath } from "node:url";
29
+ import * as Scope from "effect/Scope";
30
+ import { STATE_STORE_VERSION, State, StateStoreError, encodeState, reviveState } from "alchemy/State";
28
31
  //#region src/Credentials.ts
29
32
  var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
30
33
  /** Environment variable for the access token, read by {@link fromConfig}. */
@@ -226,7 +229,7 @@ const sourceOf = (source) => typeof source === "string" ? {
226
229
  };
227
230
  /** Lifetime of a push key. Long enough for a slow push with index backfills. */
228
231
  const PUSH_KEY_LIFETIME_MS = 72e5;
229
- /** The only actions a push key gets. */
232
+ /** The only actions a key for `convex deploy` gets. */
230
233
  const PUSH_KEY_ACTIONS = [
231
234
  "deployment:deploy",
232
235
  "deployment:env:view",
@@ -328,7 +331,7 @@ const parseDeployOutput = (text) => {
328
331
  };
329
332
  /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
330
333
  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");
331
- const valueOf = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
334
+ const valueOf$1 = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
332
335
  /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
333
336
  const appDirectoryOf = (source) => Effect.tryPromise({
334
337
  try: async () => {
@@ -396,7 +399,7 @@ const pushHash = (options) => Effect.gen(function* () {
396
399
  lockfile: true
397
400
  }
398
401
  })));
399
- const env = Object.entries(options.env).map(([name, value]) => [name, valueOf(value)]).sort(([a], [b]) => a < b ? -1 : 1);
402
+ const env = Object.entries(options.env).map(([name, value]) => [name, valueOf$1(value)]).sort(([a], [b]) => a < b ? -1 : 1);
400
403
  return createHash("sha256").update(JSON.stringify({
401
404
  files,
402
405
  deployment: options.deployment,
@@ -423,29 +426,41 @@ const cliEnvironment = (home) => ({
423
426
  XDG_CONFIG_HOME: home
424
427
  });
425
428
  /**
426
- * Creates a deploy key for one push, runs `use` with it, and deletes it
427
- * afterwards. Without a push command, the key can only deploy and read and
428
- * write variables. The delete is skipped for a key that is the OAuth credential.
429
- * A failed delete is logged: the key expires anyway.
429
+ * A deploy key of `deployment` that expires after PUSH_KEY_LIFETIME_MS, with
430
+ * only `allowedActions`, or every action when that is undefined. Delete it
431
+ * with {@link deleteDeployKey} when the work is done.
430
432
  */
431
- const withPushKey = (api, deployment, source, use) => Effect.acquireUseRelease(Effect.gen(function* () {
433
+ const createDeployKey = (api, deployment, name, allowedActions) => Effect.gen(function* () {
432
434
  const now = yield* Clock.currentTimeMillis;
433
435
  const created = yield* api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
434
436
  params: { path: { deployment_name: deployment } },
435
437
  body: {
436
- name: "alchemy-push",
437
- ...source === void 0 || sourceOf(source).command === void 0 ? { allowedActions: [...PUSH_KEY_ACTIONS] } : {},
438
+ name,
439
+ ...allowedActions === void 0 ? {} : { allowedActions: [...allowedActions] },
438
440
  expiresAt: now + PUSH_KEY_LIFETIME_MS
439
441
  }
440
442
  }));
441
443
  return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
442
- }), use, (key) => Effect.gen(function* () {
444
+ });
445
+ /**
446
+ * Deletes a key that {@link createDeployKey} made. The delete is skipped for
447
+ * a key that is the OAuth credential. A failed delete is logged: the key
448
+ * expires anyway.
449
+ */
450
+ const deleteDeployKey = (api, deployment, key) => Effect.gen(function* () {
443
451
  if (yield* api.isCredentialToken(Redacted.value(key))) return;
444
452
  yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
445
453
  params: { path: { deployment_name: deployment } },
446
454
  body: { id: Redacted.value(key) }
447
455
  }))));
448
- }).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not delete its push key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 6e4} minutes.`))));
456
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not delete its key on ${deployment} (${error.message}). The key expires on its own within ${PUSH_KEY_LIFETIME_MS / 6e4} minutes.`)));
457
+ /**
458
+ * Creates a deploy key for one push, runs `use` with it, and deletes it
459
+ * afterwards, also after a failure. `allowedActions` is PUSH_KEY_ACTIONS for
460
+ * `convex deploy`, or undefined for a push command, which can do more than a
461
+ * push, such as the uploads of convex-hosting through `convex run`.
462
+ */
463
+ const withPushKey = (api, deployment, allowedActions, use) => Effect.acquireUseRelease(createDeployKey(api, deployment, "alchemy-push", allowedActions), use, (key) => deleteDeployKey(api, deployment, key));
449
464
  /** Every variable of the deployment with its value. */
450
465
  const listVariables = (url, key) => settle("list environment variables", () => createDeploymentClient(url, Redacted.value(key)).GET("/list_environment_variables")).pipe(Effect.map((body) => Schema.decodeUnknownSync(EnvironmentVariableList)(body).environmentVariables));
451
466
  /** One call that sets variables, or removes those whose value is null. */
@@ -486,15 +501,15 @@ const push = (spawner, key, options) => Effect.gen(function* () {
486
501
  } : yield* retryIdempotentWrite(Effect.gen(function* () {
487
502
  const current = yield* listVariables(options.url, key);
488
503
  if (!options.adopt) {
489
- const taken = env.filter(([name, value]) => !options.previousEnvNames.includes(name) && Object.hasOwn(current, name) && current[name] !== valueOf(value)).map(([name]) => name);
504
+ const taken = env.filter(([name, value]) => !options.previousEnvNames.includes(name) && Object.hasOwn(current, name) && current[name] !== valueOf$1(value)).map(([name]) => name);
490
505
  if (taken.length > 0) return yield* new VariablesOwnedBySomeoneElse({
491
506
  deployment: options.deployment,
492
507
  names: taken
493
508
  });
494
509
  }
495
- const changes = [...env.filter(([name, value]) => current[name] !== valueOf(value)).map(([name, value]) => ({
510
+ const changes = [...env.filter(([name, value]) => current[name] !== valueOf$1(value)).map(([name, value]) => ({
496
511
  name,
497
- value: valueOf(value)
512
+ value: valueOf$1(value)
498
513
  })), ...removed.filter((name) => Object.hasOwn(current, name)).map((name) => ({
499
514
  name,
500
515
  value: null
@@ -933,7 +948,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
933
948
  }) : access.kind === "previewKey" ? push(spawner, access.key, {
934
949
  ...options,
935
950
  previewName: target.previewName
936
- }) : withPushKey(access.api, target.name, news.source, (key) => push(spawner, key, {
951
+ }) : withPushKey(access.api, target.name, typeof news.source === "object" && news.source.command !== void 0 ? void 0 : PUSH_KEY_ACTIONS, (key) => push(spawner, key, {
937
952
  ...options,
938
953
  previewName: void 0
939
954
  }))).pipe(Effect.onError(() => created && access.kind !== "deployKey" ? absentAsUndefined(retryIdempotentWrite(access.api.requestVoid("delete deployment", (client) => client.POST("/deployments/{deployment_name}/delete", { params: { path: { deployment_name: target.name } } })))).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not delete ${target.name} after the failed push (${error.message}). The next run takes it over only with --adopt.`))) : Effect.void));
@@ -1041,7 +1056,9 @@ const DeploymentRow = Schema.Struct({
1041
1056
  name: Schema.String,
1042
1057
  deploymentType: Schema.String,
1043
1058
  isDefault: Schema.Boolean,
1044
- deploymentUrl: Schema.String
1059
+ deploymentUrl: Schema.String,
1060
+ /** Absent or null until the first push. */
1061
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
1045
1062
  });
1046
1063
  const DeploymentRows = Schema.Array(DeploymentRow);
1047
1064
  /**
@@ -1067,10 +1084,71 @@ const checkCreateTimeProps = (projectId, olds, news) => {
1067
1084
  };
1068
1085
  /** True when `team`, a slug or a numeric id, names the team of the project. */
1069
1086
  const isTeamOf = (details, team) => typeof team === "number" || /^\d+$/.test(team) ? details.teamId === Number(team) : details.teamSlug === team;
1087
+ /** The row of the project's default production deployment, or undefined. */
1088
+ const prodDeploymentOf = (api, projectId) => api.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: { path: { project_id: projectId } } })).pipe(Effect.map((rows) => Schema.decodeUnknownSync(DeploymentRows)(rows).find((row) => row.deploymentType === "prod" && row.isDefault)));
1089
+ /** GET /projects/{id}, or undefined after a 404. */
1090
+ const getProjectById = (api, projectId) => absentAsUndefined(api.request("get project", (client) => client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1091
+ /**
1092
+ * Every project in the team with exactly this name, in the order of the
1093
+ * listing. Reads every page. `q` narrows the listing on the server, and the
1094
+ * exact match here decides. Pattern from Confect
1095
+ * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
1096
+ * (https://github.com/rjdellecese/confect, ISC license).
1097
+ */
1098
+ const listProjectsByName = (api, teamId, name) => Effect.gen(function* () {
1099
+ const matches = [];
1100
+ const seenCursors = /* @__PURE__ */ new Set();
1101
+ let cursor;
1102
+ while (true) {
1103
+ const body = yield* api.request("list projects", (client) => client.GET("/teams/{team_id}/projects", { params: {
1104
+ path: { team_id: teamId },
1105
+ query: {
1106
+ q: name,
1107
+ ...cursor === void 0 ? {} : { cursor }
1108
+ }
1109
+ } }));
1110
+ const page = Schema.decodeUnknownSync(ProjectPage)(body);
1111
+ matches.push(...page.items.filter((row) => row.name === name));
1112
+ if (!page.pagination.hasMore) break;
1113
+ const next = page.pagination.nextCursor;
1114
+ if (next === void 0 || next === null || seenCursors.has(next)) return yield* new ConvexApiError({
1115
+ operation: "list projects",
1116
+ status: 0,
1117
+ code: "InvalidPagination",
1118
+ message: `The project listing of team ${teamId} reports more pages but gives no new cursor. Convex.Project stops so that it does not miss a project named "${name}".`
1119
+ });
1120
+ seenCursors.add(next);
1121
+ cursor = next;
1122
+ }
1123
+ return matches;
1124
+ });
1125
+ /** The one project in the team with exactly this name, or undefined. More than one is an error. */
1126
+ const findProjectByName = (api, teamId, name) => listProjectsByName(api, teamId, name).pipe(Effect.flatMap((matches) => matches.length > 1 ? new AmbiguousProject({
1127
+ teamId,
1128
+ name,
1129
+ projectIds: matches.map((row) => row.id)
1130
+ }) : Effect.succeed(matches[0])));
1131
+ /**
1132
+ * POST /teams/{team_id}/create_project, then GET /projects/{id}. The identity
1133
+ * comes from the create response, never from a name lookup.
1134
+ */
1135
+ const createProject = (api, teamId, props) => Effect.gen(function* () {
1136
+ const created = yield* api.request("create project", (client) => client.POST("/teams/{team_id}/create_project", {
1137
+ params: { path: { team_id: teamId } },
1138
+ body: {
1139
+ projectName: props.name,
1140
+ deploymentType: props.deploymentType,
1141
+ ...props.deploymentRegion === void 0 ? {} : { deploymentRegion: props.deploymentRegion }
1142
+ }
1143
+ }));
1144
+ const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
1145
+ const details = yield* getProjectById(api, projectId);
1146
+ if (details === void 0) return yield* new CreatedProjectNotFound({ projectId });
1147
+ return details;
1148
+ });
1070
1149
  const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1071
1150
  const api = yield* ManagementApi;
1072
- const prodDeployment = (projectId) => api.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: { path: { project_id: projectId } } })).pipe(Effect.map((rows) => Schema.decodeUnknownSync(DeploymentRows)(rows).find((row) => row.deploymentType === "prod" && row.isDefault)));
1073
- const attributesOf = (details) => prodDeployment(details.id).pipe(Effect.map((prod) => ({
1151
+ const attributesOf = (details) => prodDeploymentOf(api, details.id).pipe(Effect.map((prod) => ({
1074
1152
  projectId: details.id,
1075
1153
  slug: details.slug,
1076
1154
  name: details.name,
@@ -1078,51 +1156,13 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1078
1156
  prodDeploymentName: prod?.name ?? details.prodDeploymentName ?? void 0,
1079
1157
  prodDeploymentUrl: prod?.deploymentUrl
1080
1158
  })));
1081
- const getById = (projectId) => absentAsUndefined(api.request("get project", (client) => client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1159
+ const getById = (projectId) => getProjectById(api, projectId);
1082
1160
  /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
1083
1161
  const getBySlug = (team, slug) => absentAsUndefined(api.request("get project by slug", (client) => client.GET("/teams/{team_id_or_slug}/projects/{project_slug}", { params: { path: {
1084
1162
  team_id_or_slug: String(team),
1085
1163
  project_slug: slug
1086
1164
  } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1087
- /**
1088
- * The one project in the team with exactly this name, or undefined.
1089
- * Reads every page. `q` narrows the listing on the server, and the
1090
- * exact match here decides. Pattern from Confect
1091
- * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
1092
- * (https://github.com/rjdellecese/confect, ISC license).
1093
- */
1094
- const findByName = (teamId, name) => Effect.gen(function* () {
1095
- const matches = [];
1096
- const seenCursors = /* @__PURE__ */ new Set();
1097
- let cursor;
1098
- while (true) {
1099
- const body = yield* api.request("list projects", (client) => client.GET("/teams/{team_id}/projects", { params: {
1100
- path: { team_id: teamId },
1101
- query: {
1102
- q: name,
1103
- ...cursor === void 0 ? {} : { cursor }
1104
- }
1105
- } }));
1106
- const page = Schema.decodeUnknownSync(ProjectPage)(body);
1107
- matches.push(...page.items.filter((row) => row.name === name));
1108
- if (!page.pagination.hasMore) break;
1109
- const next = page.pagination.nextCursor;
1110
- if (next === void 0 || next === null || seenCursors.has(next)) return yield* new ConvexApiError({
1111
- operation: "list projects",
1112
- status: 0,
1113
- code: "InvalidPagination",
1114
- message: `The project listing of team ${teamId} reports more pages but gives no new cursor. Convex.Project stops so that it does not miss a project named "${name}".`
1115
- });
1116
- seenCursors.add(next);
1117
- cursor = next;
1118
- }
1119
- if (matches.length > 1) return yield* new AmbiguousProject({
1120
- teamId,
1121
- name,
1122
- projectIds: matches.map((row) => row.id)
1123
- });
1124
- return matches[0];
1125
- });
1165
+ const findByName = (teamId, name) => findProjectByName(api, teamId, name);
1126
1166
  /**
1127
1167
  * Brings an existing project to the desired name. The team cannot
1128
1168
  * change, so a project in another team fails.
@@ -1228,17 +1268,11 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1228
1268
  return yield* converge(existing, teamId, news.name);
1229
1269
  }
1230
1270
  }
1231
- const created = yield* api.request("create project", (client) => client.POST("/teams/{team_id}/create_project", {
1232
- params: { path: { team_id: teamId } },
1233
- body: {
1234
- projectName: news.name,
1235
- deploymentType: news.deploymentType ?? "prod",
1236
- ...news.deploymentRegion === void 0 ? {} : { deploymentRegion: news.deploymentRegion }
1237
- }
1238
- }));
1239
- const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
1240
- const details = yield* getById(projectId);
1241
- if (details === void 0) return yield* new CreatedProjectNotFound({ projectId });
1271
+ const details = yield* createProject(api, teamId, {
1272
+ name: news.name,
1273
+ deploymentType: news.deploymentType ?? "prod",
1274
+ deploymentRegion: news.deploymentRegion
1275
+ });
1242
1276
  return yield* converge(details, teamId, news.name);
1243
1277
  }),
1244
1278
  delete: Effect.fn(function* ({ output }) {
@@ -1256,6 +1290,235 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
1256
1290
  */
1257
1291
  const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([Project, Deployment])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.orDie);
1258
1292
  //#endregion
1259
- export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers };
1293
+ //#region src/State.ts
1294
+ /** The name of the state project in each team. */
1295
+ const STORE_PROJECT_NAME = "alchemy-state";
1296
+ /**
1297
+ * The actions of the key of one run: the internal queries and mutations of
1298
+ * the store, nothing else. Measured: `deployment:data:view` and
1299
+ * `deployment:data:write` do not run an internal function.
1300
+ */
1301
+ const RUN_KEY_ACTIONS = ["deployment:functions:runInternalQueries", "deployment:functions:runInternalMutations"];
1302
+ /** The body of POST /api/function, with HTTP 200 for a function error too. */
1303
+ const Reply = Schema.Union([Schema.Struct({
1304
+ status: Schema.Literal("success"),
1305
+ value: Schema.Unknown
1306
+ }), Schema.Struct({
1307
+ status: Schema.Literal("error"),
1308
+ errorMessage: Schema.String
1309
+ })]);
1310
+ /** The error reply of /api/function for a function that the deployment does not have (recorded). */
1311
+ const MISSING_FUNCTION = /Could not find function for '/;
1312
+ /** HTTP 401 or 403 from the function API: the key is gone or expired (recorded: 403 without a key). */
1313
+ const isUnauthorized = (error) => error.http?.status === 401 || error.http?.status === 403;
1314
+ const failure = (message) => new StateStoreError({ message: `Convex state: ${message}` });
1315
+ /** No reply, HTTP 429, or HTTP 5xx: a new attempt can succeed. Every call of the store is idempotent. */
1316
+ const isTransient = (error) => error.http !== void 0 && (error.http.status === void 0 || error.http.status === 429 || error.http.status >= 500);
1317
+ const post = (url, headers, body) => Effect.tryPromise({
1318
+ try: () => fetch(url, {
1319
+ method: "POST",
1320
+ headers: {
1321
+ "Content-Type": "application/json",
1322
+ ...headers
1323
+ },
1324
+ body: JSON.stringify(body)
1325
+ }),
1326
+ catch: () => new StateStoreError({
1327
+ message: `Convex state: POST ${url} failed`,
1328
+ http: {}
1329
+ })
1330
+ }).pipe(Effect.flatMap((response) => response.ok || response.status === 560 ? Effect.tryPromise({
1331
+ try: () => response.json(),
1332
+ catch: () => failure(`${url} gave no JSON`)
1333
+ }) : Effect.fail(new StateStoreError({
1334
+ message: `Convex state: POST ${url} failed with HTTP ${response.status}`,
1335
+ http: { status: response.status }
1336
+ }))), Effect.retry({
1337
+ while: isTransient,
1338
+ schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
1339
+ times: 5
1340
+ }));
1341
+ const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1342
+ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/function`, { Authorization: `Convex ${Redacted.value(key)}` }, {
1343
+ path,
1344
+ args,
1345
+ format: "json"
1346
+ }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`))) });
1347
+ /** The value of a reply, or the Convex message, which names the function and the cause, such as a missing key action. */
1348
+ const valueOf = (reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim()));
1349
+ /** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
1350
+ const remoteOf = (api) => {
1351
+ const run = (name, args) => api.call(`state:${name}`, args).pipe(Effect.flatMap(valueOf));
1352
+ const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
1353
+ const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.NullOr(Schema.String), name)));
1354
+ const encode = (value) => JSON.stringify(encodeState(value));
1355
+ return {
1356
+ listStacks: () => strings("stacks", {}),
1357
+ listStages: (stack) => strings("stages", { stack }),
1358
+ list: (r) => strings("list", {
1359
+ stack: r.stack,
1360
+ stage: r.stage
1361
+ }),
1362
+ get: (r) => optional("get", {
1363
+ stack: r.stack,
1364
+ stage: r.stage,
1365
+ fqn: r.fqn
1366
+ }).pipe(Effect.map((text) => text === null ? void 0 : JSON.parse(text, reviveState))),
1367
+ getReplacedResources: (r) => strings("replaced", {
1368
+ stack: r.stack,
1369
+ stage: r.stage
1370
+ }).pipe(Effect.map((texts) => texts.map((text) => JSON.parse(text, reviveState)))),
1371
+ getOutput: (r) => optional("getOutput", {
1372
+ stack: r.stack,
1373
+ stage: r.stage
1374
+ }).pipe(Effect.map((text) => text === null ? void 0 : JSON.parse(text, reviveState))),
1375
+ set: (r) => run("set", {
1376
+ stack: r.stack,
1377
+ stage: r.stage,
1378
+ fqn: r.fqn,
1379
+ value: encode(r.value)
1380
+ }),
1381
+ delete: (r) => run("remove", {
1382
+ stack: r.stack,
1383
+ stage: r.stage,
1384
+ fqn: r.fqn
1385
+ }),
1386
+ deleteStack: (r) => run("removeStack", {
1387
+ stack: r.stack,
1388
+ stage: r.stage
1389
+ }),
1390
+ setOutput: (r) => run("setOutput", {
1391
+ stack: r.stack,
1392
+ stage: r.stage,
1393
+ value: encode(r.value)
1394
+ })
1395
+ };
1396
+ };
1397
+ /** The version that the store answers, or undefined when the deployment has no `state:version`. */
1398
+ const storeVersion = (api) => api.call("state:version", {}).pipe(Effect.flatMap((reply) => reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) ? Effect.succeed(void 0) : valueOf(reply).pipe(Effect.flatMap(decode(Schema.Number, "store version")))));
1399
+ /**
1400
+ * The functions directory of the store app, `src/store/convex` of this
1401
+ * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
1402
+ * in the installed package, which ships `src` too, so the directory is
1403
+ * `../src/store/convex` from either place.
1404
+ */
1405
+ const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
1406
+ /**
1407
+ * Pushes `store` to the production deployment of the state project with the
1408
+ * installed Convex CLI: no typecheck, no codegen, the key only in the
1409
+ * environment of the CLI.
1410
+ */
1411
+ const pushStore = (options, deployment) => Effect.gen(function* () {
1412
+ const cli = yield* installedCli(join(options.store, ".."));
1413
+ yield* Effect.logInfo(`Convex state: pushing the store version 1 to ${deployment.name}`);
1414
+ yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => push(options.spawner, key, {
1415
+ deployment: deployment.name,
1416
+ url: deployment.deploymentUrl,
1417
+ source: {
1418
+ path: options.store,
1419
+ command: [
1420
+ ...cli,
1421
+ "deploy",
1422
+ "--typecheck",
1423
+ "disable",
1424
+ "--codegen",
1425
+ "disable"
1426
+ ]
1427
+ },
1428
+ env: {},
1429
+ previousEnvNames: [],
1430
+ adopt: false,
1431
+ previewName: void 0
1432
+ }));
1433
+ });
1434
+ /**
1435
+ * Creates the state project of the team. Two first runs can both create one:
1436
+ * the run whose project is not the oldest deletes its own, which holds
1437
+ * nothing yet, and uses the oldest, so that every run ends on one store.
1438
+ */
1439
+ const createStoreProject = (api, teamId, team) => Effect.gen(function* () {
1440
+ yield* Effect.logInfo(`Convex state: creating the project ${STORE_PROJECT_NAME} in team ${team}`);
1441
+ const created = yield* createProject(api, teamId, {
1442
+ name: STORE_PROJECT_NAME,
1443
+ deploymentType: "prod",
1444
+ deploymentRegion: void 0
1445
+ });
1446
+ const oldest = (yield* listProjectsByName(api, teamId, STORE_PROJECT_NAME)).reduce((a, b) => b.id < a.id ? b : a, created);
1447
+ if (oldest.id === created.id) return created;
1448
+ yield* Effect.logInfo(`Convex state: another run created ${STORE_PROJECT_NAME} ${oldest.id} first; project ${created.id} is deleted again.`);
1449
+ yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete project", (client) => client.POST("/projects/{project_id}/delete", { params: { path: { project_id: created.id } } }))));
1450
+ return oldest;
1451
+ });
1452
+ /**
1453
+ * The StateService over the `alchemy-state` project of the team: found or
1454
+ * created, its store pushed when it is missing or older, and read and
1455
+ * written with the keys of this run.
1456
+ */
1457
+ const makeConvexState = (options) => Effect.gen(function* () {
1458
+ const { api, team } = options;
1459
+ const teamId = yield* api.resolveTeamId(team);
1460
+ const project = (yield* findProjectByName(api, teamId, "alchemy-state")) ?? (yield* createStoreProject(api, teamId, team));
1461
+ const deployment = yield* prodDeploymentOf(api, project.id);
1462
+ if (deployment === void 0) return yield* new ProductionDeploymentNotFound({ projectId: project.id });
1463
+ const keys = [];
1464
+ yield* Scope.addFinalizer(options.scope, Effect.suspend(() => Effect.forEach(keys, (key) => deleteDeployKey(api, deployment.name, key), { discard: true })));
1465
+ const mint = createDeployKey(api, deployment.name, "alchemy-state", RUN_KEY_ACTIONS).pipe(Effect.map((key) => {
1466
+ keys.push(key);
1467
+ return makeFunctionApi(deployment.deploymentUrl, key);
1468
+ }), Effect.mapError((error) => failure(`no key for ${deployment.name}: ${error.message}`)));
1469
+ let current = yield* mint;
1470
+ const functions = { call: (path, args) => current.call(path, args).pipe(Effect.catchIf(isUnauthorized, () => mint.pipe(Effect.flatMap((next) => {
1471
+ current = next;
1472
+ return next.call(path, args);
1473
+ })))) };
1474
+ const version = yield* storeVersion(functions);
1475
+ if (version === void 0 || version < 1) {
1476
+ if (version === void 0 && deployment.lastDeployTime) return yield* failure(`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.`);
1477
+ const now = yield* storeVersion(functions);
1478
+ if (now === void 0 || now < 1) {
1479
+ yield* pushStore(options, deployment);
1480
+ const pushed = yield* storeVersion(functions);
1481
+ if (pushed === void 0 || pushed < 1) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 1.`);
1482
+ }
1483
+ } else if (version > 1) yield* Effect.logInfo(`Convex state: the store of team ${team} is version ${version}, newer than version 1 of this package, and it stays.`);
1484
+ const remote = remoteOf(functions);
1485
+ return {
1486
+ id: "convex",
1487
+ getVersion: () => Effect.succeed(STATE_STORE_VERSION),
1488
+ listStacks: remote.listStacks,
1489
+ listStages: remote.listStages,
1490
+ list: remote.list,
1491
+ get: remote.get,
1492
+ getReplacedResources: remote.getReplacedResources,
1493
+ getOutput: remote.getOutput,
1494
+ set: (request) => remote.set(request).pipe(Effect.as(request.value)),
1495
+ delete: (request) => remote.delete(request).pipe(Effect.asVoid),
1496
+ deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
1497
+ setOutput: (request) => remote.setOutput(request).pipe(Effect.as(request.value))
1498
+ };
1499
+ });
1500
+ /**
1501
+ * Alchemy state in the `alchemy-state` project of `team`, with the team token
1502
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. The first use of the run
1503
+ * finds or creates the project and pushes the store when it is missing or
1504
+ * older. `alchemy plan` on another stack never touches Convex.
1505
+ */
1506
+ const state = (options) => Layer.effect(State, Effect.gen(function* () {
1507
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1508
+ const scope = yield* Scope.Scope;
1509
+ const context = yield* Effect.context();
1510
+ return yield* Effect.cached(Effect.gen(function* () {
1511
+ const api = yield* ManagementApi.pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
1512
+ return yield* makeConvexState({
1513
+ team: options.team,
1514
+ api,
1515
+ spawner,
1516
+ scope,
1517
+ store: storeDirectory()
1518
+ });
1519
+ }).pipe(Effect.provideContext(context), Effect.orDie));
1520
+ }));
1521
+ //#endregion
1522
+ export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, STORE_PROJECT_NAME, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, state };
1260
1523
 
1261
1524
  //# sourceMappingURL=index.js.map