@samebase/alchemy-convex 0.5.0 → 0.6.1

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}. */
@@ -167,15 +169,54 @@ const TeamRow = Schema.Struct({
167
169
  slug: Schema.String
168
170
  });
169
171
  const TeamRows = Schema.Array(TeamRow);
172
+ /** The first page of GET /teams/{team_id}/projects: every row carries the slug of the team. */
173
+ const ProjectSlugRows = Schema.Struct({ items: Schema.Array(Schema.Struct({ teamSlug: Schema.String })) });
174
+ /** GET /token_details: the team of a team token, or the project of a project token. */
175
+ const TokenDetails = Schema.Union([Schema.Struct({
176
+ type: Schema.Literal("teamToken"),
177
+ teamId: Schema.Number
178
+ }), Schema.Struct({
179
+ type: Schema.Literal("projectToken"),
180
+ projectId: Schema.Number
181
+ })]);
170
182
  /** The service for one way to get the bearer token. */
171
183
  const makeService = (credentials) => {
172
184
  const client = Effect.cached(credentials.pipe(Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))))).pipe(Effect.flatten);
173
185
  const request = (operation, run) => client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
174
186
  const requestVoid = (operation, run) => client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
175
187
  const deploymentRequest = (operation, deploymentUrlOrName, deployKey, run) => settle(operation, () => run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))));
