@samebase/alchemy-convex 0.5.0 → 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/lib/index.js CHANGED
@@ -25,7 +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 { InMemoryService, STATE_STORE_VERSION, State, StateStoreError, encodeState, reviveState } from "alchemy/State";
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";
29
31
  //#region src/Credentials.ts
30
32
  var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
31
33
  /** Environment variable for the access token, read by {@link fromConfig}. */
@@ -227,7 +229,7 @@ const sourceOf = (source) => typeof source === "string" ? {
227
229
  };
228
230
  /** Lifetime of a push key. Long enough for a slow push with index backfills. */
229
231
  const PUSH_KEY_LIFETIME_MS = 72e5;
230
- /** The only actions a push key gets. */
232
+ /** The only actions a key for `convex deploy` gets. */
231
233
  const PUSH_KEY_ACTIONS = [
232
234
  "deployment:deploy",
233
235
  "deployment:env:view",
@@ -329,7 +331,7 @@ const parseDeployOutput = (text) => {
329
331
  };
330
332
  /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
331
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");
332
- const valueOf = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
334
+ const valueOf$1 = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
333
335
  /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
334
336
  const appDirectoryOf = (source) => Effect.tryPromise({
335
337
  try: async () => {
@@ -397,7 +399,7 @@ const pushHash = (options) => Effect.gen(function* () {
397
399
  lockfile: true
398
400
  }
399
401
  })));
400
- 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);
401
403
  return createHash("sha256").update(JSON.stringify({
402
404
  files,
403
405
  deployment: options.deployment,
@@ -424,29 +426,41 @@ const cliEnvironment = (home) => ({
424
426
  XDG_CONFIG_HOME: home
425
427
  });
426
428
  /**
427
- * Creates a deploy key for one push, runs `use` with it, and deletes it
428
- * afterwards. Without a push command, the key can only deploy and read and
429
- * write variables. The delete is skipped for a key that is the OAuth credential.
430
- * 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.
431
432
  */
432
- const withPushKey = (api, deployment, source, use) => Effect.acquireUseRelease(Effect.gen(function* () {
433
+ const createDeployKey = (api, deployment, name, allowedActions) => Effect.gen(function* () {
433
434
  const now = yield* Clock.currentTimeMillis;
434
435
  const created = yield* api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
435
436
  params: { path: { deployment_name: deployment } },
436
437
  body: {
437
- name: "alchemy-push",
438
- ...source === void 0 || sourceOf(source).command === void 0 ? { allowedActions: [...PUSH_KEY_ACTIONS] } : {},
438
+ name,
439
+ ...allowedActions === void 0 ? {} : { allowedActions: [...allowedActions] },
439
440
  expiresAt: now + PUSH_KEY_LIFETIME_MS
440
441
  }
441
442
  }));
442
443
  return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
443
- }), 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* () {
444
451
  if (yield* api.isCredentialToken(Redacted.value(key))) return;
445
452
  yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
446
453
  params: { path: { deployment_name: deployment } },
447
454
  body: { id: Redacted.value(key) }
448
455
  }))));
449
- }).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));
450
464
  /** Every variable of the deployment with its value. */
451
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));
452
466
  /** One call that sets variables, or removes those whose value is null. */
@@ -487,15 +501,15 @@ const push = (spawner, key, options) => Effect.gen(function* () {
487
501
  } : yield* retryIdempotentWrite(Effect.gen(function* () {
488
502
  const current = yield* listVariables(options.url, key);
489
503
  if (!options.adopt) {
490
- 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);
491
505
  if (taken.length > 0) return yield* new VariablesOwnedBySomeoneElse({
492
506
  deployment: options.deployment,
493
507
  names: taken
494
508
  });
495
509
  }
496
- 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]) => ({
497
511
  name,
498
- value: valueOf(value)
512
+ value: valueOf$1(value)
499
513
  })), ...removed.filter((name) => Object.hasOwn(current, name)).map((name) => ({
500
514
  name,
501
515
  value: null
@@ -934,7 +948,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
934
948
  }) : access.kind === "previewKey" ? push(spawner, access.key, {
935
949
  ...options,
936
950
  previewName: target.previewName
937
- }) : 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, {
938
952
  ...options,
939
953
  previewName: void 0
940
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));
@@ -1042,7 +1056,9 @@ const DeploymentRow = Schema.Struct({
1042
1056
  name: Schema.String,
1043
1057
  deploymentType: Schema.String,
1044
1058
  isDefault: Schema.Boolean,
1045
- deploymentUrl: Schema.String
1059
+ deploymentUrl: Schema.String,
1060
+ /** Absent or null until the first push. */
1061
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
1046
1062
  });
