@samebase/alchemy-convex 0.14.1 → 0.15.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.
@@ -0,0 +1,1938 @@
1
+ import { createRequire } from "node:module";
2
+ import { Stack } from "alchemy/Stack";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Schema from "effect/Schema";
5
+ import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { dirname, join, posix, relative, resolve, sep, win32 } from "node:path";
8
+ import * as Config from "effect/Config";
9
+ import * as Context from "effect/Context";
10
+ import * as Layer from "effect/Layer";
11
+ import * as Option from "effect/Option";
12
+ import * as Redacted from "effect/Redacted";
13
+ import * as Result from "effect/Result";
14
+ import { Resource } from "alchemy";
15
+ import { AdoptPolicy, OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
16
+ import { diffBindings, isResolved } from "alchemy/Diff";
17
+ import * as Provider from "alchemy/Provider";
18
+ import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
19
+ import { AlchemyContext } from "alchemy/AlchemyContext";
20
+ import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
21
+ import * as Schedule from "effect/Schedule";
22
+ import { createHash } from "node:crypto";
23
+ import { promisify, stripVTControlCharacters } from "node:util";
24
+ import { hashDirectory } from "alchemy/Command/Memo";
25
+ import * as Clock from "effect/Clock";
26
+ import * as ChildProcess from "effect/process/ChildProcess";
27
+ import * as Stream from "effect/Stream";
28
+ import { fileURLToPath } from "node:url";
29
+ import * as Scope from "effect/Scope";
30
+ import { STATE_STORE_VERSION, State, StateStoreError, encodeState, reviveState } from "alchemy/State";
31
+ import { execFile } from "node:child_process";
32
+ //#region src/Credentials.ts
33
+ var Credentials = class extends Context.Service()("@samebase/alchemy-convex/Credentials") {};
34
+ /** Environment variable for the access token, read by {@link fromConfig}. */
35
+ const ACCESS_TOKEN_ENV = "CONVEX_ACCESS_TOKEN";
36
+ /** Environment variable for the deploy key, read by {@link fromConfig}. The Convex CLI reads the same name. */
37
+ const DEPLOY_KEY_ENV = "CONVEX_DEPLOY_KEY";
38
+ /** The Convex CLI stores its device login here after `npx convex login`. */
39
+ const convexCliConfigPath = () => join(homedir(), ".convex", "config.json");
40
+ const CliConfig = Schema.Struct({ accessToken: Schema.String });
41
+ var CredentialsError = class extends Schema.TaggedError()("CredentialsError", { reason: Schema.String }) {};
42
+ /**
43
+ * Token the Convex CLI saved on this machine. It works on the Management API
44
+ * for the teams the logged-in user belongs to. Fails when nobody is logged in.
45
+ */
46
+ const readConvexCliLogin = Effect.gen(function* () {
47
+ const path = convexCliConfigPath();
48
+ const text = yield* Effect.tryPromise({
49
+ try: () => readFile(path, "utf8"),
50
+ catch: () => new CredentialsError({ reason: `This step needs the Convex Management API. Set ${ACCESS_TOKEN_ENV} to a team access token, or run \`npx convex login\`. There is no Convex CLI login at ${path}.` })
51
+ });
52
+ const parsed = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(CliConfig))(text).pipe(Effect.mapError(() => new CredentialsError({ reason: `${path} has no accessToken` })));
53
+ return {
54
+ accessToken: Redacted.make(parsed.accessToken),
55
+ source: "convex-cli-login"
56
+ };
57
+ });
58
+ /**
59
+ * Default resolution through the stack ConfigProvider (process env, `.env`,
60
+ * stack secrets): `CONVEX_ACCESS_TOKEN`, then the Convex CLI login on this
61
+ * machine, for the access token; `CONVEX_DEPLOY_KEY` for the deploy key. A
62
+ * developer who ran `npx convex login` needs nothing. CI sets only the
63
+ * deploy key when the stack pushes to deployments that exist, and the access
64
+ * token when the stack also creates projects or deployments.
65
+ */
66
+ const fromConfig = () => Layer.effect(Credentials, Effect.gen(function* () {
67
+ const resolved = yield* Effect.cached(Config.Redacted(ACCESS_TOKEN_ENV).pipe(Effect.map((token) => ({
68
+ accessToken: token,
69
+ source: "env"
70
+ })), Effect.catch(() => readConvexCliLogin), Effect.result));
71
+ const deployKey = yield* Effect.cached(Config.option(Config.Redacted(DEPLOY_KEY_ENV)).pipe(Effect.map(Option.getOrUndefined), Effect.orDie));
72
+ return {
73
+ accessToken: resolved.pipe(Effect.flatMap(Effect.fromResult), Effect.orDie),
74
+ hasAccessToken: resolved.pipe(Effect.map(Result.isSuccess)),
75
+ deployKey
76
+ };
77
+ }));
78
+ /** Explicit values, for tests or callers that already hold them. */
79
+ const fromValues = (values) => Layer.succeed(Credentials, {
80
+ accessToken: values.accessToken === void 0 ? Effect.die(new CredentialsError({ reason: "This step needs the Convex Management API, and fromValues got no accessToken." })) : Effect.succeed({
81
+ accessToken: values.accessToken,
82
+ source: "explicit"
83
+ }),
84
+ hasAccessToken: Effect.succeed(values.accessToken !== void 0),
85
+ deployKey: Effect.succeed(values.deployKey)
86
+ });
87
+ //#endregion
88
+ //#region src/Adoption.ts
89
+ /** True when the resource `fqn` may take over an object that is not in state. */
90
+ const shouldAdopt = (fqn) => Effect.gen(function* () {
91
+ const stack = yield* Effect.serviceOption(Stack);
92
+ const own = Option.isSome(stack) ? stack.value.resources[fqn]?.Adopt : void 0;
93
+ if (own !== void 0) return own;
94
+ const policy = yield* Effect.serviceOption(AdoptPolicy);
95
+ if (Option.isSome(policy)) return policy.value;
96
+ const context = yield* Effect.serviceOption(AlchemyContext);
97
+ return Option.isSome(context) && context.value.adopt;
98
+ });
99
+ //#endregion
100
+ //#region src/ManagementApi.ts
101
+ /**
102
+ * One error for every control-plane and deployment call. Resources branch on
103
+ * `status` (404 means absent for read and delete), so the class is load
104
+ * bearing, not decorative.
105
+ */
106
+ var ConvexApiError = class extends Schema.TaggedError()("ConvexApiError", {
107
+ operation: Schema.String,
108
+ status: Schema.Number,
109
+ code: Schema.optionalKey(Schema.String),
110
+ message: Schema.String
111
+ }) {};
112
+ const ErrorBody = Schema.Struct({
113
+ code: Schema.optionalKey(Schema.String),
114
+ message: Schema.optionalKey(Schema.String)
115
+ });
116
+ const toApiError = (operation, result) => {
117
+ const body = Schema.decodeUnknownSync(Schema.NullishOr(ErrorBody))(result.error ?? null);
118
+ const code = body?.code;
119
+ return new ConvexApiError({
120
+ operation,
121
+ status: result.response.status,
122
+ ...code === void 0 ? {} : { code },
123
+ message: body?.message ?? `${operation} failed with HTTP ${result.response.status}`
124
+ });
125
+ };
126
+ /** The part of a Convex token after the first "|", or the whole value when it has none. */
127
+ const tokenPart = (value) => value.slice(value.indexOf("|") + 1);
128
+ var ManagementApi = class extends Context.Service()("@samebase/alchemy-convex/ManagementApi") {};
129
+ const attempt = (operation, run) => Effect.tryPromise({
130
+ try: run,
131
+ catch: (cause) => new ConvexApiError({
132
+ operation,
133
+ status: 0,
134
+ message: cause instanceof Error ? cause.message : String(cause)
135
+ })
136
+ });
137
+ /** Reads `data` or fails with the HTTP status and the API's own code and message. */
138
+ const settle = (operation, run) => attempt(operation, run).pipe(Effect.flatMap((result) => result.response.ok && result.data !== void 0 ? Effect.succeed(result.data) : Effect.fail(toApiError(operation, result))));
139
+ /** For endpoints that answer with an empty body, such as delete calls. */
140
+ const settleVoid = (operation, run) => attempt(operation, run).pipe(Effect.flatMap((result) => result.response.ok ? Effect.void : Effect.fail(toApiError(operation, result))));
141
+ /**
142
+ * True when an idempotent write can succeed on a new attempt:
143
+ *
144
+ * - A Convex write conflict. Two parallel update_environment_variables calls
145
+ * on one deployment gave HTTP 503 `OptimisticConcurrencyControlFailure`,
146
+ * "Data read or written in this mutation changed while it was being run",
147
+ * in a live run. The platform codes `OCC` and `WriteConflict` and the
148
+ * message "... changed while this mutation was being run ..." mean the same.
149
+ * - Any other 5xx answer. Two parallel delete_preview_deploy_key calls on one
150
+ * project gave HTTP 500 `InternalServerError`, "Your request couldn't be
151
+ * completed. Try again later.", in a live run. The next attempt succeeded.
152
+ */
153
+ const isRetryableWriteError = (error) => error.code === "OptimisticConcurrencyControlFailure" || error.code === "OCC" || error.code === "WriteConflict" || /changed while (it|this mutation) was being run/.test(error.message) || error.status >= 500;
154
+ /**
155
+ * Runs an idempotent write again after a retryable Convex error: at most
156
+ * four more attempts, with exponential backoff from 100 ms and jitter. Other
157
+ * errors fail at once, and so does the last retryable error. Use it only for
158
+ * a write that has the same result when it runs twice, such as setting a
159
+ * value or deleting by id. Never for a create that does not check first.
160
+ */
161
+ const retryIdempotentWrite = (effect) => effect.pipe(Effect.retry({
162
+ while: (error) => error instanceof ConvexApiError && isRetryableWriteError(error),
163
+ times: 4,
164
+ schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered)
165
+ }));
166
+ /** Turns a 404 into `undefined` so read and delete stay idempotent. */
167
+ const absentAsUndefined = (effect) => effect.pipe(Effect.catchIf((error) => error.status === 404, () => Effect.succeed(void 0)));
168
+ const TeamRow = Schema.Struct({
169
+ id: Schema.Number,
170
+ slug: Schema.String
171
+ });
172
+ const TeamRows = Schema.Array(TeamRow);
173
+ /** The first page of GET /teams/{team_id}/projects: every row carries the slug of the team. */
174
+ const ProjectSlugRows = Schema.Struct({ items: Schema.Array(Schema.Struct({ teamSlug: Schema.String })) });
175
+ /** GET /token_details: the team of a team token, or the project of a project token. */
176
+ const TokenDetails = Schema.Union([Schema.Struct({
177
+ type: Schema.Literal("teamToken"),
178
+ teamId: Schema.Number
179
+ }), Schema.Struct({
180
+ type: Schema.Literal("projectToken"),
181
+ projectId: Schema.Number
182
+ })]);
183
+ /** The service for one way to get the bearer token. */
184
+ const makeService = (credentials) => {
185
+ const client = Effect.cached(credentials.pipe(Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))))).pipe(Effect.flatten);
186
+ const request = (operation, run) => client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
187
+ const requestVoid = (operation, run) => client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
188
+ const deploymentRequest = (operation, deploymentUrlOrName, deployKey, run) => settle(operation, () => run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))));
189
+ 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)))));
190
+ /** The slug of the team, from the first project of its listing, or undefined for a team without one. Read once. */
191
+ 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));
192
+ const resolveTeamId = (team) => Effect.gen(function* () {
193
+ if (typeof team === "number") return team;
194
+ if (team !== void 0 && /^\d+$/.test(team)) return Number(team);
195
+ const details = yield* tokenDetails;
196
+ if (details?.type === "teamToken") {
197
+ if (team === void 0) return details.teamId;
198
+ const slug = yield* teamSlug(details.teamId);
199
+ if (slug === void 0) return yield* new ConvexApiError({
200
+ operation: "resolve team",
201
+ status: 0,
202
+ 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.`
203
+ });
204
+ if (slug !== team) return yield* new ConvexApiError({
205
+ operation: "resolve team",
206
+ status: 403,
207
+ 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}.`
208
+ });
209
+ return details.teamId;
210
+ }
211
+ if (details?.type === "projectToken") return yield* new ConvexApiError({
212
+ operation: "resolve team",
213
+ status: 403,
214
+ message: `the access token belongs to project ${details.projectId}, and a project token cannot create projects. Use a team access token.`
215
+ });
216
+ if (team === void 0) return yield* new ConvexApiError({
217
+ operation: "resolve team",
218
+ status: 0,
219
+ message: "set team to the slug or the id of the Convex team: this access token does not name a team."
220
+ });
221
+ const resolved = yield* credentials;
222
+ const match = (yield* Effect.tryPromise({
223
+ try: async () => {
224
+ const response = await fetch("https://api.convex.dev/api/dashboard/teams", { headers: { Authorization: `Bearer ${Redacted.value(resolved.accessToken)}` } });
225
+ if (!response.ok) throw new Error(`team lookup for "${team}" failed with HTTP ${response.status}`);
226
+ return Schema.decodeUnknownSync(TeamRows)(await response.json());
227
+ },
228
+ catch: (cause) => new ConvexApiError({
229
+ operation: "resolve team",
230
+ status: 0,
231
+ message: cause instanceof Error ? cause.message : String(cause)
232
+ })
233
+ })).find((row) => row.slug === team);
234
+ if (match === void 0) return yield* new ConvexApiError({
235
+ operation: "resolve team",
236
+ status: 404,
237
+ message: `no team with slug "${team}" for this token`
238
+ });
239
+ return match.id;
240
+ });
241
+ const isCredentialToken = (secret) => credentials.pipe(Effect.map((resolved) => tokenPart(Redacted.value(resolved.accessToken)) === tokenPart(secret)));
242
+ const withToken = (token) => makeService(Effect.succeed({
243
+ accessToken: token,
244
+ source: "explicit"
245
+ }));
246
+ return {
247
+ request,
248
+ requestVoid,
249
+ deploymentRequest,
250
+ resolveTeamId,
251
+ isCredentialToken,
252
+ withToken
253
+ };
254
+ };
255
+ const ManagementApiLive = () => Layer.effect(ManagementApi, Effect.gen(function* () {
256
+ const credentials = yield* Credentials;
257
+ return makeService(credentials.accessToken);
258
+ }));
259
+ //#endregion
260
+ //#region src/Push.ts
261
+ const sourceOf = (source) => typeof source === "string" ? {
262
+ path: source,
263
+ command: void 0,
264
+ include: void 0
265
+ } : {
266
+ path: source.path,
267
+ command: source.command,
268
+ include: source.include
269
+ };
270
+ /** Lifetime of a push key. Long enough for a slow push with index backfills. */
271
+ const PUSH_KEY_LIFETIME_MS = 72e5;
272
+ /** The only actions a key for `convex deploy` gets. */
273
+ const PUSH_KEY_ACTIONS = [
274
+ "deployment:deploy",
275
+ "deployment:env:view",
276
+ "deployment:env:write"
277
+ ];
278
+ /** POST /deployments/{deployment_name}/create_deploy_key */
279
+ const CreatedDeployKey = Schema.Struct({ deployKey: Schema.String });
280
+ /** GET https://<deployment>.convex.cloud/api/v1/list_environment_variables */
281
+ const EnvironmentVariableList = Schema.Struct({ environmentVariables: Schema.Record(Schema.String, Schema.String) });
282
+ /** No `convex` package resolves from the app directory. */
283
+ var ConvexCliNotFound = class extends Schema.TaggedError()("ConvexCliNotFound", { cwd: Schema.String }) {
284
+ get message() {
285
+ return `Convex.Deployment found no installed convex package from ${this.cwd}, and it does not download one. Install convex in the app (npm install convex), or set rootDir to the directory of the app.`;
286
+ }
287
+ };
288
+ /** The app directory (`rootDir`) has no package.json. */
289
+ var ConvexAppNotFound = class extends Schema.TaggedError()("ConvexAppNotFound", {
290
+ /** The app directory, resolved against the working directory of the deploy. */
291
+ rootDir: Schema.String }) {
292
+ get message() {
293
+ return `Convex.Deployment found no package.json in ${this.rootDir}. Set rootDir to the directory of the Convex app, relative to the directory where the deploy runs, such as "apps/web".`;
294
+ }
295
+ };
296
+ /** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
297
+ var CodePushFailed = class extends Schema.TaggedError()("CodePushFailed", {
298
+ /** Exit code of the CLI. Undefined when it did not start or a signal stopped it. */
299
+ exitCode: Schema.UndefinedOr(Schema.Number),
300
+ /** The last lines of the CLI output, with the deploy key removed. */
301
+ outputTail: Schema.String
302
+ }) {
303
+ get message() {
304
+ return this.exitCode === 0 ? `convex deploy exited 0 but printed no "Deployed Convex functions to <url>" line. Last output, with the deploy key removed:\n${this.outputTail}` : `convex deploy failed${this.exitCode === void 0 ? "" : ` with exit code ${this.exitCode}`}. Last output, with the deploy key removed:\n${this.outputTail}`;
305
+ }
306
+ };
307
+ /** The CLI pushed to another deployment than the one of the resource. Checked after the push. */
308
+ var PushTargetMismatch = class extends Schema.TaggedError()("PushTargetMismatch", {
309
+ expected: Schema.String,
310
+ actual: Schema.String
311
+ }) {
312
+ get message() {
313
+ return `The Convex CLI pushed the functions to deployment ${this.actual}, but Convex.Deployment expected ${this.expected}. Check that convex.json does not select another deployment.`;
314
+ }
315
+ };
316
+ /** Variables in `env` exist on the deployment with other values, and adoption is off. */
317
+ var VariablesOwnedBySomeoneElse = class extends Schema.TaggedError()("VariablesOwnedBySomeoneElse", {
318
+ deployment: Schema.String,
319
+ names: Schema.Array(Schema.String)
320
+ }) {
321
+ get message() {
322
+ return `Environment variables ${this.names.join(", ")} already exist on deployment ${this.deployment} with other values. Re-run with --adopt to take them over and overwrite them, or remove them from env.`;
323
+ }
324
+ };
325
+ /** A preview deploy key cannot set variables: the deployment API takes only a deploy key of the deployment. */
326
+ var EnvironmentNeedsDeployKey = class extends Schema.TaggedError()("EnvironmentNeedsDeployKey", { deployment: Schema.String }) {
327
+ get message() {
328
+ return `Convex.Deployment cannot set or remove env variables on preview deployment ${this.deployment} with a preview deploy key: Convex lets that key create, push to, and delete preview deployments, but not change their variables. Set CONVEX_ACCESS_TOKEN for this stage, or set the values as project default environment variables for preview deployments in the Convex dashboard.`;
329
+ }
330
+ };
331
+ /**
332
+ * What a deploy key targets, from its prefix: `<type>:<deployment>|<secret>`
333
+ * (or `<deployment>|<secret>` in old keys) for one deployment, and
334
+ * `preview:<team>:<project>|<secret>` for the preview deployments of a
335
+ * project. Same rules as convex 1.46 `cli/lib/deployment.js`.
336
+ */
337
+ const keyTargetOf = (key) => {
338
+ const bar = key.indexOf("|");
339
+ if (bar <= 0 || /[\s#'"`]/.test(key)) return { kind: "other" };
340
+ const [first, second, third, ...rest] = key.slice(0, bar).split(":");
341
+ if (rest.length > 0) return { kind: "other" };
342
+ if (first === "preview" && second && third) return {
343
+ kind: "preview",
344
+ team: second,
345
+ project: third
346
+ };
347
+ if (third !== void 0) return { kind: "other" };
348
+ if (second) return {
349
+ kind: "deployment",
350
+ deployment: second
351
+ };
352
+ if (first && second === void 0) return {
353
+ kind: "deployment",
354
+ deployment: first
355
+ };
356
+ return { kind: "other" };
357
+ };
358
+ /**
359
+ * The CLI prints this line with `logFinishedStep` after the push succeeds, to
360
+ * stderr (convex 1.46 `cli/lib/deploy2.js`). The host is
361
+ * `<deployment>.convex.cloud` or `<deployment>.<region>.convex.cloud`.
362
+ */
363
+ const DEPLOYED_LINE = /Deployed Convex functions to (https:\/\/([a-z0-9]+(?:-[a-z0-9]+)*)(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud)\/?(?=\s|$)/m;
364
+ /** Reads the deployment URL from `convex deploy` output. ANSI escape codes are removed first. */
365
+ const parseDeployOutput = (text) => {
366
+ const match = DEPLOYED_LINE.exec(stripVTControlCharacters(text));
367
+ const url = match?.[1];
368
+ const deploymentName = match?.[2];
369
+ return url === void 0 || deploymentName === void 0 ? void 0 : {
370
+ url,
371
+ deploymentName
372
+ };
373
+ };
374
+ /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
375
+ 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");
376
+ const valueOf$1 = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
377
+ /**
378
+ * The app directory: `rootDir` resolved against the working directory of the
379
+ * deploy. It must have a package.json. There is no search above it.
380
+ */
381
+ const appDirectoryOf = (rootDir) => {
382
+ const directory = resolve(rootDir);
383
+ return Effect.tryPromise({
384
+ try: () => access(join(directory, "package.json")).then(() => directory),
385
+ catch: () => new ConvexAppNotFound({ rootDir: directory })
386
+ });
387
+ };
388
+ /** `source` is not the functions directory that the Convex CLI pushes from the app directory. */
389
+ var SourceMismatch = class extends Schema.TaggedError()("SourceMismatch", {
390
+ /** The app directory, resolved. */
391
+ rootDir: Schema.String,
392
+ source: Schema.String,
393
+ functions: Schema.String
394
+ }) {
395
+ get message() {
396
+ return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} from the app directory ${this.rootDir} (the "functions" field of convex.json, or convex/ by default). Set rootDir to the directory of the Convex app, and source to its functions directory relative to rootDir, such as "./convex".`;
397
+ }
398
+ };
399
+ const ConvexJson = Schema.Struct({ functions: Schema.optionalKey(Schema.String) });
400
+ /** Fails with {@link SourceMismatch} when `path` is not the functions directory of the app in `cwd`. */
401
+ const checkFunctionsDirectory = (cwd, path) => Effect.gen(function* () {
402
+ const text = yield* Effect.promise(() => readFile(join(cwd, "convex.json"), "utf8").catch(() => void 0));
403
+ const configured = text === void 0 ? void 0 : Schema.decodeUnknownSync(Schema.fromJsonString(ConvexJson))(text).functions;
404
+ const functions = resolve(cwd, configured ?? "convex");
405
+ if (functions !== path) return yield* new SourceMismatch({
406
+ rootDir: cwd,
407
+ source: path,
408
+ functions
409
+ });
410
+ });
411
+ const CliManifest = Schema.Struct({ bin: Schema.Union([Schema.String, Schema.Record(Schema.String, Schema.String)]) });
412
+ /** `[node, <convex bin>]` for the `convex` package that `cwd` resolves. Never downloads. */
413
+ const installedCli = (cwd) => Effect.tryPromise({
414
+ try: async () => {
415
+ const manifestPath = createRequire(join(cwd, "package.json")).resolve("convex/package.json");
416
+ const { bin } = Schema.decodeUnknownSync(Schema.fromJsonString(CliManifest))(await readFile(manifestPath, "utf8"));
417
+ const entry = typeof bin === "string" ? bin : bin["convex"];
418
+ if (entry === void 0) throw new Error("the convex package has no convex bin");
419
+ return [process.execPath, resolve(dirname(manifestPath), entry)];
420
+ },
421
+ catch: () => new ConvexCliNotFound({ cwd })
422
+ });
423
+ /**
424
+ * The content identity of a push: the files of the app directory that git
425
+ * does not ignore (or `path`, the manifests, and the `include` globs), the
426
+ * nearest lockfile, the target deployment, and the `env` names and values.
427
+ * A change in a workspace package outside the app directory is not seen.
428
+ */
429
+ const pushHash = (options) => Effect.gen(function* () {
430
+ const source = options.source === void 0 ? void 0 : sourceOf(options.source);
431
+ const files = source === void 0 ? void 0 : yield* appDirectoryOf(options.rootDir).pipe(Effect.flatMap((cwd) => {
432
+ const functions = relative(cwd, resolve(cwd, source.path)).split("\\").join("/");
433
+ return hashDirectory({
434
+ cwd,
435
+ memo: source.include !== void 0 ? {
436
+ include: [
437
+ `${functions}/**`,
438
+ "package.json",
439
+ "convex.json",
440
+ ...source.include
441
+ ],
442
+ lockfile: true
443
+ } : functions === ".." || functions.startsWith("../") ? {
444
+ include: ["**/*", `${functions}/**`],
445
+ lockfile: true
446
+ } : {}
447
+ });
448
+ }));
449
+ const env = Object.entries(options.env).map(([name, value]) => [name, valueOf$1(value)]).sort(([a], [b]) => a < b ? -1 : 1);
450
+ return createHash("sha256").update(JSON.stringify({
451
+ files,
452
+ deployment: options.deployment,
453
+ env
454
+ })).digest("hex");
455
+ });
456
+ /**
457
+ * Runs `use` with an env file that holds the key, in a new temporary
458
+ * directory, and removes the directory afterwards: on success, on failure,
459
+ * and on interruption.
460
+ */
461
+ const withKeyFile = (key, use) => Effect.acquireUseRelease(Effect.promise(() => mkdtemp(join(tmpdir(), "alchemy-convex-"))), (home) => Effect.promise(() => writeFile(join(home, "deploy.env"), `CONVEX_DEPLOY_KEY=${key}\n`, { mode: 384 })).pipe(Effect.andThen(use({
462
+ envFile: join(home, "deploy.env"),
463
+ home
464
+ }))), (home) => Effect.promise(() => rm(home, {
465
+ recursive: true,
466
+ force: true
467
+ })));
468
+ /** This process's variables without `CONVEX_*`, with `home` as the home directory. */
469
+ const cliEnvironment = (home) => ({
470
+ ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("CONVEX_"))),
471
+ HOME: home,
472
+ USERPROFILE: home,
473
+ XDG_CONFIG_HOME: home
474
+ });
475
+ /**
476
+ * A deploy key of `deployment` that expires after PUSH_KEY_LIFETIME_MS, with
477
+ * only `allowedActions`, or every action when that is undefined. Delete it
478
+ * with {@link deleteDeployKey} when the work is done.
479
+ */
480
+ const createDeployKey = (api, deployment, name, allowedActions) => Effect.gen(function* () {
481
+ const now = yield* Clock.currentTimeMillis;
482
+ const created = yield* api.request("create deploy key", (client) => client.POST("/deployments/{deployment_name}/create_deploy_key", {
483
+ params: { path: { deployment_name: deployment } },
484
+ body: {
485
+ name,
486
+ ...allowedActions === void 0 ? {} : { allowedActions: [...allowedActions] },
487
+ expiresAt: now + PUSH_KEY_LIFETIME_MS
488
+ }
489
+ }));
490
+ return Redacted.make(Schema.decodeUnknownSync(CreatedDeployKey)(created).deployKey);
491
+ });
492
+ /**
493
+ * Deletes a key that {@link createDeployKey} made. The delete is skipped for
494
+ * a key that is the OAuth credential. A failed delete is logged: the key
495
+ * expires anyway.
496
+ */
497
+ const deleteDeployKey = (api, deployment, key) => Effect.gen(function* () {
498
+ if (yield* api.isCredentialToken(Redacted.value(key))) return;
499
+ yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete deploy key", (client) => client.POST("/deployments/{deployment_name}/delete_deploy_key", {
500
+ params: { path: { deployment_name: deployment } },
501
+ body: { id: Redacted.value(key) }
502
+ }))));
503
+ }).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.`)));
504
+ /**
505
+ * Creates a deploy key for one push, runs `use` with it, and deletes it
506
+ * afterwards, also after a failure. `allowedActions` is PUSH_KEY_ACTIONS for
507
+ * `convex deploy`, or undefined for a push command, which can do more than a
508
+ * push, such as the uploads of convex-hosting through `convex run`.
509
+ */
510
+ const withPushKey = (api, deployment, allowedActions, use) => Effect.acquireUseRelease(createDeployKey(api, deployment, "alchemy-push", allowedActions), use, (key) => deleteDeployKey(api, deployment, key));
511
+ /** Every variable of the deployment with its value. */
512
+ 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));
513
+ /** One call that sets variables, or removes those whose value is null. */
514
+ const writeVariables = (url, key, changes) => settleVoid("update environment variables", () => createDeploymentClient(url, Redacted.value(key)).POST("/update_environment_variables", { body: { changes: changes.map(({ name, value }) => ({
515
+ name,
516
+ value
517
+ })) } }));
518
+ /**
519
+ * The app directory and the program of a push. Fails before any change when
520
+ * `rootDir` has no package.json, when `source` is not the functions directory
521
+ * of the app, or when the app has no installed Convex CLI and no push command.
522
+ */
523
+ const checkSource = (rootDir, input) => Effect.gen(function* () {
524
+ const source = sourceOf(input);
525
+ const cwd = yield* appDirectoryOf(rootDir);
526
+ yield* checkFunctionsDirectory(cwd, resolve(cwd, source.path));
527
+ return {
528
+ cwd,
529
+ program: source.command ?? (yield* installedCli(cwd))
530
+ };
531
+ });
532
+ /**
533
+ * Sets the `env` variables and pushes the functions of `source` to one
534
+ * deployment with `key`: a deploy key of the deployment, or a preview deploy
535
+ * key of its project with `previewName`. Returns the URL that the CLI
536
+ * printed, or undefined without `source`.
537
+ */
538
+ const push = (spawner, key, options) => Effect.gen(function* () {
539
+ const source = options.source === void 0 ? void 0 : sourceOf(options.source);
540
+ const resolved = options.source === void 0 ? void 0 : yield* checkSource(options.rootDir, options.source);
541
+ const cwd = resolved?.cwd;
542
+ const program = resolved?.program;
543
+ const env = Object.entries(options.env).sort(([a], [b]) => a < b ? -1 : 1);
544
+ const removed = options.previousEnvNames.filter((name) => !Object.hasOwn(options.env, name));
545
+ const written = env.length === 0 && removed.length === 0 ? {
546
+ current: {},
547
+ changes: []
548
+ } : yield* retryIdempotentWrite(Effect.gen(function* () {
549
+ const current = yield* listVariables(options.url, key);
550
+ if (!options.adopt) {
551
+ const taken = env.filter(([name, value]) => !options.previousEnvNames.includes(name) && Object.hasOwn(current, name) && current[name] !== valueOf$1(value)).map(([name]) => name);
552
+ if (taken.length > 0) return yield* new VariablesOwnedBySomeoneElse({
553
+ deployment: options.deployment,
554
+ names: taken
555
+ });
556
+ }
557
+ const changes = [...env.filter(([name, value]) => current[name] !== valueOf$1(value)).map(([name, value]) => ({
558
+ name,
559
+ value: valueOf$1(value)
560
+ })), ...removed.filter((name) => Object.hasOwn(current, name)).map((name) => ({
561
+ name,
562
+ value: null
563
+ }))];
564
+ if (changes.length > 0) yield* writeVariables(options.url, key, changes);
565
+ return {
566
+ current,
567
+ changes
568
+ };
569
+ }));
570
+ /**
571
+ * After a failed push, the variables go back to their earlier values, so
572
+ * that the deployment matches the hash in state. A failed rollback is
573
+ * logged: the push error is the one to report.
574
+ */
575
+ const rollback = written.changes.length === 0 ? Effect.void : retryIdempotentWrite(writeVariables(options.url, key, written.changes.map(({ name }) => ({
576
+ name,
577
+ value: Object.hasOwn(written.current, name) ? written.current[name] ?? null : null
578
+ })))).pipe(Effect.catch((error) => Effect.logWarning(`Convex.Deployment could not set the env variables of ${options.deployment} back after the failed push (${error.message}). Run the deploy again.`)));
579
+ if (source === void 0 || cwd === void 0 || program === void 0) return void 0;
580
+ return yield* runPush(spawner, key, {
581
+ ...options,
582
+ cwd,
583
+ program,
584
+ command: source.command
585
+ }).pipe(Effect.onError(() => rollback));
586
+ });
587
+ /** Runs the installed CLI or the push command once, and checks the deployment that it names. */
588
+ const runPush = (spawner, key, options) => Effect.gen(function* () {
589
+ const { cwd, program } = options;
590
+ const secret = Redacted.value(key);
591
+ const secrets = [secret, secret.slice(secret.indexOf("|") + 1)].filter((value) => value.length >= 8);
592
+ const result = yield* withKeyFile(secret, ({ envFile, home }) => Effect.gen(function* () {
593
+ const [command, ...programArgs] = program;
594
+ const args = options.command === void 0 ? [
595
+ ...programArgs,
596
+ "deploy",
597
+ ...options.previewName === void 0 ? [] : ["--preview-name", options.previewName],
598
+ "--env-file",
599
+ envFile
600
+ ] : programArgs;
601
+ const child = yield* spawner.spawn(ChildProcess.make(command, args, {
602
+ cwd,
603
+ env: {
604
+ ...cliEnvironment(home),
605
+ ...options.command === void 0 ? {} : { CONVEX_DEPLOY_KEY: secret }
606
+ },
607
+ extendEnv: false,
608
+ stdin: "ignore",
609
+ stdout: "pipe",
610
+ stderr: "pipe"
611
+ }));
612
+ const [exitCode, stdout, stderr] = yield* Effect.all([
613
+ child.exitCode,
614
+ Stream.mkString(Stream.decodeText(child.stdout)),
615
+ Stream.mkString(Stream.decodeText(child.stderr))
616
+ ], { concurrency: "unbounded" });
617
+ return {
618
+ exitCode: Number(exitCode),
619
+ output: `${stdout}\n${stderr}`
620
+ };
621
+ }).pipe(Effect.scoped)).pipe(Effect.mapError((error) => new CodePushFailed({
622
+ exitCode: void 0,
623
+ outputTail: outputTail(error.message, secrets)
624
+ })));
625
+ const deployed = parseDeployOutput(result.output);
626
+ if (result.exitCode !== 0 || deployed === void 0) return yield* new CodePushFailed({
627
+ exitCode: result.exitCode,
628
+ outputTail: outputTail(result.output, secrets)
629
+ });
630
+ if (deployed.deploymentName !== options.deployment) return yield* new PushTargetMismatch({
631
+ expected: options.deployment,
632
+ actual: deployed.deploymentName
633
+ });
634
+ return deployed.url;
635
+ });
636
+ //#endregion
637
+ //#region src/Deployment.ts
638
+ /** The resource class. Stacks call {@link Deployment}, which writes `rootDir` into the props. */
639
+ const DeploymentResource = Resource("Convex.Deployment", { defaultRemovalPolicy: "retain" });
640
+ /** `rootDir` in POSIX form, without "./" and a trailing slash, such as `apps/web` or ".". */
641
+ const normalizeRootDir = (rootDir) => {
642
+ const normalized = posix.normalize(rootDir.replaceAll("\\", "/"));
643
+ return normalized.length > 1 && normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
644
+ };
645
+ /** `rootDir` is an absolute path. The declaration stops with it as a defect, before the plan. */
646
+ var AbsoluteRootDir = class extends Schema.TaggedError()("AbsoluteRootDir", { rootDir: Schema.String }) {
647
+ get message() {
648
+ return `Convex.Deployment rootDir ${this.rootDir} is an absolute path. State keeps rootDir for every machine that runs the stack, so set it relative to the directory where the deploy runs, such as "apps/web".`;
649
+ }
650
+ };
651
+ /**
652
+ * `Convex.Deployment("id", props)`. The props go to state with `rootDir` in
653
+ * POSIX form, and with "." when it is not set, so that every row names the
654
+ * app directory of its deployment. An absolute `rootDir` stops the
655
+ * declaration, so it never reaches the plan or state. The check dies,
656
+ * because the program of `Alchemy.Stack` can fail only with a ConfigError.
657
+ * `win32.isAbsolute` also answers true for a POSIX absolute path. The other
658
+ * fields keep it usable where Alchemy takes a resource class, such as
659
+ * `Provider.collection`.
660
+ */
661
+ const Deployment = Object.assign((id, props) => {
662
+ const rootDir = props.rootDir ?? ".";
663
+ return win32.isAbsolute(rootDir) ? Effect.die(new AbsoluteRootDir({ rootDir })) : DeploymentResource(id, {
664
+ ...props,
665
+ rootDir: normalizeRootDir(rootDir)
666
+ });
667
+ }, {
668
+ Type: DeploymentResource.Type,
669
+ Props: DeploymentResource.Props,
670
+ Self: DeploymentResource.Self,
671
+ Provider: DeploymentResource.Provider,
672
+ Aliases: DeploymentResource.Aliases,
673
+ ref: (id, options) => DeploymentResource.ref(id, options)
674
+ });
675
+ /** The props name another deployment than the one in state. */
676
+ var DeploymentIdentityChange = class extends Schema.TaggedError()("DeploymentIdentityChange", {
677
+ /** Name of the deployment in state, such as `happy-animal-123`. */
678
+ deployment: Schema.String,
679
+ field: Schema.Literals([
680
+ "projectId",
681
+ "type",
682
+ "name"
683
+ ]),
684
+ current: Schema.String,
685
+ requested: Schema.String
686
+ }) {
687
+ get message() {
688
+ return `Convex.Deployment cannot change the ${this.field} of deployment ${this.deployment} from ${this.current} to ${this.requested}. Set ${this.field} back to ${this.current}. For another deployment, create a new resource with a new logical id.`;
689
+ }
690
+ };
691
+ /** The project has no default production deployment for a `prod` resource to take over. */
692
+ var ProductionDeploymentNotFound = class extends Schema.TaggedError()("ProductionDeploymentNotFound", { projectId: Schema.Number }) {
693
+ get message() {
694
+ return `Project ${this.projectId} has no default production deployment, and Convex.Deployment does not create one. Create it in the Convex dashboard, or with \`npx convex deployment create --type prod --default\` in the project, then run the deploy again.`;
695
+ }
696
+ };
697
+ /** More than one deployment of the project has the name, so the lookup cannot choose one. */
698
+ var AmbiguousDeployment = class extends Schema.TaggedError()("AmbiguousDeployment", {
699
+ projectId: Schema.Number,
700
+ type: Schema.Literals(["dev", "preview"]),
701
+ name: Schema.String,
702
+ deployments: Schema.Array(Schema.String)
703
+ }) {
704
+ get message() {
705
+ return `Project ${this.projectId} has ${this.deployments.length} ${this.type} deployments named "${this.name}" (${this.deployments.join(", ")}). Convex.Deployment does not choose one. Delete the extra deployments in the Convex dashboard, or use another name.`;
706
+ }
707
+ };
708
+ /**
709
+ * The URL of a Convex cloud deployment: `https://<name>.convex.cloud`, or
710
+ * `https://<name>.<region>.convex.cloud` outside US East.
711
+ */
712
+ const CLOUD_URL = /^https:\/\/[a-z0-9]+(?:-[a-z0-9]+)*(?:\.[a-z0-9]+(?:-[a-z0-9]+)*)?\.convex\.cloud$/;
713
+ /**
714
+ * The HTTP actions URL of a deployment: the client URL with `.convex.site`
715
+ * for `.convex.cloud`, as Convex sets CONVEX_SITE_URL. Custom domains are
716
+ * not covered.
717
+ */
718
+ const siteUrlOf = (url) => url.replace(/\.convex\.cloud$/, ".convex.site");
719
+ /** One row of list_deployments, GET /deployments/{name}, and create_deployment. Only the fields the provider uses. */
720
+ const DeploymentRow$1 = Schema.Struct({
721
+ name: Schema.String,
722
+ projectId: Schema.Number,
723
+ deploymentType: Schema.String,
724
+ isDefault: Schema.Boolean,
725
+ reference: Schema.String,
726
+ /** Set in list_deployments and create_deployment rows only: GET /deployments/{name} answers null. */
727
+ previewIdentifier: Schema.optionalKey(Schema.NullOr(Schema.String)),
728
+ deploymentUrl: Schema.String.check(Schema.isPattern(CLOUD_URL)),
729
+ /** Absent or null until the first push: list_deployments leaves it out for a new deployment. */
730
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
731
+ });
732
+ const DeploymentRows$1 = Schema.Array(DeploymentRow$1);
733
+ /** GET https://<deployment>/api/v1/deployment_info, for a cloud deployment. Only the fields the provider uses. */
734
+ const DeploymentInfo = Schema.Struct({
735
+ kind: Schema.Literal("cloud"),
736
+ projectId: Schema.Number,
737
+ deploymentType: Schema.String,
738
+ reference: Schema.optionalKey(Schema.NullOr(Schema.String))
739
+ });
740
+ /** GET https://<deployment>/api/v1/get_canonical_urls */
741
+ const CanonicalUrls = Schema.Struct({
742
+ convexCloudUrl: Schema.String,
743
+ convexSiteUrl: Schema.String
744
+ });
745
+ const targetOf = (row, type, previewName) => ({
746
+ projectId: row.projectId,
747
+ type,
748
+ name: row.name,
749
+ reference: row.reference,
750
+ previewName: type === "preview" ? previewName : void 0,
751
+ url: row.deploymentUrl,
752
+ siteUrl: siteUrlOf(row.deploymentUrl)
753
+ });
754
+ /** True when the deployment has received a push. */
755
+ const isDeployed = (row) => row.lastDeployTime !== void 0 && row.lastDeployTime !== null;
756
+ /** The `name` prop of a deployment: the dev reference or the preview name. */
757
+ const nameOf = (target) => target.type === "dev" ? target.reference : target.type === "preview" ? target.previewName : void 0;
758
+ /**
759
+ * Fails with {@link DeploymentIdentityChange} when the props name another
760
+ * deployment than `current`: the attributes in state, or a live deployment.
761
+ */
762
+ const checkIdentity = (current, news) => {
763
+ const change = (field, from, to) => Effect.fail(new DeploymentIdentityChange({
764
+ deployment: current.name,
765
+ field,
766
+ current: from,
767
+ requested: to
768
+ }));
769
+ if (current.projectId !== news.project.projectId) return change("projectId", String(current.projectId), String(news.project.projectId));
770
+ if (current.type !== news.type) return change("type", current.type, news.type);
771
+ const currentName = nameOf(current);
772
+ if (news.type !== "prod" && currentName !== news.name) return change("name", currentName ?? "(none)", news.name);
773
+ return Effect.void;
774
+ };
775
+ const addressOf = (props) => ({
776
+ projectId: props.project.projectId,
777
+ type: props.type,
778
+ name: props.type === "prod" ? void 0 : props.name
779
+ });
780
+ /** True when an update writes or removes variables: `env` has names, or state has names to remove. */
781
+ const changesEnv = (news, output) => Object.keys(news.env ?? {}).length > 0 || envNamesOf(output).length > 0;
782
+ /** The `env` names in state. State from 0.3 has none. */
783
+ const envNamesOf = (output) => output?.envNames ?? [];
784
+ /** The address of the deployment in state. */
785
+ const addressInState = (output) => ({
786
+ projectId: output.projectId,
787
+ type: output.type,
788
+ name: nameOf(output)
789
+ });
790
+ const ProjectSlugs = Schema.Struct({
791
+ slug: Schema.String,
792
+ teamSlug: Schema.String
793
+ });
794
+ /** True for an answer that says this credential cannot read the object. */
795
+ const isRefused = (error) => error.status === 400 || error.status === 401 || error.status === 403 || error.status === 404;
796
+ const DeploymentProvider = () => Provider.effect(DeploymentResource, Effect.gen(function* () {
797
+ const api = yield* ManagementApi;
798
+ const credentials = yield* Credentials;
799
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
800
+ /** The live deployment, or undefined after a 404. GET /deployments/{name} answers no preview identifier. */
801
+ const getByName = (management, name) => absentAsUndefined(management.request("get deployment", (client) => client.GET("/deployments/{deployment_name}", { params: { path: { deployment_name: name } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(DeploymentRow$1)(body)));
802
+ const listDeployments = (management, projectId, type, isDefault) => management.request("list deployments", (client) => client.GET("/projects/{project_id}/list_deployments", { params: {
803
+ path: { project_id: projectId },
804
+ query: {
805
+ deploymentType: type,
806
+ ...isDefault === void 0 ? {} : { isDefault }
807
+ }
808
+ } })).pipe(Effect.map((body) => Schema.decodeUnknownSync(DeploymentRows$1)(body)));
809
+ /** The row of the project's default production deployment, or undefined. */
810
+ const productionRow = (management, projectId) => listDeployments(management, projectId, "prod", true).pipe(Effect.map((rows) => rows.find((candidate) => candidate.deploymentType === "prod" && candidate.isDefault)));
811
+ /** The project's default production deployment, or undefined. */
812
+ const production = (management, projectId) => productionRow(management, projectId).pipe(Effect.map((row) => row === void 0 ? void 0 : targetOf(row, "prod", void 0)));
813
+ /**
814
+ * The one dev deployment with this reference, or the one preview
815
+ * deployment with this preview name, or undefined.
816
+ */
817
+ const findByName = (management, projectId, type, name) => Effect.gen(function* () {
818
+ const matches = (yield* listDeployments(management, projectId, type)).filter((row) => row.deploymentType === type && (type === "dev" ? row.reference === name : row.previewIdentifier === name));
819
+ if (matches.length > 1) return yield* new AmbiguousDeployment({
820
+ projectId,
821
+ type,
822
+ name,
823
+ deployments: matches.map((row) => row.name)
824
+ });
825
+ return matches[0];
826
+ });
827
+ /** The deployment that the address names, through the Management API, or undefined. */
828
+ const lookup = (management, address) => address.type === "prod" || address.name === void 0 ? production(management, address.projectId) : findByName(management, address.projectId, address.type, address.name).pipe(Effect.map((row) => row === void 0 ? void 0 : targetOf(row, address.type, address.name)));
829
+ /** The deployment of a deploy key, when it is the deployment that the address names. */
830
+ const deployKeyTarget = (key, deployment, address) => Effect.gen(function* () {
831
+ const info = Schema.decodeUnknownSync(DeploymentInfo)(yield* api.deploymentRequest("get deployment info", deployment, key, (client) => client.GET("/deployment_info")));
832
+ const reference = info.reference ?? "";
833
+ if (info.projectId !== address.projectId || info.deploymentType !== address.type || address.type === "dev" && reference !== address.name) return;
834
+ const urls = Schema.decodeUnknownSync(CanonicalUrls)(yield* api.deploymentRequest("get canonical urls", deployment, key, (client) => client.GET("/get_canonical_urls")));
835
+ return {
836
+ projectId: info.projectId,
837
+ type: address.type,
838
+ name: deployment,
839
+ reference,
840
+ previewName: void 0,
841
+ url: urls.convexCloudUrl,
842
+ siteUrl: urls.convexSiteUrl
843
+ };
844
+ }).pipe(Effect.catchIf(isRefused, () => Effect.succeed(void 0)));
845
+ /** True when the preview deploy key belongs to the project. */
846
+ const previewKeyFits = (management, slugs, projectId) => management.request("get project", (client) => client.GET("/projects/{project_id}", { params: { path: { project_id: projectId } } })).pipe(Effect.map((body) => {
847
+ const project = Schema.decodeUnknownSync(ProjectSlugs)(body);
848
+ return project.slug === slugs.project && project.teamSlug === slugs.team;
849
+ }), Effect.catchIf(isRefused, () => Effect.succeed(false)));
850
+ /**
851
+ * The narrowest credential that reaches the deployment. See {@link Access}.
852
+ * When `changesEnv`, a preview deploy key gives way to the access token,
853
+ * because it cannot change variables.
854
+ */
855
+ const accessFor = (address, changesEnv) => Effect.gen(function* () {
856
+ const key = yield* credentials.deployKey;
857
+ if (key !== void 0) {
858
+ const target = keyTargetOf(Redacted.value(key));
859
+ if (target.kind === "deployment" && address.type !== "preview") {
860
+ const found = yield* deployKeyTarget(key, target.deployment, address);
861
+ if (found !== void 0) return {
862
+ kind: "deployKey",
863
+ key,
864
+ target: found
865
+ };
866
+ }
867
+ if (target.kind === "preview" && address.type === "preview") {
868
+ const scoped = api.withToken(key);
869
+ if (!(changesEnv && (yield* credentials.hasAccessToken)) && (yield* previewKeyFits(scoped, target, address.projectId))) return {
870
+ kind: "previewKey",
871
+ key,
872
+ api: scoped
873
+ };
874
+ }
875
+ }
876
+ return {
877
+ kind: "token",
878
+ api
879
+ };
880
+ });
881
+ /** The live deployment of a resource in state, or undefined when it is gone. */
882
+ const live = (access, output) => Effect.gen(function* () {
883
+ if (access.kind === "deployKey") return access.target;
884
+ if (output.type === "prod") return yield* absentAsUndefined(production(access.api, output.projectId));
885
+ const row = yield* getByName(access.api, output.name);
886
+ return row === void 0 ? void 0 : targetOf(row, output.type, output.previewName);
887
+ });
888
+ /** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
889
+ const wantedHash = (news, rootDir, deployment) => news.source === void 0 && Object.keys(news.env ?? {}).length === 0 ? Effect.succeed(void 0) : pushHash({
890
+ rootDir,
891
+ source: news.source,
892
+ deployment,
893
+ env: news.env ?? {}
894
+ });
895
+ return {
896
+ stables: [
897
+ "projectId",
898
+ "type",
899
+ "previewName"
900
+ ],
901
+ diff: Effect.fn(function* ({ news, olds, output, oldBindings, newBindings }) {
902
+ if (!isResolved(news) || output === void 0) return void 0;
903
+ yield* checkIdentity(output, news);
904
+ if (!isResolved(newBindings)) return void 0;
905
+ const rootDir = news.rootDir ?? ".";
906
+ if (news.source !== void 0) yield* checkSource(rootDir, news.source);
907
+ if (olds.rootDir !== rootDir) return { action: "update" };
908
+ const access = yield* accessFor(addressOf(news), changesEnv(news, output));
909
+ const current = yield* live(access, output);
910
+ if (current?.name !== output.name) return { action: "update" };
911
+ if ((yield* wantedHash(news, rootDir, output.name)) !== output.hash) return { action: "update" };
912
+ if (diffBindings(oldBindings, newBindings).every((binding) => binding.action === "noop")) return { action: "noop" };
913
+ return {
914
+ action: "update",
915
+ stables: [
916
+ "hash",
917
+ "envNames",
918
+ ...[
919
+ "projectId",
920
+ "type",
921
+ "previewName",
922
+ "name",
923
+ "reference",
924
+ "url",
925
+ "siteUrl"
926
+ ].filter((key) => current[key] === output[key])
927
+ ]
928
+ };
929
+ }),
930
+ read: Effect.fn(function* ({ olds, output }) {
931
+ if (output !== void 0) {
932
+ const access = yield* accessFor(addressInState(output), false);
933
+ const current = yield* live(access, output);
934
+ return current === void 0 ? void 0 : {
935
+ ...output,
936
+ ...current
937
+ };
938
+ }
939
+ const address = addressOf(olds);
940
+ const access = yield* accessFor(address, false);
941
+ if (access.kind === "deployKey") return Unowned({
942
+ ...access.target,
943
+ hash: void 0,
944
+ envNames: []
945
+ });
946
+ if (olds.type === "prod") {
947
+ const row = yield* productionRow(access.api, address.projectId);
948
+ if (row === void 0) return void 0;
949
+ const attributes = {
950
+ ...targetOf(row, "prod", void 0),
951
+ hash: void 0,
952
+ envNames: []
953
+ };
954
+ return isDeployed(row) ? Unowned(attributes) : attributes;
955
+ }
956
+ const found = yield* lookup(access.api, address);
957
+ if (found === void 0) return void 0;
958
+ return Unowned({
959
+ ...found,
960
+ hash: void 0,
961
+ envNames: []
962
+ });
963
+ }),
964
+ reconcile: Effect.fn(function* ({ fqn, news, output }) {
965
+ if (output !== void 0) yield* checkIdentity(output, news);
966
+ const rootDir = news.rootDir ?? ".";
967
+ if (news.source !== void 0) yield* checkSource(rootDir, news.source);
968
+ const address = addressOf(news);
969
+ const access = yield* accessFor(address, changesEnv(news, output));
970
+ const env = news.env ?? {};
971
+ if (access.kind === "previewKey" && changesEnv(news, output)) return yield* new EnvironmentNeedsDeployKey({ deployment: output?.name ?? address.name ?? "(new)" });
972
+ const adopt = yield* shouldAdopt(fqn);
973
+ const refuse = (name) => new OwnedBySomeoneElse({
974
+ message: `Project ${address.projectId} already has ${news.type} deployment ${name}${news.type === "prod" ? " with code" : ` named "${news.name}"`}. Re-run with --adopt to take it over${news.type === "prod" ? "" : ", or use another name"}.`,
975
+ resourceType: DeploymentResource.Type,
976
+ physicalName: name
977
+ });
978
+ const { target, created } = yield* Effect.gen(function* () {
979
+ if (access.kind === "deployKey") {
980
+ if (output === void 0 && !adopt) return yield* refuse(access.target.name);
981
+ return {
982
+ target: access.target,
983
+ created: false
984
+ };
985
+ }
986
+ if (news.type === "prod") {
987
+ const row = yield* productionRow(access.api, address.projectId);
988
+ if (row === void 0) return yield* new ProductionDeploymentNotFound({ projectId: address.projectId });
989
+ if (output === void 0 && isDeployed(row) && !adopt) return yield* refuse(row.name);
990
+ return {
991
+ target: targetOf(row, "prod", void 0),
992
+ created: false
993
+ };
994
+ }
995
+ if (output !== void 0) {
996
+ const row = yield* getByName(access.api, output.name);
997
+ if (row !== void 0) {
998
+ if (row.deploymentType !== output.type) return yield* new DeploymentIdentityChange({
999
+ deployment: row.name,
1000
+ field: "type",
1001
+ current: row.deploymentType,
1002
+ requested: news.type
1003
+ });
1004
+ const current = targetOf(row, output.type, output.previewName);
1005
+ yield* checkIdentity(current, news);
1006
+ return {
1007
+ target: current,
1008
+ created: false
1009
+ };
1010
+ }
1011
+ }
1012
+ const existing = yield* lookup(access.api, address);
1013
+ if (existing !== void 0) {
1014
+ if (!adopt) return yield* refuse(existing.name);
1015
+ return {
1016
+ target: existing,
1017
+ created: false
1018
+ };
1019
+ }
1020
+ const row = yield* access.api.request("create deployment", (client) => client.POST("/projects/{project_id}/create_deployment", {
1021
+ params: { path: { project_id: address.projectId } },
1022
+ body: {
1023
+ type: news.type,
1024
+ reference: news.name
1025
+ }
1026
+ }));
1027
+ return {
1028
+ target: targetOf(Schema.decodeUnknownSync(DeploymentRow$1)(row), news.type, news.name),
1029
+ created: true
1030
+ };
1031
+ });
1032
+ const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
1033
+ const before = yield* wantedHash(news, rootDir, target.name);
1034
+ if (before === void 0 && previousEnvNames.length === 0) return {
1035
+ ...target,
1036
+ hash: void 0,
1037
+ envNames: []
1038
+ };
1039
+ if (output?.name === target.name && before === output.hash) return {
1040
+ ...target,
1041
+ hash: output.hash,
1042
+ envNames: previousEnvNames
1043
+ };
1044
+ const options = {
1045
+ deployment: target.name,
1046
+ url: target.url,
1047
+ rootDir,
1048
+ source: news.source,
1049
+ env,
1050
+ previousEnvNames,
1051
+ adopt
1052
+ };
1053
+ yield* (access.kind === "deployKey" ? push(spawner, access.key, {
1054
+ ...options,
1055
+ previewName: void 0
1056
+ }) : access.kind === "previewKey" ? push(spawner, access.key, {
1057
+ ...options,
1058
+ previewName: target.previewName
1059
+ }) : withPushKey(access.api, target.name, typeof news.source === "object" && news.source.command !== void 0 ? void 0 : PUSH_KEY_ACTIONS, (key) => push(spawner, key, {
1060
+ ...options,
1061
+ previewName: void 0
1062
+ }))).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));
1063
+ return {
1064
+ ...target,
1065
+ hash: yield* wantedHash(news, rootDir, target.name),
1066
+ envNames: Object.keys(env).sort()
1067
+ };
1068
+ }),
1069
+ delete: Effect.fn(function* ({ output }) {
1070
+ const keep = Effect.logWarning(`Convex.Deployment does not delete production deployment ${output.name}: it belongs to project ${output.projectId}. To delete it, delete the project with RemovalPolicy.destroy() on Convex.Project.`);
1071
+ if (output.type === "prod") return yield* keep;
1072
+ const access = yield* accessFor(addressInState(output), false);
1073
+ const management = access.kind === "deployKey" ? api : access.api;
1074
+ const row = yield* getByName(management, output.name);
1075
+ if (row === void 0) return;
1076
+ if (row.deploymentType === "prod") return yield* keep;
1077
+ yield* absentAsUndefined(retryIdempotentWrite(management.requestVoid("delete deployment", (client) => client.POST("/deployments/{deployment_name}/delete", { params: { path: { deployment_name: output.name } } }))));
1078
+ })
1079
+ };
1080
+ }));
1081
+ //#endregion
1082
+ //#region src/Project.ts
1083
+ const Project = Resource("Convex.Project", { defaultRemovalPolicy: "retain" });
1084
+ /** The props change the team of an existing project. Convex cannot move a project. */
1085
+ var ProjectTeamChange = class extends Schema.TaggedError()("ProjectTeamChange", {
1086
+ projectId: Schema.Number,
1087
+ teamId: Schema.Number,
1088
+ requestedTeamId: Schema.Number
1089
+ }) {
1090
+ get message() {
1091
+ return `Convex.Project cannot move project ${this.projectId} from team ${this.teamId} to team ${this.requestedTeamId}. Set team back to ${this.teamId}. To use the other team, create a new resource with a new logical id and move the data yourself.`;
1092
+ }
1093
+ };
1094
+ /**
1095
+ * The props change `deploymentType` or `deploymentRegion` of an existing
1096
+ * project. Convex uses them only when it creates the project.
1097
+ */
1098
+ var ProjectDeploymentChange = class extends Schema.TaggedError()("ProjectDeploymentChange", {
1099
+ projectId: Schema.Number,
1100
+ field: Schema.Literals(["deploymentType", "deploymentRegion"]),
1101
+ current: Schema.UndefinedOr(Schema.String),
1102
+ requested: Schema.UndefinedOr(Schema.String)
1103
+ }) {
1104
+ get message() {
1105
+ const show = (value) => value === void 0 ? "(not set)" : value;
1106
+ return `Convex.Project cannot change ${this.field} of project ${this.projectId} from ${show(this.current)} to ${show(this.requested)}: Convex uses it only when it creates a project. Set ${this.field} back to ${show(this.current)}. For a new deployment, create a new resource with a new logical id and move the data yourself.`;
1107
+ }
1108
+ };
1109
+ /** More than one project in the team has the name, so the lookup cannot choose one. */
1110
+ var AmbiguousProject = class extends Schema.TaggedError()("AmbiguousProject", {
1111
+ teamId: Schema.Number,
1112
+ name: Schema.String,
1113
+ projectIds: Schema.Array(Schema.Number)
1114
+ }) {
1115
+ get message() {
1116
+ return `Team ${this.teamId} has ${this.projectIds.length} projects named "${this.name}" (ids ${this.projectIds.join(", ")}). Convex.Project does not choose one. Rename the projects in the Convex dashboard so that only one has this name, or use a different name.`;
1117
+ }
1118
+ };
1119
+ /** Convex answered a create call but then did not return the project. */
1120
+ var CreatedProjectNotFound = class extends Schema.TaggedError()("CreatedProjectNotFound", { projectId: Schema.Number }) {
1121
+ get message() {
1122
+ return `Convex created project ${this.projectId} but GET /projects/${this.projectId} did not return it. Run the deploy again with --adopt to take over project ${this.projectId}.`;
1123
+ }
1124
+ };
1125
+ /** No project in the team has the slug. A `slug` resource never creates one. */
1126
+ var ProjectNotFound = class extends Schema.TaggedError()("ProjectNotFound", {
1127
+ team: Schema.String,
1128
+ slug: Schema.String
1129
+ }) {
1130
+ get message() {
1131
+ return `Team ${this.team} has no project with the slug "${this.slug}", and a Convex.Project with a slug never creates one. Check the slug in the project URL of the Convex dashboard, or use name to create a project.`;
1132
+ }
1133
+ };
1134
+ /** The props change the slug of a `slug` resource: that is another project. */
1135
+ var ProjectSlugChange = class extends Schema.TaggedError()("ProjectSlugChange", {
1136
+ projectId: Schema.Number,
1137
+ slug: Schema.String,
1138
+ requestedSlug: Schema.String
1139
+ }) {
1140
+ get message() {
1141
+ return `Convex.Project ${this.projectId} has the slug "${this.slug}", and the props ask for "${this.requestedSlug}", which is another project. Set slug back to "${this.slug}". For the other project, create a new resource with a new logical id.`;
1142
+ }
1143
+ };
1144
+ /** Fields of GET /projects/{id}, PATCH /projects/{id}, and GET /teams/{team}/projects rows that the provider uses. */
1145
+ const ProjectDetails = Schema.Struct({
1146
+ id: Schema.Number,
1147
+ name: Schema.String,
1148
+ slug: Schema.String,
1149
+ teamId: Schema.Number,
1150
+ teamSlug: Schema.String,
1151
+ prodDeploymentName: Schema.NullOr(Schema.String)
1152
+ });
1153
+ /** GET /teams/{team_id}/projects: one page of projects. */
1154
+ const ProjectPage = Schema.Struct({
1155
+ items: Schema.Array(ProjectDetails),
1156
+ pagination: Schema.Struct({
1157
+ hasMore: Schema.Boolean,
1158
+ nextCursor: Schema.optionalKey(Schema.NullOr(Schema.String))
1159
+ })
1160
+ });
1161
+ /** POST /teams/{team_id}/create_project. Only the id: everything else comes from GET /projects/{id}. */
1162
+ const CreatedProject = Schema.Struct({ projectId: Schema.Number });
1163
+ const DeploymentRow = Schema.Struct({
1164
+ name: Schema.String,
1165
+ deploymentType: Schema.String,
1166
+ isDefault: Schema.Boolean,
1167
+ deploymentUrl: Schema.String,
1168
+ /** Absent or null until the first push. */
1169
+ lastDeployTime: Schema.optionalKey(Schema.NullOr(Schema.Number))
1170
+ });
1171
+ const DeploymentRows = Schema.Array(DeploymentRow);
1172
+ /**
1173
+ * Fails with {@link ProjectDeploymentChange} when a create-time prop
1174
+ * changed. An omitted `deploymentType` means `prod`.
1175
+ */
1176
+ const checkCreateTimeProps = (projectId, olds, news) => {
1177
+ const oldType = olds.deploymentType ?? "prod";
1178
+ const newType = news.deploymentType ?? "prod";
1179
+ if (oldType !== newType) return Effect.fail(new ProjectDeploymentChange({
1180
+ projectId,
1181
+ field: "deploymentType",
1182
+ current: oldType,
1183
+ requested: newType
1184
+ }));
1185
+ if (olds.deploymentRegion !== news.deploymentRegion) return Effect.fail(new ProjectDeploymentChange({
1186
+ projectId,
1187
+ field: "deploymentRegion",
1188
+ current: olds.deploymentRegion,
1189
+ requested: news.deploymentRegion
1190
+ }));
1191
+ return Effect.void;
1192
+ };
1193
+ /** True when `team`, a slug or a numeric id, names the team of the project. */
1194
+ const isTeamOf = (details, team) => typeof team === "number" || /^\d+$/.test(team) ? details.teamId === Number(team) : details.teamSlug === team;
1195
+ /** The row of the project's default production deployment, or undefined. */
1196
+ 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)));
1197
+ /** GET /projects/{id}, or undefined after a 404. */
1198
+ 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)));
1199
+ /**
1200
+ * Every project in the team with exactly this name, in the order of the
1201
+ * listing. Reads every page. `q` narrows the listing on the server, and the
1202
+ * exact match here decides. Pattern from Confect
1203
+ * `packages/alchemy/src/ConvexClient.ts` and `Project.ts`
1204
+ * (https://github.com/rjdellecese/confect, ISC license).
1205
+ */
1206
+ const listProjectsByName = (api, teamId, name) => Effect.gen(function* () {
1207
+ const matches = [];
1208
+ const seenCursors = /* @__PURE__ */ new Set();
1209
+ let cursor;
1210
+ while (true) {
1211
+ const body = yield* api.request("list projects", (client) => client.GET("/teams/{team_id}/projects", { params: {
1212
+ path: { team_id: teamId },
1213
+ query: {
1214
+ q: name,
1215
+ ...cursor === void 0 ? {} : { cursor }
1216
+ }
1217
+ } }));
1218
+ const page = Schema.decodeUnknownSync(ProjectPage)(body);
1219
+ matches.push(...page.items.filter((row) => row.name === name));
1220
+ if (!page.pagination.hasMore) break;
1221
+ const next = page.pagination.nextCursor;
1222
+ if (next === void 0 || next === null || seenCursors.has(next)) return yield* new ConvexApiError({
1223
+ operation: "list projects",
1224
+ status: 0,
1225
+ code: "InvalidPagination",
1226
+ 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}".`
1227
+ });
1228
+ seenCursors.add(next);
1229
+ cursor = next;
1230
+ }
1231
+ return matches;
1232
+ });
1233
+ /** The one project in the team with exactly this name, or undefined. More than one is an error. */
1234
+ const findProjectByName = (api, teamId, name) => listProjectsByName(api, teamId, name).pipe(Effect.flatMap((matches) => matches.length > 1 ? new AmbiguousProject({
1235
+ teamId,
1236
+ name,
1237
+ projectIds: matches.map((row) => row.id)
1238
+ }) : Effect.succeed(matches[0])));
1239
+ /**
1240
+ * POST /teams/{team_id}/create_project, then GET /projects/{id}. The identity
1241
+ * comes from the create response, never from a name lookup.
1242
+ */
1243
+ const createProject = (api, teamId, props) => Effect.gen(function* () {
1244
+ const created = yield* api.request("create project", (client) => client.POST("/teams/{team_id}/create_project", {
1245
+ params: { path: { team_id: teamId } },
1246
+ body: {
1247
+ projectName: props.name,
1248
+ deploymentType: props.deploymentType,
1249
+ ...props.deploymentRegion === void 0 ? {} : { deploymentRegion: props.deploymentRegion }
1250
+ }
1251
+ }));
1252
+ const { projectId } = Schema.decodeUnknownSync(CreatedProject)(created);
1253
+ const details = yield* getProjectById(api, projectId);
1254
+ if (details === void 0) return yield* new CreatedProjectNotFound({ projectId });
1255
+ return details;
1256
+ });
1257
+ const ProjectProvider = () => Provider.effect(Project, Effect.gen(function* () {
1258
+ const api = yield* ManagementApi;
1259
+ const attributesOf = (details) => prodDeploymentOf(api, details.id).pipe(Effect.map((prod) => ({
1260
+ projectId: details.id,
1261
+ slug: details.slug,
1262
+ name: details.name,
1263
+ teamId: details.teamId,
1264
+ prodDeploymentName: prod?.name ?? details.prodDeploymentName ?? void 0,
1265
+ prodDeploymentUrl: prod?.deploymentUrl
1266
+ })));
1267
+ const getById = (projectId) => getProjectById(api, projectId);
1268
+ /** The project with this slug in the team, or undefined. Official endpoint; the team can be a slug. */
1269
+ const getBySlug = (team, slug) => absentAsUndefined(api.request("get project by slug", (client) => client.GET("/teams/{team_id_or_slug}/projects/{project_slug}", { params: { path: {
1270
+ team_id_or_slug: String(team),
1271
+ project_slug: slug
1272
+ } } }))).pipe(Effect.map((body) => body === void 0 ? void 0 : Schema.decodeUnknownSync(ProjectDetails)(body)));
1273
+ const findByName = (teamId, name) => findProjectByName(api, teamId, name);
1274
+ /**
1275
+ * Brings an existing project to the desired name. The team cannot
1276
+ * change, so a project in another team fails.
1277
+ */
1278
+ const converge = (details, teamId, name) => Effect.gen(function* () {
1279
+ if (details.teamId !== teamId) return yield* new ProjectTeamChange({
1280
+ projectId: details.id,
1281
+ teamId: details.teamId,
1282
+ requestedTeamId: teamId
1283
+ });
1284
+ if (details.name === name) return yield* attributesOf(details);
1285
+ const updated = yield* api.request("update project", (client) => client.PATCH("/projects/{project_id}", {
1286
+ params: { path: { project_id: details.id } },
1287
+ body: { name }
1288
+ }));
1289
+ return yield* attributesOf(Schema.decodeUnknownSync(ProjectDetails)(updated));
1290
+ });
1291
+ return {
1292
+ stables: [
1293
+ "projectId",
1294
+ "slug",
1295
+ "teamId"
1296
+ ],
1297
+ diff: Effect.fn(function* ({ olds, news, output }) {
1298
+ if (!isResolved(news)) return void 0;
1299
+ if (output === void 0) return void 0;
1300
+ if ("slug" in news) {
1301
+ if (news.slug !== output.slug) return yield* new ProjectSlugChange({
1302
+ projectId: output.projectId,
1303
+ slug: output.slug,
1304
+ requestedSlug: news.slug
1305
+ });
1306
+ if (olds.team !== news.team) {
1307
+ const details = yield* getById(output.projectId);
1308
+ if (details !== void 0 && !isTeamOf(details, news.team)) return yield* new ProjectTeamChange({
1309
+ projectId: output.projectId,
1310
+ teamId: output.teamId,
1311
+ requestedTeamId: yield* api.resolveTeamId(news.team)
1312
+ });
1313
+ }
1314
+ return { action: "noop" };
1315
+ }
1316
+ if (olds.team !== news.team) {
1317
+ const requestedTeamId = yield* api.resolveTeamId(news.team);
1318
+ if (requestedTeamId !== output.teamId) return yield* new ProjectTeamChange({
1319
+ projectId: output.projectId,
1320
+ teamId: output.teamId,
1321
+ requestedTeamId
1322
+ });
1323
+ }
1324
+ return "name" in olds && olds.name === news.name ? void 0 : { action: "update" };
1325
+ }),
1326
+ read: Effect.fn(function* ({ olds, output }) {
1327
+ if (output !== void 0) {
1328
+ const details = yield* getById(output.projectId);
1329
+ return details === void 0 ? void 0 : yield* attributesOf(details);
1330
+ }
1331
+ const existing = "slug" in olds ? yield* getBySlug(olds.team, olds.slug) : yield* findByName(yield* api.resolveTeamId(olds.team), olds.name);
1332
+ return existing === void 0 ? void 0 : Unowned(yield* attributesOf(existing));
1333
+ }),
1334
+ reconcile: Effect.fn(function* ({ fqn, olds, news, output }) {
1335
+ if ("slug" in news) {
1336
+ const details = output === void 0 ? yield* getBySlug(news.team, news.slug) : yield* getById(output.projectId);
1337
+ if (details === void 0) return yield* new ProjectNotFound({
1338
+ team: String(news.team),
1339
+ slug: news.slug
1340
+ });
1341
+ if (details.slug !== news.slug) return yield* new ProjectSlugChange({
1342
+ projectId: details.id,
1343
+ slug: details.slug,
1344
+ requestedSlug: news.slug
1345
+ });
1346
+ if (!isTeamOf(details, news.team)) return yield* new ProjectTeamChange({
1347
+ projectId: details.id,
1348
+ teamId: details.teamId,
1349
+ requestedTeamId: yield* api.resolveTeamId(news.team)
1350
+ });
1351
+ if (output === void 0 && !(yield* shouldAdopt(fqn))) return yield* new OwnedBySomeoneElse({
1352
+ message: `Convex project ${details.id} ("${news.slug}") exists and is not in this stack's state. Re-run with --adopt to take it over.`,
1353
+ resourceType: Project.Type,
1354
+ physicalName: news.slug
1355
+ });
1356
+ return yield* attributesOf(details);
1357
+ }
1358
+ const teamId = yield* api.resolveTeamId(news.team);
1359
+ if (output !== void 0) {
1360
+ if (olds !== void 0 && "name" in olds) yield* checkCreateTimeProps(output.projectId, olds, news);
1361
+ if (output.teamId !== teamId) return yield* new ProjectTeamChange({
1362
+ projectId: output.projectId,
1363
+ teamId: output.teamId,
1364
+ requestedTeamId: teamId
1365
+ });
1366
+ const details = yield* getById(output.projectId);
1367
+ if (details !== void 0) return yield* converge(details, teamId, news.name);
1368
+ } else {
1369
+ const existing = yield* findByName(teamId, news.name);
1370
+ if (existing !== void 0) {
1371
+ if (!(yield* shouldAdopt(fqn))) return yield* new OwnedBySomeoneElse({
1372
+ message: `Convex project ${existing.id} in team ${teamId} already has the name "${news.name}". Re-run with --adopt to take it over, or use a different name.`,
1373
+ resourceType: Project.Type,
1374
+ physicalName: news.name
1375
+ });
1376
+ return yield* converge(existing, teamId, news.name);
1377
+ }
1378
+ }
1379
+ const details = yield* createProject(api, teamId, {
1380
+ name: news.name,
1381
+ deploymentType: news.deploymentType ?? "prod",
1382
+ deploymentRegion: news.deploymentRegion
1383
+ });
1384
+ return yield* converge(details, teamId, news.name);
1385
+ }),
1386
+ delete: Effect.fn(function* ({ output }) {
1387
+ yield* absentAsUndefined(api.requestVoid("delete project", (client) => client.POST("/projects/{project_id}/delete", { params: { path: { project_id: output.projectId } } })));
1388
+ })
1389
+ };
1390
+ }));
1391
+ //#endregion
1392
+ //#region src/RunRecord.ts
1393
+ /** The key of the record in the stage output document. */
1394
+ const RUN_RECORD_KEY = "samebase.run";
1395
+ /** The name of the stack file. Alchemy resolves `--config` against the working directory and does not expose it to a state layer, so the record assumes the default name. */
1396
+ const STACK_FILE_NAME = "alchemy.run.ts";
1397
+ var RunRecordError = class extends Schema.TaggedError()("RunRecordError", { reason: Schema.String }) {
1398
+ get message() {
1399
+ return `The run record of the stage output cannot be made: ${this.reason}`;
1400
+ }
1401
+ };
1402
+ /** The part of the GitHub Actions event payload that the record reads. A push event has no `pull_request`. */
1403
+ const EventPayload = Schema.Struct({ pull_request: Schema.optionalKey(Schema.Struct({
1404
+ number: Schema.Number,
1405
+ head: Schema.Struct({ sha: Schema.String })
1406
+ })) });
1407
+ /** `owner/name` of an https, ssh, or scp-like git URL with a host. A local path is not one. */
1408
+ const REMOTE_URL = /^(?:[a-z+]+:\/\/[^/]+\/|[^/@:]+@[^:/]+:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
1409
+ const execFileAsync = promisify(execFile);
1410
+ const now = Clock.currentTimeMillis.pipe(Effect.map((millis) => new Date(millis).toISOString()));
1411
+ const git = (cwd, args) => Effect.tryPromise({
1412
+ try: async () => (await execFileAsync("git", [
1413
+ "-C",
1414
+ cwd,
1415
+ ...args
1416
+ ], { encoding: "utf8" })).stdout.trim(),
1417
+ catch: (error) => new RunRecordError({ reason: `git ${args.join(" ")} failed in ${cwd} (${error instanceof Error ? error.message.split("\n")[0] : String(error)})` })
1418
+ });
1419
+ /** The stack file from the repository root: the working directory below the git top level, plus {@link STACK_FILE_NAME}. */
1420
+ const stackFileOf = (cwd) => Effect.gen(function* () {
1421
+ const top = yield* git(cwd, ["rev-parse", "--show-toplevel"]);
1422
+ const [realTop, realCwd] = yield* Effect.tryPromise({
1423
+ try: () => Promise.all([realpath(top), realpath(cwd)]),
1424
+ catch: () => new RunRecordError({ reason: `${cwd} is gone` })
1425
+ });
1426
+ return [...relative(realTop, realCwd).split(sep).filter((part) => part !== ""), STACK_FILE_NAME].join("/");
1427
+ });
1428
+ const variable = (env, name) => Effect.suspend(() => {
1429
+ const value = env[name];
1430
+ return value === void 0 || value === "" ? Effect.fail(new RunRecordError({ reason: `GITHUB_ACTIONS is true, and ${name} is not set` })) : Effect.succeed(value);
1431
+ });
1432
+ const fromGitHubActions = (where) => Effect.gen(function* () {
1433
+ const { env } = where;
1434
+ const repository = yield* variable(env, "GITHUB_REPOSITORY");
1435
+ const refName = yield* variable(env, "GITHUB_REF_NAME");
1436
+ const sha = yield* variable(env, "GITHUB_SHA");
1437
+ const serverUrl = yield* variable(env, "GITHUB_SERVER_URL");
1438
+ const runId = yield* variable(env, "GITHUB_RUN_ID");
1439
+ const runAttempt = yield* variable(env, "GITHUB_RUN_ATTEMPT");
1440
+ const eventPath = env["GITHUB_EVENT_PATH"];
1441
+ const pullRequest = (eventPath === void 0 || eventPath === "" ? void 0 : yield* Effect.tryPromise({
1442
+ try: () => readFile(eventPath, "utf8"),
1443
+ catch: () => new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} cannot be read` })
1444
+ }).pipe(Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(EventPayload))), Effect.mapError((error) => error instanceof RunRecordError ? error : new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} is not an event payload` }))))?.pull_request;
1445
+ const headCommit = pullRequest?.head.sha ?? sha;
1446
+ const headRef = env["GITHUB_HEAD_REF"];
1447
+ const mergeRef = /^refs\/pull\/(\d+)\/merge$/.exec(env["GITHUB_REF"] ?? "");
1448
+ return {
1449
+ version: 1,
1450
+ repository,
1451
+ stackFile: yield* stackFileOf(where.cwd),
1452
+ branch: headRef === void 0 || headRef === "" ? refName : headRef,
1453
+ pullRequest: pullRequest?.number ?? (mergeRef === null ? null : Number(mergeRef[1])),
1454
+ headCommit,
1455
+ runUrl: `${serverUrl}/${repository}/actions/runs/${runId}/attempts/${runAttempt}`,
1456
+ source: "github-actions",
1457
+ recordedAt: yield* now
1458
+ };
1459
+ });
1460
+ const fromGit = (where) => Effect.gen(function* () {
1461
+ const { cwd } = where;
1462
+ const origin = yield* git(cwd, [
1463
+ "remote",
1464
+ "get-url",
1465
+ "origin"
1466
+ ]);
1467
+ const match = REMOTE_URL.exec(origin);
1468
+ if (match === null) return yield* new RunRecordError({ reason: `the origin remote of ${cwd} is not a git URL with a host and owner/name: ${origin}` });
1469
+ const branch = yield* git(cwd, [
1470
+ "rev-parse",
1471
+ "--abbrev-ref",
1472
+ "HEAD"
1473
+ ]);
1474
+ if (branch === "HEAD") return yield* new RunRecordError({ reason: `the checkout at ${cwd} is on no branch (detached HEAD)` });
1475
+ return {
1476
+ version: 1,
1477
+ repository: `${match[1]}/${match[2]}`,
1478
+ stackFile: yield* stackFileOf(cwd),
1479
+ branch,
1480
+ pullRequest: null,
1481
+ headCommit: yield* git(cwd, ["rev-parse", "HEAD"]),
1482
+ runUrl: null,
1483
+ source: "laptop",
1484
+ recordedAt: yield* now
1485
+ };
1486
+ });
1487
+ /**
1488
+ * The record of this run: from the GitHub Actions variables and the event
1489
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
1490
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
1491
+ * missing; nothing is guessed.
1492
+ */
1493
+ const runRecord = (where) => where.env["GITHUB_ACTIONS"] === "true" ? fromGitHubActions(where) : fromGit(where);
1494
+ /** A plain object, which a spread keeps whole. A Date, a Duration, or a Redacted value is an object too, and a spread loses it. */
1495
+ const isPlainObject = (value) => {
1496
+ if (typeof value !== "object" || value === null) return false;
1497
+ const prototype = Object.getPrototypeOf(value);
1498
+ return prototype === Object.prototype || prototype === null;
1499
+ };
1500
+ /**
1501
+ * The output document of a stage: the stack's output plus the record under
1502
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
1503
+ * key: a string, an array, null, or an object that is not plain, such as a
1504
+ * Date. A value that the stack itself put under the key stays.
1505
+ */
1506
+ const withRunRecord = (output, record) => isPlainObject(output) ? "samebase.run" in output ? output : {
1507
+ ...output,
1508
+ [RUN_RECORD_KEY]: record
1509
+ } : void 0;
1510
+ //#endregion
1511
+ //#region src/State.ts
1512
+ /** The name of the state project in each team. */
1513
+ const STORE_PROJECT_NAME = "samebase";
1514
+ /**
1515
+ * The actions of the key of one run: the internal queries and mutations of
1516
+ * the store, nothing else. Measured: `deployment:data:view` and
1517
+ * `deployment:data:write` do not run an internal function.
1518
+ */
1519
+ const RUN_KEY_ACTIONS = ["deployment:functions:runInternalQueries", "deployment:functions:runInternalMutations"];
1520
+ /** The body of POST /api/function, with HTTP 200 for a function error too. */
1521
+ const Reply = Schema.Union([Schema.Struct({
1522
+ status: Schema.Literal("success"),
1523
+ value: Schema.Unknown
1524
+ }), Schema.Struct({
1525
+ status: Schema.Literal("error"),
1526
+ errorMessage: Schema.String
1527
+ })]);
1528
+ /** The error reply of /api/function for a function that the deployment does not have (recorded). */
1529
+ const MISSING_FUNCTION = /Could not find function for '/;
1530
+ /** HTTP 401 or 403 from the function API: the key is gone or expired (recorded: 403 without a key). */
1531
+ const isUnauthorized = (error) => error.http?.status === 401 || error.http?.status === 403;
1532
+ const failure = (message) => new StateStoreError({ message: `Convex state: ${message}` });
1533
+ /** No reply, HTTP 429, or HTTP 5xx: a new attempt can succeed. Every call of the store is idempotent. */
1534
+ const isTransient = (error) => error.http !== void 0 && (error.http.status === void 0 || error.http.status === 429 || error.http.status >= 500);
1535
+ const post = (url, headers, body) => Effect.tryPromise({
1536
+ try: () => fetch(url, {
1537
+ method: "POST",
1538
+ headers: {
1539
+ "Content-Type": "application/json",
1540
+ ...headers
1541
+ },
1542
+ body: JSON.stringify(body)
1543
+ }),
1544
+ catch: () => new StateStoreError({
1545
+ message: `Convex state: POST ${url} failed`,
1546
+ http: {}
1547
+ })
1548
+ }).pipe(Effect.flatMap((response) => response.ok || response.status === 560 ? Effect.tryPromise({
1549
+ try: () => response.json(),
1550
+ catch: () => failure(`${url} gave no JSON`)
1551
+ }) : Effect.fail(new StateStoreError({
1552
+ message: `Convex state: POST ${url} failed with HTTP ${response.status}`,
1553
+ http: { status: response.status }
1554
+ }))), Effect.retry({
1555
+ while: isTransient,
1556
+ schedule: Schedule.exponential("100 millis").pipe(Schedule.jittered),
1557
+ times: 5
1558
+ }));
1559
+ const decode = (schema, what) => (value) => Schema.decodeUnknownEffect(schema)(value).pipe(Effect.mapError(() => failure(`bad ${what}`)));
1560
+ const makeFunctionApi = (url, key) => ({ call: (path, args) => post(`${url}/api/function`, { Authorization: `Convex ${Redacted.value(key)}` }, {
1561
+ path,
1562
+ args,
1563
+ format: "json"
1564
+ }).pipe(Effect.flatMap(decode(Reply, `reply of ${path}`))) });
1565
+ /** The value of a reply, or the Convex message, which names the function and the cause, such as a missing key action. */
1566
+ const valueOf = (reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(failure(reply.errorMessage.trim()));
1567
+ /** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
1568
+ const remoteOf = (api, record) => {
1569
+ const run = (name, args) => api.call(`state:${name}`, args).pipe(Effect.flatMap(valueOf));
1570
+ const write = (name, args) => Effect.gen(function* () {
1571
+ const currentRun = record === void 0 ? void 0 : {
1572
+ ...record,
1573
+ recordedAt: new Date(yield* Clock.currentTimeMillis).toISOString()
1574
+ };
1575
+ return yield* run(name, {
1576
+ ...args,
1577
+ run: currentRun
1578
+ });
1579
+ });
1580
+ const strings = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
1581
+ const optional = (name, args) => run(name, args).pipe(Effect.flatMap(decode(Schema.NullOr(Schema.String), name)));
1582
+ const encode = (value) => JSON.stringify(encodeState(value));
1583
+ return {
1584
+ listStacks: () => strings("stacks", {}),
1585
+ listStages: (stack) => strings("stages", { stack }),
1586
+ list: (r) => strings("list", {
1587
+ stack: r.stack,
1588
+ stage: r.stage
1589
+ }),
1590
+ get: (r) => optional("get", {
1591
+ stack: r.stack,
1592
+ stage: r.stage,
1593
+ fqn: r.fqn
1594
+ }).pipe(Effect.map((text) => text === null ? void 0 : JSON.parse(text, reviveState))),
1595
+ getReplacedResources: (r) => strings("replaced", {
1596
+ stack: r.stack,
1597
+ stage: r.stage
1598
+ }).pipe(Effect.map((texts) => texts.map((text) => JSON.parse(text, reviveState)))),
1599
+ getOutput: (r) => optional("getOutput", {
1600
+ stack: r.stack,
1601
+ stage: r.stage
1602
+ }).pipe(Effect.map((text) => text === null ? void 0 : JSON.parse(text, reviveState))),
1603
+ set: (r) => write("set", {
1604
+ stack: r.stack,
1605
+ stage: r.stage,
1606
+ fqn: r.fqn,
1607
+ value: encode(r.value)
1608
+ }),
1609
+ delete: (r) => write("remove", {
1610
+ stack: r.stack,
1611
+ stage: r.stage,
1612
+ fqn: r.fqn
1613
+ }),
1614
+ deleteStack: (r) => run("removeStack", {
1615
+ stack: r.stack,
1616
+ stage: r.stage
1617
+ }),
1618
+ setOutput: (r) => write("setOutput", {
1619
+ stack: r.stack,
1620
+ stage: r.stage,
1621
+ value: encode(r.value)
1622
+ })
1623
+ };
1624
+ };
1625
+ /** The version that the store answers, or undefined when the deployment has no `state:version`. */
1626
+ 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")))));
1627
+ /**
1628
+ * The app that serves the store. A store without `state:host` is a seed: it
1629
+ * was pushed before 0.14.0, and only the store app of this package served
1630
+ * those stores.
1631
+ */
1632
+ const storeHost = (api) => api.call("state:host", {}).pipe(Effect.flatMap((reply) => reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) ? Effect.succeed("seed") : valueOf(reply).pipe(Effect.flatMap(decode(Schema.Literals(["seed", "base"]), "store host")))));
1633
+ /**
1634
+ * The base app of the team serves an older store. Convex.state() never
1635
+ * pushes its store app over a base, because the push replaces every function
1636
+ * of the deployment and so erases the base app.
1637
+ */
1638
+ var OutdatedBase = class extends Schema.TaggedError()("OutdatedBase", {
1639
+ team: Schema.Union([Schema.String, Schema.Number]),
1640
+ deployment: Schema.String,
1641
+ version: Schema.Number
1642
+ }) {
1643
+ get message() {
1644
+ return `The base of team ${this.team} (${this.deployment}) answers store version ${this.version}, and this package needs store version 4. Convex.state does not push over a base. Upgrade the dependency @samebase/alchemy-convex of the base app to the version of this package, and push the base app.`;
1645
+ }
1646
+ };
1647
+ /**
1648
+ * The functions directory of the store app, `src/store/convex` of this
1649
+ * package. This file is `src/State.ts` in a checkout and a chunk in `lib/`
1650
+ * in the installed package, which ships `src` too, so the directory is
1651
+ * `../src/store/convex` from either place. Its `convex.config.ts` mounts the
1652
+ * alchemyState component of `src/store/component`, inside the same push root.
1653
+ */
1654
+ const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
1655
+ /**
1656
+ * Pushes `store` to the production deployment of the state project with the
1657
+ * installed Convex CLI: no typecheck, no codegen, the key only in the
1658
+ * environment of the CLI.
1659
+ */
1660
+ const pushStore = (options, deployment) => Effect.gen(function* () {
1661
+ const cli = yield* installedCli(join(options.store, ".."));
1662
+ yield* Effect.logInfo(`Convex state: pushing the store version 4 to ${deployment.name}`);
1663
+ yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => push(options.spawner, key, {
1664
+ deployment: deployment.name,
1665
+ url: deployment.deploymentUrl,
1666
+ rootDir: join(options.store, ".."),
1667
+ source: {
1668
+ path: options.store,
1669
+ command: [
1670
+ ...cli,
1671
+ "deploy",
1672
+ "--typecheck",
1673
+ "disable",
1674
+ "--codegen",
1675
+ "disable"
1676
+ ]
1677
+ },
1678
+ env: {},
1679
+ previousEnvNames: [],
1680
+ adopt: false,
1681
+ previewName: void 0
1682
+ }));
1683
+ });
1684
+ /**
1685
+ * Creates the state project of the team. Two first runs can both create one:
1686
+ * the run whose project is not the oldest deletes its own, which holds
1687
+ * nothing yet, and uses the oldest, so that every run ends on one store.
1688
+ */
1689
+ const createStoreProject = (api, teamId, team) => Effect.gen(function* () {
1690
+ yield* Effect.logInfo(`Convex state: creating the project ${STORE_PROJECT_NAME} in team ${team}`);
1691
+ const created = yield* createProject(api, teamId, {
1692
+ name: STORE_PROJECT_NAME,
1693
+ deploymentType: "prod",
1694
+ deploymentRegion: void 0
1695
+ });
1696
+ const oldest = (yield* listProjectsByName(api, teamId, STORE_PROJECT_NAME)).reduce((a, b) => b.id < a.id ? b : a, created);
1697
+ if (oldest.id === created.id) return created;
1698
+ yield* Effect.logInfo(`Convex state: another run created ${STORE_PROJECT_NAME} ${oldest.id} first; project ${created.id} is deleted again.`);
1699
+ yield* absentAsUndefined(retryIdempotentWrite(api.requestVoid("delete project", (client) => client.POST("/projects/{project_id}/delete", { params: { path: { project_id: created.id } } }))));
1700
+ return oldest;
1701
+ });
1702
+ /**
1703
+ * The StateService over the `samebase` project of the team: found or
1704
+ * created, its store app pushed when the store is missing or an older seed,
1705
+ * and read and written with the keys of this run.
1706
+ */
1707
+ const makeConvexState = (options) => Effect.gen(function* () {
1708
+ const { api } = options;
1709
+ const teamId = yield* api.resolveTeamId(options.team);
1710
+ const team = options.team ?? teamId;
1711
+ const project = (yield* findProjectByName(api, teamId, "samebase")) ?? (yield* createStoreProject(api, teamId, team));
1712
+ const deployment = yield* prodDeploymentOf(api, project.id);
1713
+ if (deployment === void 0) return yield* new ProductionDeploymentNotFound({ projectId: project.id });
1714
+ const keys = [];
1715
+ yield* Scope.addFinalizer(options.scope, Effect.suspend(() => Effect.forEach(keys, (key) => deleteDeployKey(api, deployment.name, key), { discard: true })));
1716
+ const mint = createDeployKey(api, deployment.name, STORE_PROJECT_NAME, RUN_KEY_ACTIONS).pipe(Effect.map((key) => {
1717
+ keys.push(key);
1718
+ return makeFunctionApi(deployment.deploymentUrl, key);
1719
+ }), Effect.mapError((error) => failure(`no key for ${deployment.name}: ${error.message}`)));
1720
+ let current = yield* mint;
1721
+ const functions = { call: (path, args) => current.call(path, args).pipe(Effect.catchIf(isUnauthorized, () => mint.pipe(Effect.flatMap((next) => {
1722
+ current = next;
1723
+ return next.call(path, args);
1724
+ })))) };
1725
+ const needsPush = (found) => found === void 0 ? Effect.succeed(true) : found < 4 ? storeHost(functions).pipe(Effect.map((host) => host === "seed")) : Effect.succeed(false);
1726
+ let version = yield* storeVersion(functions);
1727
+ if (yield* needsPush(version)) {
1728
+ 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.`);
1729
+ version = yield* storeVersion(functions);
1730
+ if (yield* needsPush(version)) {
1731
+ yield* pushStore(options, deployment);
1732
+ version = yield* storeVersion(functions);
1733
+ }
1734
+ }
1735
+ if (version !== void 0 && version < 4 && (yield* storeHost(functions)) === "base") return yield* new OutdatedBase({
1736
+ team,
1737
+ deployment: deployment.name,
1738
+ version
1739
+ });
1740
+ if (version !== 4) return yield* failure(`${deployment.name} answers store version ${version ?? "none"}, and this package needs store version 4.`);
1741
+ const remote = remoteOf(functions, options.run);
1742
+ /** The stack's output plus the record, when the run has one and the output has a place for it. */
1743
+ const documentOf = (request) => Effect.gen(function* () {
1744
+ const { run } = options;
1745
+ if (run === void 0) return request.value;
1746
+ const recordedAt = new Date(yield* Clock.currentTimeMillis).toISOString();
1747
+ const document = withRunRecord(request.value, {
1748
+ ...run,
1749
+ recordedAt
1750
+ });
1751
+ if (document === void 0) {
1752
+ yield* Effect.logWarning(`Convex state: the output of ${request.stack}/${request.stage} is not a plain object, so it carries no run record.`);
1753
+ return request.value;
1754
+ }
1755
+ return document;
1756
+ });
1757
+ return {
1758
+ id: "convex",
1759
+ getVersion: () => Effect.succeed(STATE_STORE_VERSION),
1760
+ listStacks: remote.listStacks,
1761
+ listStages: remote.listStages,
1762
+ list: remote.list,
1763
+ get: remote.get,
1764
+ getReplacedResources: remote.getReplacedResources,
1765
+ getOutput: remote.getOutput,
1766
+ set: (request) => remote.set(request).pipe(Effect.as(request.value)),
1767
+ delete: (request) => remote.delete(request).pipe(Effect.asVoid),
1768
+ deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
1769
+ setOutput: (request) => Effect.gen(function* () {
1770
+ const value = yield* documentOf(request);
1771
+ yield* remote.setOutput({
1772
+ stack: request.stack,
1773
+ stage: request.stage,
1774
+ value
1775
+ });
1776
+ return value;
1777
+ })
1778
+ };
1779
+ });
1780
+ /**
1781
+ * Alchemy state in the `samebase` project of `team`, with the team token
1782
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
1783
+ * its team, so `team` is optional with one; the Convex CLI login needs the
1784
+ * slug or the id. The first use of the run finds or creates the project and
1785
+ * pushes the store app when the store is missing or an older seed. An older
1786
+ * base fails the run with {@link OutdatedBase}. `alchemy plan` on another
1787
+ * stack never touches Convex.
1788
+ */
1789
+ const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
1790
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1791
+ const scope = yield* Scope.Scope;
1792
+ const context = yield* Effect.context();
1793
+ return yield* Effect.cached(Effect.gen(function* () {
1794
+ const api = yield* ManagementApi.pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
1795
+ const run = yield* runRecord({
1796
+ cwd: process.cwd(),
1797
+ env: process.env
1798
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Convex state: the stage outputs of this run carry no record. ${error.message}`).pipe(Effect.as(void 0))));
1799
+ return yield* makeConvexState({
1800
+ team: options.team,
1801
+ api,
1802
+ spawner,
1803
+ scope,
1804
+ store: storeDirectory(),
1805
+ run
1806
+ });
1807
+ }).pipe(Effect.provideContext(context), Effect.orDie));
1808
+ }));
1809
+ //#endregion
1810
+ //#region src/HubClient.ts
1811
+ const HubClient = Resource("IdentityHub.Client");
1812
+ /** The base of the team could not do the call. The reason is the Convex message: it holds no secret. */
1813
+ var HubClientFailed = class extends Schema.TaggedError()("HubClientFailed", {
1814
+ team: Schema.Union([Schema.String, Schema.Number]),
1815
+ reason: Schema.String
1816
+ }) {
1817
+ get message() {
1818
+ return `Convex.HubClient: the base of team ${this.team}: ${this.reason}.`;
1819
+ }
1820
+ };
1821
+ /** The reply of `identityBroker:registerClient`. */
1822
+ const Registered = Schema.Struct({
1823
+ issuer: Schema.NonEmptyString,
1824
+ clientId: Schema.NonEmptyString,
1825
+ clientSecret: Schema.NonEmptyString
1826
+ });
1827
+ /** Runs `use` with the function API of the base of `team`, with a key of this call only. */
1828
+ const withBase = (api, team, use) => Effect.gen(function* () {
1829
+ const teamId = yield* api.resolveTeamId(team);
1830
+ const project = yield* findProjectByName(api, teamId, STORE_PROJECT_NAME);
1831
+ if (project === void 0) return yield* new HubClientFailed({
1832
+ team,
1833
+ reason: `no project ${STORE_PROJECT_NAME}, which holds the hub`
1834
+ });
1835
+ const deployment = yield* prodDeploymentOf(api, project.id);
1836
+ if (deployment === void 0) return yield* new HubClientFailed({
1837
+ team,
1838
+ reason: `project ${STORE_PROJECT_NAME} has no production deployment`
1839
+ });
1840
+ return yield* withPushKey(api, deployment.name, RUN_KEY_ACTIONS, (key) => use(makeFunctionApi(deployment.deploymentUrl, key)));
1841
+ });
1842
+ /** One internal function of the hub: its value, or the Convex message as an error. */
1843
+ const runHub = (functions, team, name, args) => functions.call(`identityBroker:${name}`, args).pipe(Effect.mapError((error) => new HubClientFailed({
1844
+ team,
1845
+ reason: error.message
1846
+ })), Effect.flatMap((reply) => reply.status === "success" ? Effect.succeed(reply.value) : Effect.fail(new HubClientFailed({
1847
+ team,
1848
+ reason: reply.errorMessage.trim()
1849
+ }))));
1850
+ /**
1851
+ * Registers one client. An attempt that a lost reply repeats leaves one spare
1852
+ * client on the hub, which signs nobody in.
1853
+ */
1854
+ const registerHubClient = (api, props) => withBase(api, props.team, (functions) => runHub(functions, props.team, "registerClient", {
1855
+ appId: props.app,
1856
+ redirectUri: props.redirectUri,
1857
+ deploymentType: props.deploymentType,
1858
+ clientName: props.clientName,
1859
+ softwareVersion: props.softwareVersion ?? "unknown",
1860
+ tokenEndpointAuthMethod: "client_secret_post"
1861
+ })).pipe(Effect.flatMap((value) => Schema.decodeUnknownEffect(Registered)(value).pipe(Effect.mapError(() => new HubClientFailed({
1862
+ team: props.team,
1863
+ reason: "registerClient gave no client"
1864
+ })))), Effect.map(({ issuer, clientId, clientSecret }) => ({
1865
+ issuer,
1866
+ clientId,
1867
+ clientSecret: Redacted.make(clientSecret)
1868
+ })));
1869
+ const unregister = (api, team, clientId) => withBase(api, team, (functions) => runHub(functions, team, "deleteClient", { clientId }));
1870
+ /** True when the client in state serves the redirect URI and deployment type of `props`. */
1871
+ const registeredFor = (output, props) => output.redirectUri === props.redirectUri && output.deploymentType === props.deploymentType;
1872
+ const HubClientProvider = () => Provider.effect(HubClient, Effect.gen(function* () {
1873
+ const api = yield* ManagementApi;
1874
+ return {
1875
+ diff: ({ news, olds, output }) => Effect.succeed(!isResolved(news) || output === void 0 ? void 0 : !registeredFor(output, news) ? { action: "replace" } : olds.team === news.team && olds.app === news.app ? { action: "noop" } : { action: "update" }),
1876
+ read: ({ output }) => Effect.succeed(output),
1877
+ reconcile: Effect.fn(function* ({ news, output }) {
1878
+ if (output !== void 0 && registeredFor(output, news)) return output;
1879
+ const client = yield* registerHubClient(api, news);
1880
+ if (output !== void 0) yield* unregister(api, news.team, output.clientId).pipe(Effect.catchTag("HubClientFailed", (error) => Effect.logWarning(`Client ${output.clientId} stays on the hub: ${error.message} Delete it with \`npx convex run --prod identityBroker:deleteClient\`.`)));
1881
+ return {
1882
+ ...client,
1883
+ redirectUri: news.redirectUri,
1884
+ deploymentType: news.deploymentType,
1885
+ clientName: news.clientName
1886
+ };
1887
+ }),
1888
+ delete: ({ olds, output }) => unregister(api, olds.team, output.clientId).pipe(Effect.asVoid)
1889
+ };
1890
+ }));
1891
+ /** The variables that a deployment reads to sign in through the hub. */
1892
+ const HUB_VARIABLES = [
1893
+ "HUB_ISSUER",
1894
+ "HUB_CLIENT_ID",
1895
+ "HUB_CLIENT_SECRET"
1896
+ ];
1897
+ /**
1898
+ * `alchemy-convex hub-client`: a stack deploys production and previews, and this covers the
1899
+ * development deployment, which no stack deploys. A deployment with all three variables keeps
1900
+ * them. So a deployment that holds placeholder values for a checkout without a hub is left alone.
1901
+ * The secret reaches the Convex CLI on standard input, never in its arguments.
1902
+ */
1903
+ const registerDevelopmentClient = (options) => Effect.gen(function* () {
1904
+ const { convex } = options;
1905
+ const names = convex([
1906
+ "env",
1907
+ "list",
1908
+ "--names-only"
1909
+ ]).split("\n");
1910
+ if (HUB_VARIABLES.every((name) => names.includes(name))) return void 0;
1911
+ const siteUrl = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.String))(convex([
1912
+ "run",
1913
+ "--inline-query",
1914
+ "process.env.CONVEX_SITE_URL"
1915
+ ])).pipe(Effect.mapError(() => new HubClientFailed({
1916
+ team: options.team,
1917
+ reason: "no CONVEX_SITE_URL to register"
1918
+ })));
1919
+ const callbackUrl = `${siteUrl.replace(/\/$/, "")}${options.callbackPath}`;
1920
+ const client = yield* registerHubClient(yield* ManagementApi, {
1921
+ team: options.team,
1922
+ app: options.app,
1923
+ redirectUri: callbackUrl,
1924
+ deploymentType: "development",
1925
+ clientName: "development",
1926
+ softwareVersion: new URL(siteUrl).host
1927
+ });
1928
+ convex([
1929
+ "env",
1930
+ "set",
1931
+ "--force"
1932
+ ], `HUB_ISSUER='${client.issuer}'\nHUB_CLIENT_ID='${client.clientId}'\nHUB_CLIENT_SECRET='${Redacted.value(client.clientSecret)}'\n`);
1933
+ return callbackUrl;
1934
+ });
1935
+ //#endregion
1936
+ export { PushTargetMismatch as A, DEPLOY_KEY_ENV as B, DeploymentProvider as C, ConvexAppNotFound as D, CodePushFailed as E, ManagementApi as F, fromValues as H, ManagementApiLive as I, ACCESS_TOKEN_ENV as L, installedCli as M, parseDeployOutput as N, ConvexCliNotFound as O, ConvexApiError as P, Credentials as R, DeploymentIdentityChange as S, ProductionDeploymentNotFound as T, fromConfig as V, ProjectProvider as _, OutdatedBase as a, AmbiguousDeployment as b, RUN_RECORD_KEY as c, withRunRecord as d, AmbiguousProject as f, ProjectNotFound as g, ProjectDeploymentChange as h, registerDevelopmentClient as i, VariablesOwnedBySomeoneElse as j, EnvironmentNeedsDeployKey as k, RunRecordError as l, Project as m, HubClientFailed as n, STORE_PROJECT_NAME as o, CreatedProjectNotFound as p, HubClientProvider as r, state as s, HubClient as t, runRecord as u, ProjectTeamChange as v, DeploymentResource as w, Deployment as x, AbsoluteRootDir as y, CredentialsError as z };
1937
+
1938
+ //# sourceMappingURL=HubClient-C_nvjlvW.js.map