188
+ const tokenDetails = Effect.runSync(Effect.cached(request("get token details", (c) => c.GET("/token_details")).pipe(Effect.map((body) => Schema.decodeUnknownSync(TokenDetails)(body)), Effect.catchIf((error) => error.status === 403, () => Effect.succeed(void 0)))));
189
+ /** The slug of the team, from the first project of its listing, or undefined for a team without one. Read once. */
190
+ const teamSlug = (teamId) => request("list projects", (c) => c.GET("/teams/{team_id}/projects", { params: { path: { team_id: teamId } } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(ProjectSlugRows)(body).items[0]?.teamSlug));
176
191
  const resolveTeamId = (team) => Effect.gen(function* () {
177
192
  if (typeof team === "number") return team;
178
- if (/^\d+$/.test(team)) return Number(team);
193
+ if (team !== void 0 && /^\d+$/.test(team)) return Number(team);
194
+ const details = yield* tokenDetails;
195
+ if (details?.type === "teamToken") {
196
+ if (team === void 0) return details.teamId;
197
+ const slug = yield* teamSlug(details.teamId);
198
+ if (slug === void 0) return yield* new ConvexApiError({
199
+ operation: "resolve team",
200
+ status: 0,
201
+ message: `team "${team}" cannot be checked against the access token: team ${details.teamId} has no project yet. Set team to ${details.teamId}, or leave it out.`
202
+ });
203
+ if (slug !== team) return yield* new ConvexApiError({
204
+ operation: "resolve team",
205
+ status: 403,
206
+ message: `the access token belongs to team ${details.teamId} ("${slug}"), and team is "${team}". Use a token of "${team}", or set team to ${details.teamId}.`
207
+ });
208
+ return details.teamId;
209
+ }
210
+ if (details?.type === "projectToken") return yield* new ConvexApiError({
211
+ operation: "resolve team",
212
+ status: 403,
213
+ message: `the access token belongs to project ${details.projectId}, and a project token cannot create projects. Use a team access token.`
214
+ });
215
+ if (team === void 0) return yield* new ConvexApiError({
216
+ operation: "resolve team",
217
+ status: 0,
218
+ message: "set team to the slug or the id of the Convex team: this access token does not name a team."
219
+ });
179
220
  const resolved = yield* credentials;
180
221
  const match = (yield* Effect.tryPromise({
181
222
  try: async () => {
@@ -227,7 +268,7 @@ const sourceOf = (source) => typeof source === "string" ? {
227
268
  };
228
269
  /** Lifetime of a push key. Long enough for a slow push with index backfills. */
229
270
  const PUSH_KEY_LIFETIME_MS = 72e5;
230
- /** The only actions a push key gets. */
271
+ /** The only actions a key for `convex deploy` gets. */
231
272
  const PUSH_KEY_ACTIONS = [
232
273
  "deployment:deploy",
233
274
  "deployment:env:view",
@@ -329,7 +370,7 @@ const parseDeployOutput = (text) => {
329
370
  };
330
371
  /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
331
372
  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;
373
+ const valueOf$1 = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
333
374
  /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
334
375
  const appDirectoryOf = (source) => Effect.tryPromise({
335
376
  try: async () => {
@@ -397,7 +438,7 @@ const pushHash = (options) => Effect.gen(function* () {
397
438
  lockfile: true
398
439
  }
399
440
  })));
400
- const env = Object.entries(options.env).map(([name, value]) => [name, valueOf(value)]).sort(([a], [b]) => a < b ? -1 : 1);
441
+ const env = Object.entries(options.env).map(([name, value]) => [name, valueOf$1(value)]).sort(([a], [b]) => a < b ? -1 : 1);
401
442
  return createHash("sha256").update(JSON.stringify({
402
443
  files,
403
444
  deployment: options.deployment,
@@ -424,29 +465,41 @@ const cliEnvironment = (home) => ({
424
465
  XDG_CONFIG_HOME: home
425
466
  });
426
467
  /**
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.
468
+ * A deploy key of `deployment` that expires after PUSH_KEY_LIFETIME_MS, with
469
+ * only `allowedActions`, or every action when that is undefined. Delete it
470
+ * with {@link deleteDeployKey} when the work is done.
431
471
  */
432
- const withPushKey = (api, deployment, source, use) => Effect.acquireUseRelease(Effect.gen(function* () {
472
+ const createDeployKey = (api, deployment, name, allowedActions) => Effect.gen(function* () {
433
473
  const now = yield* Clock.currentTimeMillis;
434
474
  const created = yield* api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
435
475
  params: { path: { deployment_name: deployment } },
436
476
  body: {
437
- name: "alchemy-push",
438
- ...source === void 0 || sourceOf(source).command === void 0 ? { allowedActions: [...PUSH_KEY_ACTIONS] } : {},
477
+ name,
478
+ ...allowedActions === void 0 ? {} : { allowedActions: [...allowedActions] },
439
479
  expiresAt: now + PUSH_KEY_LIFETIME_MS
440
480
  }
441
481
  }));
442
482
  return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
443
- }), use, (key) => Effect.gen(function* () {
483
+ });
484
+ /**
485
+ * Deletes a key that {@link createDeployKey} made. The delete is skipped for
486
+ * a key that is the OAuth credential. A failed delete is logged: the key
487
+ * expires anyway.
488
+ */
489
+ const deleteDeployKey = (api, deployment, key) => Effect.gen(function* () {
444
490
  if (yield* api.isCredentialToken(Redacted.value(key))) return;
445
491
  yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
446
492
  params: { path: { deployment_name: deployment } },
447
493
  body: { id: Redacted.value(key) }
448
494
  }))));
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.`))));
495
+ }).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.`)));
496
+ /**
497
+ * Creates a deploy key for one push, runs `use` with it, and deletes it
498
+ * afterwards, also after a failure. `allowedActions` is PUSH_KEY_ACTIONS for
499
+ * `convex deploy`, or undefined for a push command, which can do more than a
500
+ * push, such as the uploads of convex-hosting through `convex run`.
501
+ */
502
+ const withPushKey = (api, deployment, allowedActions, use) => Effect.acquireUseRelease(createDeployKey(api, deployment, "alchemy-push", allowedActions), use, (key) => deleteDeployKey(api, deployment, key));
450
503
  /** Every variable of the deployment with its value. */
451
504
  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
505
  /** One call that sets variables, or removes those whose value is null. */
@@ -487,15 +540,15 @@ const push = (spawner, key, options) => Effect.gen(function* () {
487
540
  } : yield* retryIdempotentWrite(Effect.gen(function* () {
488
541
  const current = yield* listVariables(options.url, key);
489
542
  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);
543
+ const taken = env.filter(([name, value]) => !options.previousEnvNames.includes(name) && Object.hasOwn(current, name) && current[name] !== valueOf$1(value)).map(([name]) => name);
491
544
  if (taken.length > 0) return yield* new VariablesOwnedBySomeoneElse({
492
545
  deployment: options.deployment,
493
546
  names: taken
494
547
  });
495
548
  }
496
- const changes = [...env.filter(([name, value]) => current[name] !== valueOf(value)).map(([name, value]) => ({
549
+ const changes = [...env.filter(([name, value]) => current[name] !== valueOf$1(value)).map(([name, value]) => ({
497
550
  name,
498
- value: valueOf(value)
551
+ value: valueOf$1(value)
499
552
  })), ...removed.filter((name) => Object.hasOwn(current, name)).map((name) => ({
500
553
  name,
501
554
  value: null
@@ -934,7 +987,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
934
987
  }) : access.kind === "previewKey" ? push(spawner, access.key, {
935
988
  ...options,
936
989
  previewName: target.previewName
937
- }) : withPushKey(access.api, target.name, news.source, (key) => push(spawner, key, {
990
+ }) : withPushKey(access.api, target.name, typeof news.source === "object" && news.source.command !== void 0 ? void 0 : PUSH_KEY_ACTIONS, (key) => push(spawner, key, {
938
991
  ...options,
939
992
  previewName: void 0
940
993
  }))).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 +1095,9 @@ const DeploymentRow = Schema.Struct({
1042
1095
  name: Schema.String,
1043
1096
  deploymentType: Schema.String,
1044
1097
  isDefault: Schema.Boolean,
1045
- deploymentUrl: Schema.String
1098
+ deploymentUrl: Schema.String,
1099
+ /** Absent or null until the first push. */
1100
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
1046
1101
  });
1047
1102
  const DeploymentRows = Schema.Array(DeploymentRow);
1048
1103
  /**
@@ -1068,10 +1123,71 @@ const checkCreateTimeProps = (projectId, olds, news) => {
1068
1123
  };
1069
1124
  /** True when `team`, a slug or a numeric id, names the team of the project. */
1070
1125
  const isTeamOf = (details, team) => typeof team === "number" || /^\d+$/.test(team) ? details.teamId === Number(team) : details.teamSlug === team;
1126
+ /** The row of the project's default production deployment, or undefined. */
1127
+ 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)));
1128
+ /** GET /projects/{id}, or undefined after a 404. */
1129
+ 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)));
1130
+ /**
1131
+ * Every project in the team with exactly this name, in the order of the
1132
+ * listing. Reads every page. `q` narrows the listing on the server, and the
1133
+ * exact match here decides. Pattern from Confect
1134
+ * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
1135
+ * (https://github.com/rjdellecese/confect, ISC license).
1136
+ */
1137
+ const listProjectsByName = (api, teamId, name) => Effect.gen(function* () {
1138
+ const matches = [];
1139
+ const seenCursors = /* @__PURE__ */ new Set();
1140
+ let cursor;
1141
+ while (true) {
1142
+ const body = yield* api.request("list projects", (client) => client.GET("/teams/{team_id}/projects", { params: {
1143
+ path: { team_id: teamId },
1144
+ query: {
1145
+ q: name,
1146
+ ...cursor === void 0 ? {} : { cursor }
1147
+ }
1148
+ } }));
1149
+ const page = Schema.decodeUnknownSync(ProjectPage)(body);
1150
+ matches.push(...page.items.filter((row) => row.name === name));
1151
+ if (!page.pagination.hasMore) break;
1152
+ const next = page.pagination.nextCursor;
1153
+ if (next === void 0 || next === null || seenCursors.has(next)) return yield* new ConvexApiError({
1154
+ operation: "list projects",
1155
+ status: 0,
1156
+ code: "InvalidPagination",
1157
+ 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}".`
1158
+ });
1159
+ seenCursors.add(next);
1160
+ cursor = next;
1161
+ }
1162
+ return matches;
1163
+ });
1164
+ /** The one project in the team with exactly this name, or undefined. More than one is an error. */
1165
+ const findProjectByName = (api, teamId, name) => listProjectsByName(api, teamId, name).pipe(Effect.flatMap((matches) => matches.length > 1 ? new AmbiguousProject({
1166
+ teamId,
1167
+ name,
1168
+ projectIds: matches.map((row) => row.id)
1169
+ }) : Effect.succeed(matches[0])));
1170
+ /**
1171
+ * POST /teams/{team_id}/create_project, then GET /projects/{id}. The identity
1172
+ * comes from the create response, never from a name lookup.
1173
+ */
1174
+ const createProject = (api, teamId, props) => Effect.gen(function* () {
1175
+ const created = yield* api.request("create project", (client) => client.POST("/teams/{team_id}/create_project", {
1176
+ params: { path: { team_id: teamId } },
1177
+ body: {
1178
+ projectName: props.name,
1179
+ deploymentType: props.deploymentType,
1180
+ ...props.deploymentRegion === void 0 ? {} : { deploymentRegion: props.deploymentRegion }
1181
+ }
1182
+ }));
1183
+ const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
1184
+ const details = yield* getProjectById(api, projectId);
1185
+ if (details === void 0) return yield* new CreatedProjectNotFound({ projectId });
1186
+ return details;
1187
+ });
1071
1188
  const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1072
1189
  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) => ({
1190
+ const attributesOf = (details) => prodDeploymentOf(api, details.id).pipe(Effect.map((prod) => ({
1075
1191
  projectId: details.id,
1076
1192
  slug: details.slug,
1077
1193
  name: details.name,
@@ -1079,51 +1195,13 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1079
1195
  prodDeploymentName: prod?.name ?? details.prodDeploymentName ?? void 0,
1080
1196
  prodDeploymentUrl: prod?.deploymentUrl
1081
1197
  })));
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)));
1198
+ const getById = (projectId) => getProjectById(api, projectId);
1083
1199
  /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
1084
1200
  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
1201
  team_id_or_slug: String(team),
1086
1202
  project_slug: slug
1087
1203
  } } }))).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
