@samebase/alchemy-convex 0.6.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
- import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { access, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises";
3
3
  import { homedir, tmpdir } from "node:os";
4
- import { dirname, join, relative, resolve } from "node:path";
4
+ import { dirname, join, posix, relative, resolve, sep, win32 } from "node:path";
5
5
  import * as Config from "effect/Config";
6
6
  import * as Context from "effect/Context";
7
7
  import * as Effect from "effect/Effect";
@@ -20,11 +20,12 @@ import { Stack } from "alchemy/Stack";
20
20
  import { createDeploymentClient, createManagementClient } from "@convex-dev/platform";
21
21
  import * as Schedule from "effect/Schedule";
22
22
  import { createHash } from "node:crypto";
23
- import { stripVTControlCharacters } from "node:util";
23
+ import { promisify, stripVTControlCharacters } from "node:util";
24
24
  import { hashDirectory } from "alchemy/Command/Memo";
25
25
  import * as Clock from "effect/Clock";
26
26
  import * as ChildProcess from "effect/process/ChildProcess";
27
27
  import * as Stream from "effect/Stream";
28
+ import { execFile } from "node:child_process";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import * as Scope from "effect/Scope";
30
31
  import { STATE_STORE_VERSION, State, StateStoreError, encodeState, reviveState } from "alchemy/State";
@@ -281,13 +282,15 @@ const EnvironmentVariableList = Schema.Struct({ environmentVariables: Schema.Rec
281
282
  /** No `convex` package resolves from the app directory. */
282
283
  var ConvexCliNotFound = class extends Schema.TaggedError()("ConvexCliNotFound", { cwd: Schema.String }) {
283
284
  get message() {
284
- 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 source to the functions directory of the app.`;
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.`;
285
286
  }
286
287
  };
287
- /** No directory at or above the parent of `source` has a package.json. */
288
- var ConvexAppNotFound = class extends Schema.TaggedError()("ConvexAppNotFound", { source: Schema.String }) {
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 }) {
289
292
  get message() {
290
- return `Convex.Deployment found no package.json at or above the parent of ${this.source}. Set source to the functions directory of a Convex app, such as "./convex".`;
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".`;
291
294
  }
292
295
  };
293
296
  /** The Convex CLI failed, or exited 0 without the deployment URL. The output tail never contains the key. */
@@ -371,28 +374,26 @@ const parseDeployOutput = (text) => {
371
374
  /** The last non-empty lines of `text`, without escape codes and with every secret replaced. */
372
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");
373
376
  const valueOf$1 = (value) => Redacted.isRedacted(value) ? Redacted.value(value) : value;
374
- /** The app directory: the nearest directory at or above the parent of `source` with a package.json. */
375
- const appDirectoryOf = (source) => Effect.tryPromise({
376
- try: async () => {
377
- let directory = dirname(resolve(source));
378
- while (true) try {
379
- await access(join(directory, "package.json"));
380
- return directory;
381
- } catch {
382
- const parent = dirname(directory);
383
- if (parent === directory) throw new Error("no package.json");
384
- directory = parent;
385
- }
386
- },
387
- catch: () => new ConvexAppNotFound({ source })
388
- });
389
- /** `source` is not the functions directory that the Convex CLI pushes from this app. */
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. */
390
389
  var SourceMismatch = class extends Schema.TaggedError()("SourceMismatch", {
390
+ /** The app directory, resolved. */
391
+ rootDir: Schema.String,
391
392
  source: Schema.String,
392
393
  functions: Schema.String
393
394
  }) {
394
395
  get message() {
395
- return `Convex.Deployment source is ${this.source}, but the Convex CLI pushes ${this.functions} in this app (the "functions" field of convex.json, or convex/ by default). Set source to ${this.functions}.`;
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".`;
396
397
  }
397
398
  };
398
399
  const ConvexJson = Schema.Struct({ functions: Schema.optionalKey(Schema.String) });
@@ -401,8 +402,9 @@ const checkFunctionsDirectory = (cwd, path) => Effect.gen(function* () {
401
402
  const text = yield* Effect.promise(() => readFile(join(cwd, "convex.json"), "utf8").catch(() => void 0));
402
403
  const configured = text === void 0 ? void 0 : Schema.decodeUnknownSync(Schema.fromJsonString(ConvexJson))(text).functions;
403
404
  const functions = resolve(cwd, configured ?? "convex");
404
- if (functions !== resolve(path)) return yield* new SourceMismatch({
405
- source: resolve(path),
405
+ if (functions !== path) return yield* new SourceMismatch({
406
+ rootDir: cwd,
407
+ source: path,
406
408
  functions
407
409
  });
408
410
  });
@@ -426,18 +428,24 @@ const installedCli = (cwd) => Effect.tryPromise({
426
428
  */
427
429
  const pushHash = (options) => Effect.gen(function* () {
428
430
  const source = options.source === void 0 ? void 0 : sourceOf(options.source);
429
- const files = source === void 0 ? void 0 : yield* appDirectoryOf(source.path).pipe(Effect.flatMap((cwd) => hashDirectory({
430
- cwd,
431
- memo: source.include === void 0 ? {} : {
432
- include: [
433
- `${relative(cwd, resolve(source.path)).split("\\").join("/")}/**`,
434
- "package.json",
435
- "convex.json",
436
- ...source.include
437
- ],
438
- lockfile: true
439
- }
440
- })));
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
+ }));
441
449
  const env = Object.entries(options.env).map(([name, value]) => [name, valueOf$1(value)]).sort(([a], [b]) => a < b ? -1 : 1);
442
450
  return createHash("sha256").update(JSON.stringify({
443
451
  files,
@@ -509,13 +517,13 @@ const writeVariables = (url, key, changes) => settleVoid("update environment var
509
517
  })) } }));
510
518
  /**
511
519
  * The app directory and the program of a push. Fails before any change when
512
- * `source` is not the functions directory of the app, or when the app has no
513
- * installed Convex CLI and no push command.
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.
514
522
  */
515
- const checkSource = (input) => Effect.gen(function* () {
523
+ const checkSource = (rootDir, input) => Effect.gen(function* () {
516
524
  const source = sourceOf(input);
517
- const cwd = yield* appDirectoryOf(source.path);
518
- yield* checkFunctionsDirectory(cwd, source.path);
525
+ const cwd = yield* appDirectoryOf(rootDir);
526
+ yield* checkFunctionsDirectory(cwd, resolve(cwd, source.path));
519
527
  return {
520
528
  cwd,
521
529
  program: source.command ?? (yield* installedCli(cwd))
@@ -529,7 +537,7 @@ const checkSource = (input) => Effect.gen(function* () {
529
537
  */
530
538
  const push = (spawner, key, options) => Effect.gen(function* () {
531
539
  const source = options.source === void 0 ? void 0 : sourceOf(options.source);
532
- const resolved = options.source === void 0 ? void 0 : yield* checkSource(options.source);
540
+ const resolved = options.source === void 0 ? void 0 : yield* checkSource(options.rootDir, options.source);
533
541
  const cwd = resolved?.cwd;
534
542
  const program = resolved?.program;
535
543
  const env = Object.entries(options.env).sort(([a], [b]) => a < b ? -1 : 1);
@@ -627,7 +635,43 @@ const runPush = (spawner, key, options) => Effect.gen(function* () {
627
635
  });
628
636
  //#endregion
629
637
  //#region src/Deployment.ts
630
- const Deployment = Resource("Convex.Deployment", { defaultRemovalPolicy: "retain" });
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
+ });
631
675
  /** The props name another deployment than the one in state. */
632
676
  var DeploymentIdentityChange = class extends Schema.TaggedError()("DeploymentIdentityChange", {
633
677
  /** Name of the deployment in state, such as `happy-animal-123`. */
@@ -749,7 +793,7 @@ const ProjectSlugs = Schema.Struct({
749
793
  });
750
794
  /** True for an answer that says this credential cannot read the object. */
751
795
  const isRefused = (error) => error.status === 400 || error.status === 401 || error.status === 403 || error.status === 404;
752
- const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function* () {
796
+ const DeploymentProvider = () => Provider.effect(DeploymentResource, Effect.gen(function* () {
753
797
  const api = yield* ManagementApi;
754
798
  const credentials = yield* Credentials;
755
799
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
@@ -842,7 +886,8 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
842
886
  return row === void 0 ? void 0 : targetOf(row, output.type, output.previewName);
843
887
  });
844
888
  /** The content identity of `source` and `env` on a deployment, or undefined when there is nothing to push. */
845
- const wantedHash = (news, deployment) => news.source === void 0 && Object.keys(news.env ?? {}).length === 0 ? Effect.succeed(void 0) : pushHash({
889
+ const wantedHash = (news, rootDir, deployment) => news.source === void 0 && Object.keys(news.env ?? {}).length === 0 ? Effect.succeed(void 0) : pushHash({
890
+ rootDir,
846
891
  source: news.source,
847
892
  deployment,
848
893
  env: news.env ?? {}
@@ -853,12 +898,15 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
853
898
  "type",
854
899
  "previewName"
855
900
  ],
856
- diff: Effect.fn(function* ({ news, output }) {
901
+ diff: Effect.fn(function* ({ news, olds, output }) {
857
902
  if (!isResolved(news) || output === void 0) return void 0;
858
903
  yield* checkIdentity(output, news);
904
+ const rootDir = news.rootDir ?? ".";
905
+ if (news.source !== void 0) yield* checkSource(rootDir, news.source);
906
+ if (olds.rootDir !== rootDir) return { action: "update" };
859
907
  const access = yield* accessFor(addressOf(news), changesEnv(news, output));
860
908
  if ((yield* live(access, output))?.name !== output.name) return { action: "update" };
861
- return (yield* wantedHash(news, output.name)) === output.hash ? { action: "noop" } : { action: "update" };
909
+ return (yield* wantedHash(news, rootDir, output.name)) === output.hash ? { action: "noop" } : { action: "update" };
862
910
  }),
863
911
  read: Effect.fn(function* ({ olds, output }) {
864
912
  if (output !== void 0) {
@@ -896,7 +944,8 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
896
944
  }),
897
945
  reconcile: Effect.fn(function* ({ fqn, news, output }) {
898
946
  if (output !== void 0) yield* checkIdentity(output, news);
899
- if (news.source !== void 0) yield* checkSource(news.source);
947
+ const rootDir = news.rootDir ?? ".";
948
+ if (news.source !== void 0) yield* checkSource(rootDir, news.source);
900
949
  const address = addressOf(news);
901
950
  const access = yield* accessFor(address, changesEnv(news, output));
902
951
  const env = news.env ?? {};
@@ -904,7 +953,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
904
953
  const adopt = yield* shouldAdopt(fqn);
905
954
  const refuse = (name) => new OwnedBySomeoneElse({
906
955
  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"}.`,
907
- resourceType: Deployment.Type,
956
+ resourceType: DeploymentResource.Type,
908
957
  physicalName: name
909
958
  });
910
959
  const { target, created } = yield* Effect.gen(function* () {
@@ -962,7 +1011,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
962
1011
  };
963
1012
  });
964
1013
  const previousEnvNames = output?.name === target.name ? envNamesOf(output) : [];
965
- const before = yield* wantedHash(news, target.name);
1014
+ const before = yield* wantedHash(news, rootDir, target.name);
966
1015
  if (before === void 0 && previousEnvNames.length === 0) return {
967
1016
  ...target,
968
1017
  hash: void 0,
@@ -976,6 +1025,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
976
1025
  const options = {
977
1026
  deployment: target.name,
978
1027
  url: target.url,
1028
+ rootDir,
979
1029
  source: news.source,
980
1030
  env,
981
1031
  previousEnvNames,
@@ -993,7 +1043,7 @@ const DeploymentProvider = () => Provider.effect(Deployment, Effect.gen(function
993
1043
  }))).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));
994
1044
  return {
995
1045
  ...target,
996
- hash: yield* wantedHash(news, target.name),
1046
+ hash: yield* wantedHash(news, rootDir, target.name),
997
1047
  envNames: Object.keys(env).sort()
998
1048
  };
999
1049
  }),
@@ -1327,7 +1377,126 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
1327
1377
  * default lookup (`CONVEX_ACCESS_TOKEN`, then the Convex CLI login, and
1328
1378
  * `CONVEX_DEPLOY_KEY`).
1329
1379
  */
1330
- const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([Project, Deployment])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.orDie);
1380
+ const providers = (credentials = fromConfig()) => Layer.effect(Providers, Provider.collection([Project, DeploymentResource])).pipe(Layer.provide(Layer.mergeAll(ProjectProvider(), DeploymentProvider())), Layer.provide(ManagementApiLive()), Layer.provide(credentials), Layer.orDie);
1381
+ //#endregion
1382
+ //#region src/RunRecord.ts
1383
+ /** The key of the record in the stage output document. */
1384
+ const RUN_RECORD_KEY = "samebase.run";
1385
+ /** 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. */
1386
+ const STACK_FILE_NAME = "alchemy.run.ts";
1387
+ var RunRecordError = class extends Schema.TaggedError()("RunRecordError", { reason: Schema.String }) {
1388
+ get message() {
1389
+ return `The run record of the stage output cannot be made: ${this.reason}`;
1390
+ }
1391
+ };
1392
+ /** The part of the GitHub Actions event payload that the record reads. A push event has no `pull_request`. */
1393
+ const EventPayload = Schema.Struct({ pull_request: Schema.optionalKey(Schema.Struct({
1394
+ number: Schema.Number,
1395
+ head: Schema.Struct({ sha: Schema.String })
1396
+ })) });
1397
+ /** `owner/name` of an https, ssh, or scp-like git URL with a host. A local path is not one. */
1398
+ const REMOTE_URL = /^(?:[a-z+]+:\/\/[^/]+\/|[^/@:]+@[^:/]+:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
1399
+ const execFileAsync = promisify(execFile);
1400
+ const now = Clock.currentTimeMillis.pipe(Effect.map((millis) => new Date(millis).toISOString()));
1401
+ const git = (cwd, args) => Effect.tryPromise({
1402
+ try: async () => (await execFileAsync("git", [
1403
+ "-C",
1404
+ cwd,
1405
+ ...args
1406
+ ], { encoding: "utf8" })).stdout.trim(),
1407
+ catch: (error) => new RunRecordError({ reason: `git ${args.join(" ")} failed in ${cwd} (${error instanceof Error ? error.message.split("\n")[0] : String(error)})` })
1408
+ });
1409
+ /** The stack file from the repository root: the working directory below the git top level, plus {@link STACK_FILE_NAME}. */
1410
+ const stackFileOf = (cwd) => Effect.gen(function* () {
1411
+ const top = yield* git(cwd, ["rev-parse", "--show-toplevel"]);
1412
+ const [realTop, realCwd] = yield* Effect.tryPromise({
1413
+ try: () => Promise.all([realpath(top), realpath(cwd)]),
1414
+ catch: () => new RunRecordError({ reason: `${cwd} is gone` })
1415
+ });
1416
+ return [...relative(realTop, realCwd).split(sep).filter((part) => part !== ""), STACK_FILE_NAME].join("/");
1417
+ });
1418
+ const variable = (env, name) => Effect.suspend(() => {
1419
+ const value = env[name];
1420
+ return value === void 0 || value === "" ? Effect.fail(new RunRecordError({ reason: `GITHUB_ACTIONS is true, and ${name} is not set` })) : Effect.succeed(value);
1421
+ });
1422
+ const fromGitHubActions = (where) => Effect.gen(function* () {
1423
+ const { env } = where;
1424
+ const repository = yield* variable(env, "GITHUB_REPOSITORY");
1425
+ const refName = yield* variable(env, "GITHUB_REF_NAME");
1426
+ const sha = yield* variable(env, "GITHUB_SHA");
1427
+ const serverUrl = yield* variable(env, "GITHUB_SERVER_URL");
1428
+ const runId = yield* variable(env, "GITHUB_RUN_ID");
1429
+ const runAttempt = yield* variable(env, "GITHUB_RUN_ATTEMPT");
1430
+ const eventPath = env["GITHUB_EVENT_PATH"];
1431
+ const pullRequest = (eventPath === void 0 || eventPath === "" ? void 0 : yield* Effect.tryPromise({
1432
+ try: () => readFile(eventPath, "utf8"),
1433
+ catch: () => new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} cannot be read` })
1434
+ }).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;
1435
+ const headCommit = pullRequest?.head.sha ?? sha;
1436
+ const headRef = env["GITHUB_HEAD_REF"];
1437
+ const mergeRef = /^refs\/pull\/(\d+)\/merge$/.exec(env["GITHUB_REF"] ?? "");
1438
+ return {
1439
+ version: 1,
1440
+ repository,
1441
+ stackFile: yield* stackFileOf(where.cwd),
1442
+ branch: headRef === void 0 || headRef === "" ? refName : headRef,
1443
+ pullRequest: pullRequest?.number ?? (mergeRef === null ? null : Number(mergeRef[1])),
1444
+ headCommit,
1445
+ runUrl: `${serverUrl}/${repository}/actions/runs/${runId}/attempts/${runAttempt}`,
1446
+ source: "github-actions",
1447
+ recordedAt: yield* now
1448
+ };
1449
+ });
1450
+ const fromGit = (where) => Effect.gen(function* () {
1451
+ const { cwd } = where;
1452
+ const origin = yield* git(cwd, [
1453
+ "remote",
1454
+ "get-url",
1455
+ "origin"
1456
+ ]);
1457
+ const match = REMOTE_URL.exec(origin);
1458
+ 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}` });
1459
+ const branch = yield* git(cwd, [
1460
+ "rev-parse",
1461
+ "--abbrev-ref",
1462
+ "HEAD"
1463
+ ]);
1464
+ if (branch === "HEAD") return yield* new RunRecordError({ reason: `the checkout at ${cwd} is on no branch (detached HEAD)` });
1465
+ return {
1466
+ version: 1,
1467
+ repository: `${match[1]}/${match[2]}`,
1468
+ stackFile: yield* stackFileOf(cwd),
1469
+ branch,
1470
+ pullRequest: null,
1471
+ headCommit: yield* git(cwd, ["rev-parse", "HEAD"]),
1472
+ runUrl: null,
1473
+ source: "laptop",
1474
+ recordedAt: yield* now
1475
+ };
1476
+ });
1477
+ /**
1478
+ * The record of this run: from the GitHub Actions variables and the event
1479
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
1480
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
1481
+ * missing; nothing is guessed.
1482
+ */
1483
+ const runRecord = (where) => where.env["GITHUB_ACTIONS"] === "true" ? fromGitHubActions(where) : fromGit(where);
1484
+ /** 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. */
1485
+ const isPlainObject = (value) => {
1486
+ if (typeof value !== "object" || value === null) return false;
1487
+ const prototype = Object.getPrototypeOf(value);
1488
+ return prototype === Object.prototype || prototype === null;
1489
+ };
1490
+ /**
1491
+ * The output document of a stage: the stack's output plus the record under
1492
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
1493
+ * key: a string, an array, null, or an object that is not plain, such as a
1494
+ * Date. A value that the stack itself put under the key stays.
1495
+ */
1496
+ const withRunRecord = (output, record) => isPlainObject(output) ? "samebase.run" in output ? output : {
1497
+ ...output,
1498
+ [RUN_RECORD_KEY]: record
1499
+ } : void 0;
1331
1500
  //#endregion
1332
1501
  //#region src/State.ts
1333
1502
  /** The name of the state project in each team. */
@@ -1449,10 +1618,11 @@ const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..",
1449
1618
  */
1450
1619
  const pushStore = (options, deployment) => Effect.gen(function* () {
1451
1620
  const cli = yield* installedCli(join(options.store, ".."));
1452
- yield* Effect.logInfo(`Convex state: pushing the store version 1 to ${deployment.name}`);
1621
+ yield* Effect.logInfo(`Convex state: pushing the store version 2 to ${deployment.name}`);
1453
1622
  yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => push(options.spawner, key, {
1454
1623
  deployment: deployment.name,
1455
1624
  url: deployment.deploymentUrl,
1625
+ rootDir: join(options.store, ".."),
1456
1626
  source: {
1457
1627
  path: options.store,
1458
1628
  command: [
@@ -1512,16 +1682,31 @@ const makeConvexState = (options) => Effect.gen(function* () {
1512
1682
  return next.call(path, args);
1513
1683
  })))) };
1514
1684
  const version = yield* storeVersion(functions);
1515
- if (version === void 0 || version < 1) {
1685
+ if (version === void 0 || version < 2) {
1516
1686
  if (version === void 0 && deployment.lastDeployTime) return yield* failure(`project ${STORE_PROJECT_NAME} (${project.id}) of team ${team} has code that is not the state store, and Convex.state does not push over it. Rename that project in the Convex dashboard.`);
1517
1687
  const now = yield* storeVersion(functions);
1518
- if (now === void 0 || now < 1) {
1688
+ if (now === void 0 || now < 2) {
1519
1689
  yield* pushStore(options, deployment);
1520
1690
  const pushed = yield* storeVersion(functions);
1521
- if (pushed === void 0 || pushed < 1) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 1.`);
1691
+ if (pushed === void 0 || pushed < 2) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 2.`);
1522
1692
  }
1523
- } else if (version > 1) yield* Effect.logInfo(`Convex state: the store of team ${team} is version ${version}, newer than version 1 of this package, and it stays.`);
1693
+ } else if (version > 2) yield* Effect.logInfo(`Convex state: the store of team ${team} is version ${version}, newer than version 2 of this package, and it stays.`);
1524
1694
  const remote = remoteOf(functions);
1695
+ /** The stack's output plus the record, when the run has one and the output has a place for it. */
1696
+ const documentOf = (request) => Effect.gen(function* () {
1697
+ const { run } = options;
1698
+ if (run === void 0) return request.value;
1699
+ const recordedAt = new Date(yield* Clock.currentTimeMillis).toISOString();
1700
+ const document = withRunRecord(request.value, {
1701
+ ...run,
1702
+ recordedAt
1703
+ });
1704
+ if (document === void 0) {
1705
+ yield* Effect.logWarning(`Convex state: the output of ${request.stack}/${request.stage} is not a plain object, so it carries no run record.`);
1706
+ return request.value;
1707
+ }
1708
+ return document;
1709
+ });
1525
1710
  return {
1526
1711
  id: "convex",
1527
1712
  getVersion: () => Effect.succeed(STATE_STORE_VERSION),
@@ -1534,7 +1719,15 @@ const makeConvexState = (options) => Effect.gen(function* () {
1534
1719
  set: (request) => remote.set(request).pipe(Effect.as(request.value)),
1535
1720
  delete: (request) => remote.delete(request).pipe(Effect.asVoid),
1536
1721
  deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
1537
- setOutput: (request) => remote.setOutput(request).pipe(Effect.as(request.value))
1722
+ setOutput: (request) => Effect.gen(function* () {
1723
+ const value = yield* documentOf(request);
1724
+ yield* remote.setOutput({
1725
+ stack: request.stack,
1726
+ stage: request.stage,
1727
+ value
1728
+ });
1729
+ return value;
1730
+ })
1538
1731
  };
1539
1732
  });
1540
1733
  /**
@@ -1551,16 +1744,21 @@ const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
1551
1744
  const context = yield* Effect.context();
1552
1745
  return yield* Effect.cached(Effect.gen(function* () {
1553
1746
  const api = yield* ManagementApi.pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
1747
+ const run = yield* runRecord({
1748
+ cwd: process.cwd(),
1749
+ env: process.env
1750
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Convex state: the stage outputs of this run carry no record. ${error.message}`).pipe(Effect.as(void 0))));
1554
1751
  return yield* makeConvexState({
1555
1752
  team: options.team,
1556
1753
  api,
1557
1754
  spawner,
1558
1755
  scope,
1559
- store: storeDirectory()
1756
+ store: storeDirectory(),
1757
+ run
1560
1758
  });
1561
1759
  }).pipe(Effect.provideContext(context), Effect.orDie));
1562
1760
  }));
1563
1761
  //#endregion
1564
- export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, STORE_PROJECT_NAME, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, state };
1762
+ export { ACCESS_TOKEN_ENV, AbsoluteRootDir, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, RUN_RECORD_KEY, RunRecordError, STORE_PROJECT_NAME, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, runRecord, state, withRunRecord };
1565
1763
 
1566
1764
  //# sourceMappingURL=index.js.map