1047
1063
  const DeploymentRows = Schema.Array(DeploymentRow);
1048
1064
  /**
@@ -1068,10 +1084,71 @@ const checkCreateTimeProps = (projectId, olds, news) => {
1068
1084
  };
1069
1085
  /** True when `team`, a slug or a numeric id, names the team of the project. */
1070
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
+ });
1071
1149
  const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1072
1150
  const api = yield* ManagementApi;
1073
- 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)));
1074
- const attributesOf = (details) => prodDeployment(details.id).pipe(Effect.map((prod) => ({
1151
+ const attributesOf = (details) => prodDeploymentOf(api, details.id).pipe(Effect.map((prod) => ({
1075
1152
  projectId: details.id,
1076
1153
  slug: details.slug,
1077
1154
  name: details.name,
@@ -1079,51 +1156,13 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1079
1156
  prodDeploymentName: prod?.name ?? details.prodDeploymentName ?? void 0,
1080
1157
  prodDeploymentUrl: prod?.deploymentUrl
1081
1158
  })));
1082
- 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);
1083
1160
  /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
1084
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: {
1085
1162
  team_id_or_slug: String(team),
1086
1163
  project_slug: slug
1087
1164
  } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1088
- /**
1089
- * The one project in the team with exactly this name, or undefined.
1090
- * Reads every page. `q` narrows the listing on the server, and the
1091
- * exact match here decides. Pattern from Confect
1092
- * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
1093
- * (https://github.com/rjdellecese/confect, ISC license).
1094
- */
1095
- const findByName = (teamId, name) => Effect.gen(function* () {
1096
- const matches = [];
1097
- const seenCursors = /* @__PURE__ */ new Set();
1098
- let cursor;
1099
- while (true) {
1100
- const body = yield* api.request("list projects", (client) => client.GET("/teams/{team_id}/projects", { params: {
1101
- path: { team_id: teamId },
1102
- query: {
1103
- q: name,
1104
- ...cursor === void 0 ? {} : { cursor }
1105
- }
1106
- } }));
1107
- const page = Schema.decodeUnknownSync(ProjectPage)(body);
1108
- matches.push(...page.items.filter((row) => row.name === name));
1109
- if (!page.pagination.hasMore) break;
1110
- const next = page.pagination.nextCursor;
1111
- if (next === void 0 || next === null || seenCursors.has(next)) return yield* new ConvexApiError({
1112
- operation: "list projects",
1113
- status: 0,
1114
- code: "InvalidPagination",
1115
- 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}".`
1116
- });
1117
- seenCursors.add(next);
1118
- cursor = next;
1119
- }
1120
- if (matches.length > 1) return yield* new AmbiguousProject({
1121
- teamId,
1122
- name,
1123
- projectIds: matches.map((row) => row.id)
1124
- });
1125
- return matches[0];
1126
- });
1165
+ const findByName = (teamId, name) => findProjectByName(api, teamId, name);
1127
1166
  /**
1128
1167
  * Brings an existing project to the desired name. The team cannot
1129
1168
  * change, so a project in another team fails.
@@ -1229,17 +1268,11 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1229
1268
  return yield* converge(existing, teamId, news.name);
1230
1269
  }
1231
1270
  }
1232
- const created = yield* api.request("create project", (client) => client.POST("/teams/{team_id}/create_project", {
1233
- params: { path: { team_id: teamId } },
1234
- body: {
1235
- projectName: news.name,
1236
- deploymentType: news.deploymentType ?? "prod",
1237
- ...news.deploymentRegion === void 0 ? {} : { deploymentRegion: news.deploymentRegion }
1238
- }
1239
- }));
1240
- const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
1241
- const details = yield* getById(projectId);
1242
- 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
+ });
1243
1276
  return yield* converge(details, teamId, news.name);
1244
1277
  }),
1245
1278
  delete: Effect.fn(function* ({ output }) {
@@ -1258,11 +1291,15 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
1258
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);
1259
1292
  //#endregion
1260
1293
  //#region src/State.ts
1261
- /** Environment variable with the deploy key of the deployment that holds the state. */
1262
- const STATE_DEPLOY_KEY_ENV = "CONVEX_STATE_DEPLOY_KEY";
1263
- /** The component path of alchemyState in the app: its default name. */
1264
- const COMPONENT_PATH = "alchemyState";
1265
- /** The body of POST /api/function and /api/query, with HTTP 200 or 560 for a function error. */
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. */
1266
1303
  const Reply = Schema.Union([Schema.Struct({
1267
1304
  status: Schema.Literal("success"),
1268
1305
  value: Schema.Unknown
@@ -1270,11 +1307,10 @@ const Reply = Schema.Union([Schema.Struct({
1270
1307
  status: Schema.Literal("error"),
1271
1308
  errorMessage: Schema.String
1272
1309
  })]);
1273
- /** The value of `_system/frontend/components:list`: the root has path "", a removed mount is "unmounted". */
1274
- const Components = Schema.Array(Schema.Struct({
1275
- path: Schema.String,
1276
- state: Schema.String
1277
- }));
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;
1278
1314
  const failure = (message) => new StateStoreError({ message: `Convex state: ${message}` });
1279
1315
  /** No reply, HTTP 429, or HTTP 5xx: a new attempt can succeed. Every call of the store is idempotent. */
1280
1316
  const isTransient = (error) => error.http !== void 0 && (error.http.status === void 0 || error.http.status === 429 || error.http.status >= 500);
@@ -1303,22 +1339,16 @@ const post = (url, headers, body) => Effect.tryPromise({
1303
1339
  times: 5
1304
1340
  }));
1305
1341
  const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1306
- /**
1307
- * The deployment URL of a deploy key. The Convex CLI makes the same unofficial
1308
- * call for every deploy key (convex 1.46 `cli/lib/deploymentSelection.js`).
1309
- */
1310
- const urlForKey = (key) => post("https://api.convex.dev/api/deployment/url_for_key", {}, { deployKey: Redacted.value(key) }).pipe(Effect.flatMap(decode(Schema.String, "deployment URL")));
1311
- const makeFunctionApi = (url, key) => ({ call: (path, args, componentPath) => post(`${url}/api/${componentPath === void 0 ? "query" : "function"}`, { Authorization: `Convex ${Redacted.value(key)}` }, {
1312
- ...componentPath === void 0 ? {} : { componentPath },
1342
+ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/function`, { Authorization: `Convex ${Redacted.value(key)}` }, {
1313
1343
  path,
1314
1344
  args,
1315
1345
  format: "json"
1316
- }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`)), Effect.flatMap((reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim())))) });
1317
- /** The state of the alchemyState mount: "active", "unmounted" (its rows are kept), or undefined. */
1318
- const mountOf = (api) => api.call("_system/frontend/components:list", {}).pipe(Effect.flatMap(decode(Components, "component list")), Effect.map((components) => components.find((c) => c.path === COMPONENT_PATH)?.state));
1319
- /** The component functions with Alchemy's encoding: the JSON text of encodeState. */
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. */
1320
1350
  const remoteOf = (api) => {
1321
- const run = (name, args) => api.call(`state:${name}`, args, COMPONENT_PATH);
1351
+ const run = (name, args) => api.call(`state:${name}`, args).pipe(Effect.flatMap(valueOf));
1322
1352
  const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
1323
1353
  const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.NullOr(Schema.String), name)));
1324
1354
  const encode = (value) => JSON.stringify(encodeState(value));
@@ -1364,84 +1394,131 @@ const remoteOf = (api) => {
1364
1394
  })