- });
1204
+ const findByName = (teamId, name) => findProjectByName(api, teamId, name);
1127
1205
  /**
1128
1206
  * Brings an existing project to the desired name. The team cannot
1129
1207
  * change, so a project in another team fails.
@@ -1229,17 +1307,11 @@ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1229
1307
  return yield* converge(existing, teamId, news.name);
1230
1308
  }
1231
1309
  }
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 });
1310
+ const details = yield* createProject(api, teamId, {
1311
+ name: news.name,
1312
+ deploymentType: news.deploymentType ?? "prod",
1313
+ deploymentRegion: news.deploymentRegion
1314
+ });
1243
1315
  return yield* converge(details, teamId, news.name);
1244
1316
  }),
1245
1317
  delete: Effect.fn(function* ({ output }) {
@@ -1258,11 +1330,15 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
1258
1330
  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
1331
  //#endregion
1260
1332
  //#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. */
1333
+ /** The name of the state project in each team. */
1334
+ const STORE_PROJECT_NAME = "alchemy-state";
1335
+ /**
1336
+ * The actions of the key of one run: the internal queries and mutations of
1337
+ * the store, nothing else. Measured: `deployment:data:view` and
1338
+ * `deployment:data:write` do not run an internal function.
1339
+ */
1340
+ const RUN_KEY_ACTIONS = ["deployment:functions:runInternalQueries", "deployment:functions:runInternalMutations"];
1341
+ /** The body of POST /api/function, with HTTP 200 for a function error too. */
1266
1342
  const Reply = Schema.Union([Schema.Struct({
1267
1343
  status: Schema.Literal("success"),
1268
1344
  value: Schema.Unknown
@@ -1270,11 +1346,10 @@ const Reply = Schema.Union([Schema.Struct({
1270
1346
  status: Schema.Literal("error"),
1271
1347
  errorMessage: Schema.String
1272
1348
  })]);
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
- }));
1349
+ /** The error reply of /api/function for a function that the deployment does not have (recorded). */
1350
+ const MISSING_FUNCTION = /Could not find function for '/;
1351
+ /** HTTP 401 or 403 from the function API: the key is gone or expired (recorded: 403 without a key). */
1352
+ const isUnauthorized = (error) => error.http?.status === 401 || error.http?.status === 403;
1278
1353
  const failure = (message) => new StateStoreError({ message: `Convex state: ${message}` });
1279
1354
  /** No reply, HTTP 429, or HTTP 5xx: a new attempt can succeed. Every call of the store is idempotent. */
1280
1355
  const isTransient = (error) => error.http !== void 0 && (error.http.status === void 0 || error.http.status === 429 || error.http.status >= 500);
@@ -1303,22 +1378,16 @@ const post = (url, headers, body) => Effect.tryPromise({
1303
1378
  times: 5
1304
1379
  }));
