@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/README.md +24 -16
- package/lib/index.d.ts +21 -10
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +277 -158
- package/lib/index.js.map +1 -1
- package/package.json +2 -3
- package/src/Deployment.ts +9 -2
- package/src/ManagementApi.ts +85 -3
- package/src/Project.ts +125 -88
- package/src/Push.ts +74 -46
- package/src/State.ts +281 -159
- package/src/index.ts +1 -1
- package/src/{component/_generated/api.ts → store/convex/_generated/api.d.ts} +9 -8
- package/src/store/convex/_generated/api.js +23 -0
- package/src/{component/_generated/server.ts → store/convex/_generated/server.d.ts} +18 -25
- package/src/store/convex/_generated/server.js +101 -0
- package/src/{component → store/convex}/state.ts +96 -46
- package/src/store/convex/version.ts +6 -0
- package/src/store/package.json +8 -0
- package/src/component/_generated/component.ts +0 -92
- package/src/component/convex.config.ts +0 -5
- /package/src/{component/_generated/dataModel.ts → store/convex/_generated/dataModel.d.ts} +0 -0
- /package/src/{component → store/convex}/schema.ts +0 -0
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 {
|
|
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
|
|
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
|
-
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
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
|
|
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
|
|
438
|
-
...
|
|
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
|
-
})
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
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
|
-
/**
|
|
1262
|
-
const
|
|
1263
|
-
/**
|
|
1264
|
-
|
|
1265
|
-
|
|
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
|
|
1274
|
-
const
|
|
1275
|
-
|
|
1276
|
-
|
|
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}`))
|
|
1317
|
-
/** The
|
|
1318
|
-
const
|
|
1319
|
-
/** The
|
|
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
|
|
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
|
-
|
|
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
|
|
1370
|
-
*
|
|
1371
|
-
*
|
|
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
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
const
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
}
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
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
|
|
1414
|
-
|
|
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:
|
|
1419
|
-
listStages:
|
|
1420
|
-
list:
|
|
1421
|
-
get:
|
|
1422
|
-
getReplacedResources:
|
|
1423
|
-
getOutput:
|
|
1424
|
-
set: (request) =>
|
|
1425
|
-
delete: (request) =>
|
|
1426
|
-
deleteStack: (request) =>
|
|
1427
|
-
setOutput: (request) =>
|
|
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
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
1434
|
-
*
|
|
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
|
|
1440
|
-
|
|
1441
|
-
|
|
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,
|
|
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
|