1365
1395
  };
1366
1396
  };
1367
- const unmounted = failure(`the ${COMPONENT_PATH} component of the deployment is unmounted. Its rows are kept: mount @samebase/alchemy-convex/convex.config in convex/convex.config.ts again.`);
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")))));
1368
1399
  /**
1369
- * The StateService over `api`. Without an alchemyState component at
1370
- * the start, the state of the run stays in memory until a write finds the
1371
- * component active.
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.
1372
1404
  */
1373
- const makeConvexState = (api) => Effect.gen(function* () {
1374
- const remote = remoteOf(api);
1375
- const mount = yield* mountOf(api);
1376
- if (mount === "unmounted") return yield* unmounted;
1377
- let pending = mount === void 0;
1378
- const rows = {};
1379
- const outputs = {};
1380
- const memory = yield* InMemoryService(rows, outputs);
1381
- /** Moves the memory to the component once it is active. Rows that the run did not read stop it. */
1382
- const flush = Effect.gen(function* () {
1383
- const now = yield* mountOf(api);
1384
- if (now === void 0) return;
1385
- if (now !== "active") return yield* unmounted;
1386
- const stages = [.../* @__PURE__ */ new Set([...Object.keys(rows), ...Object.keys(outputs)])].flatMap((stack) => [.../* @__PURE__ */ new Set([...Object.keys(rows[stack] ?? {}), ...Object.keys(outputs[stack] ?? {})])].map((stage) => ({
1387
- stack,
1388
- stage
1389
- })));
1390
- for (const { stack, stage } of stages) {
1391
- const found = yield* remote.list({
1392
- stack,
1393
- stage
1394
- });
1395
- if (found.length > 0) return yield* failure(`${stack}/${stage} has ${found.length} rows in the deployment that this run did not read, so the run wrote none. Run it again.`);
1396
- }
1397
- for (const { stack, stage } of stages) {
1398
- for (const [fqn, value] of Object.entries(rows[stack]?.[stage] ?? {})) yield* remote.set({
1399
- stack,
1400
- stage,
1401
- fqn,
1402
- value
1403
- });
1404
- const byStage = outputs[stack];
1405
- if (byStage !== void 0 && stage in byStage) yield* remote.setOutput({
1406
- stack,
1407
- stage,
1408
- value: byStage[stage]
1409
- });
1410
- }
1411
- pending = false;
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
1412
1445
  });
1413
- const read = (inMemory, inConvex) => Effect.suspend(() => pending ? inMemory : inConvex);
1414
- const write = (inMemory, inConvex, result) => Effect.suspend(() => pending ? Effect.tap(inMemory, () => flush) : Effect.as(inConvex, result));
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);
1415
1485
  return {
1416
1486
  id: "convex",
1417
1487
  getVersion: () => Effect.succeed(STATE_STORE_VERSION),
1418
- listStacks: () => read(memory.listStacks(), remote.listStacks()),
1419
- listStages: (stack) => read(memory.listStages(stack), remote.listStages(stack)),
1420
- list: (request) => read(memory.list(request), remote.list(request)),
1421
- get: (request) => read(memory.get(request), remote.get(request)),
1422
- getReplacedResources: (request) => read(memory.getReplacedResources(request), remote.getReplacedResources(request)),
1423
- getOutput: (request) => read(memory.getOutput(request), remote.getOutput(request)),
1424
- set: (request) => write(memory.set(request), remote.set(request), request.value),
1425
- delete: (request) => write(memory.delete(request), remote.delete(request), void 0),
1426
- deleteStack: (request) => write(memory.deleteStack(request), remote.deleteStack(request), void 0),
1427
- setOutput: (request) => write(memory.setOutput(request), remote.setOutput(request), request.value).pipe(Effect.tap(() => pending ? Effect.fail(failure(`the deployment of ${STATE_DEPLOY_KEY_ENV} has no ${COMPONENT_PATH} component at the end of the run, so this run saved no state. Mount @samebase/alchemy-convex/convex.config in convex/convex.config.ts.`)) : Effect.void))
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))
1428
1498
  };