1305
1380
  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 },
1381
+ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/function`, { Authorization: `Convex ${Redacted.value(key)}` }, {
1313
1382
  path,
1314
1383
  args,
1315
1384
  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. */
1385
+ }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`))) });
1386
+ /** The value of a reply, or the Convex message, which names the function and the cause, such as a missing key action. */
1387
+ const valueOf = (reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim()));
1388
+ /** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
1320
1389
  const remoteOf = (api) => {
1321
- const run = (name, args) => api.call(`state:${name}`, args, COMPONENT_PATH);
1390
+ const run = (name, args) => api.call(`state:${name}`, args).pipe(Effect.flatMap(valueOf));
1322
1391
  const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
1323
1392
  const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.NullOr(Schema.String), name)));
1324
1393
  const encode = (value) => JSON.stringify(encodeState(value));
@@ -1364,84 +1433,134 @@ const remoteOf = (api) => {
1364
1433
  })
1365
1434
  };
1366
1435
  };
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.`);
1436
+ /** The version that the store answers, or undefined when the deployment has no `state:version`. */
1437
+ 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
1438
  /**
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.
1439
+ * The functions directory of the store app, `src/store/convex` of this
1440
+ * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
1441
+ * in the installed package, which ships `src` too, so the directory is
1442
+ * `../src/store/convex` from either place.
1372
1443
  */
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;
1444
+ const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
1445
+ /**
1446
+ * Pushes `store` to the production deployment of the state project with the
1447
+ * installed Convex CLI: no typecheck, no codegen, the key only in the
1448
+ * environment of the CLI.
1449
+ */
1450
+ const pushStore = (options, deployment) => Effect.gen(function* () {
1451
+ const cli = yield* installedCli(join(options.store, ".."));
1452
+ yield* Effect.logInfo(`Convex state: pushing the store version 1 to ${deployment.name}`);
1453
+ yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => push(options.spawner, key, {
1454
+ deployment: deployment.name,
1455
+ url: deployment.deploymentUrl,
1456
+ source: {
1457
+ path: options.store,
1458
+ command: [
1459
+ ...cli,
1460
+ "deploy",
1461
+ "--typecheck",
1462
+ "disable",
1463
+ "--codegen",
1464
+ "disable"
1465
+ ]
1466
+ },
1467
+ env: {},
1468
+ previousEnvNames: [],
1469
+ adopt: false,
1470
+ previewName: void 0
1471
+ }));
1472
+ });
1473
+ /**
1474
+ * Creates the state project of the team. Two first runs can both create one:
1475
+ * the run whose project is not the oldest deletes its own, which holds
1476
+ * nothing yet, and uses the oldest, so that every run ends on one store.
1477
+ */
1478
+ const createStoreProject = (api, teamId, team) => Effect.gen(function* () {
1479
+ yield* Effect.logInfo(`Convex state: creating the project ${STORE_PROJECT_NAME} in team ${team}`);
1480
+ const created = yield* createProject(api, teamId, {
1481
+ name: STORE_PROJECT_NAME,
1482
+ deploymentType: "prod",
1483
+ deploymentRegion: void 0
1412
1484
  });
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));
1485
+ const oldest = (yield* listProjectsByName(api, teamId, STORE_PROJECT_NAME)).reduce((a, b) => b.id < a.id ? b : a, created);
1486
+ if (oldest.id === created.id) return created;
1487
+ yield* Effect.logInfo(`Convex state: another run created ${STORE_PROJECT_NAME} ${oldest.id} first; project ${created.id} is deleted again.`);
1488
+ yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete project", (client) => client.POST("/projects/{project_id}/delete", { params: { path: { project_id: created.id } } }))));
1489
+ return oldest;
1490
+ });
1491
+ /**
1492
+ * The StateService over the `alchemy-state` project of the team: found or
1493
+ * created, its store pushed when it is missing or older, and read and
1494
+ * written with the keys of this run.
1495
+ */
1496
+ const makeConvexState = (options) => Effect.gen(function* () {
1497
+ const { api } = options;
1498
+ const teamId = yield* api.resolveTeamId(options.team);
1499
+ const team = options.team ?? teamId;
1500
+ const project = (yield* findProjectByName(api, teamId, "alchemy-state")) ?? (yield* createStoreProject(api, teamId, team));
1501
+ const deployment = yield* prodDeploymentOf(api, project.id);
1502
+ if (deployment === void 0) return yield* new ProductionDeploymentNotFound({ projectId: project.id });
1503
+ const keys = [];
1504
+ yield* Scope.addFinalizer(options.scope, Effect.suspend(() => Effect.forEach(keys, (key) => deleteDeployKey(api, deployment.name, key), { discard: true })));
1505
+ const mint = createDeployKey(api, deployment.name, "alchemy-state", RUN_KEY_ACTIONS).pipe(Effect.map((key) => {
1506
+ keys.push(key);
1507
+ return makeFunctionApi(deployment.deploymentUrl, key);
1508
+ }), Effect.mapError((error) => failure(`no key for ${deployment.name}: ${error.message}`)));
1509
+ let current = yield* mint;
1510
+ const functions = { call: (path, args) => current.call(path, args).pipe(Effect.catchIf(isUnauthorized, () => mint.pipe(Effect.flatMap((next) => {
1511
+ current = next;
1512
+ return next.call(path, args);
1513
+ })))) };
1514
+ const version = yield* storeVersion(functions);
1515
+ if (version === void 0 || version < 1) {
1516
+ 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.`);
1517
+ const now = yield* storeVersion(functions);
1518
+ if (now === void 0 || now < 1) {
1519
+ yield* pushStore(options, deployment);
1520
+ const pushed = yield* storeVersion(functions);
1521
+ if (pushed === void 0 || pushed < 1) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 1.`);
1522
+ }
1523
+ } 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.`);
1524
+ const remote = remoteOf(functions);
1415
1525
  return {
1416
1526
  id: "convex",
1417
1527
  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))
1528
+ listStacks: remote.listStacks,
1529
+ listStages: remote.listStages,
1530
+ list: remote.list,
1531
+ get: remote.get,
1532
+ getReplacedResources: remote.getReplacedResources,
1533
+ getOutput: remote.getOutput,
1534
+ set: (request) => remote.set(request).pipe(Effect.as(request.value)),
1535
+ delete: (request) => remote.delete(request).pipe(Effect.asVoid),
1536
+ deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
1537
+ setOutput: (request) => remote.setOutput(request).pipe(Effect.as(request.value))
1428
1538
  };
1429
1539
  });
1430
1540
  /**
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`.
1541
+ * Alchemy state in the `alchemy-state` project of `team`, with the team token
1542
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
1543
+ * its team, so `team` is optional with one; the Convex CLI login needs the
1544
+ * slug or the id. The first use of the run finds or creates the project and
1545
+ * pushes the store when it is missing or older. `alchemy plan` on another
1546
+ * stack never touches Convex.
1435
1547
  */
1436
- const state = () => Layer.effect(State, Effect.gen(function* () {
1548
+ const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
1549
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1550
+ const scope = yield* Scope.Scope;
1437
1551
  const context = yield* Effect.context();
1438
1552
  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));
1553
+ const api = yield* ManagementApi.pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
1554
+ return yield* makeConvexState({
1555
+ team: options.team,
1556
+ api,
1557
+ spawner,
1558
+ scope,
1559
+ store: storeDirectory()
1560
+ });
1442
1561
  }).pipe(Effect.provideContext(context), Effect.orDie));
1443
1562
  }));
1444
1563
  //#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 };
1564
+ 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
1565
 
1447
1566
  //# sourceMappingURL=index.js.map