1429
1499
  });
1430
1500
  /**
1431
- * Alchemy state in the alchemyState component of the deployment that the
1432
- * deploy key in CONVEX_STATE_DEPLOY_KEY names: the production deployment of
1433
- * the project. The key needs the actions `deployment:data:view` and
1434
- * `deployment:data:write`.
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.
1435
1505
  */
1436
- const state = () => Layer.effect(State, Effect.gen(function* () {
1506
+ const state = (options) => Layer.effect(State, Effect.gen(function* () {
1507
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1508
+ const scope = yield* Scope.Scope;
1437
1509
  const context = yield* Effect.context();
1438
1510
  return yield* Effect.cached(Effect.gen(function* () {
1439
- const key = yield* Config.Redacted(STATE_DEPLOY_KEY_ENV);
1440
- if (keyTargetOf(Redacted.value(key)).kind !== "deployment") return yield* failure(`${STATE_DEPLOY_KEY_ENV} must be the deploy key of one deployment, such as prod:<name>|..., not a preview deploy key.`);
1441
- return yield* makeConvexState(makeFunctionApi(yield* urlForKey(key), key));
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
+ });
1442
1519
  }).pipe(Effect.provideContext(context), Effect.orDie));
1443
1520
  }));
1444
1521
  //#endregion
1445
- 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, STATE_DEPLOY_KEY_ENV, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, state };
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 };
1446
1523
 
1447
1524
  //# sourceMappingURL=index.js.map