@samebase/alchemy-convex 0.6.0 → 0.7.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/README.md CHANGED
@@ -107,8 +107,11 @@ export default Alchemy.Stack(
107
107
  );
108
108
  ```
109
109
 
110
- Every run needs the team token in `CONVEX_ACCESS_TOKEN`, or the Convex CLI login on a laptop. The
111
- first run of a team creates the project `alchemy-state` and pushes the store functions to its
110
+ Every run needs the team token in `CONVEX_ACCESS_TOKEN`, or the Convex CLI login on a laptop. A
111
+ team access token names its team, so `Convex.state()` needs no `team` with one; a `team` slug that
112
+ is set must be the slug of that team, checked against its projects, and a token of another team
113
+ fails the run. The Convex CLI login needs the slug or the id. The first run of a team creates
114
+ the project `alchemy-state` and pushes the store functions to its
112
115
  production deployment with the Convex CLI of the app; a later version of this package pushes a
113
116
  newer store when it finds an older one, and never an older one over a newer one. Each run makes a
114
117
  deploy key of that deployment that can only run the store's internal functions, and deletes it at
@@ -116,6 +119,39 @@ the end. Rows are in the tables `resources` and `outputs` in the dashboard, unde
116
119
  The store has no lock: run one deploy of a stage at a time, such as with a CI concurrency group.
117
120
  The names of the store functions are a wire contract: never renamed or removed.
118
121
 
122
+ ### What the output records about a run
123
+
124
+ Alchemy state holds no time, commit, branch, or run. `Convex.state()` adds them to the stage output:
125
+ the document that `setOutput` stores is the stack's output plus the key `samebase.run`. On GitHub
126
+ Actions the record comes from the run's variables and the event payload; on a laptop from git, which
127
+ needs a branch and an `origin` remote. It holds no secret.
128
+
129
+ ```json
130
+ {
131
+ "convexUrl": "https://modest-guanaco-252.convex.cloud",
132
+ "samebase.run": {
133
+ "version": 1,
134
+ "repository": "samebase/base-alchemy",
135
+ "stackFile": "alchemy.run.ts",
136
+ "branch": "feature",
137
+ "pullRequest": 42,
138
+ "headCommit": "8af3c5b7932c528939cbc7aa0597b1fc36c8dc83",
139
+ "runUrl": "https://github.com/samebase/base-alchemy/actions/runs/12345678901/attempts/1",
140
+ "source": "github-actions",
141
+ "recordedAt": "2026-10-21T10:00:00.000Z"
142
+ }
143
+ }
144
+ ```
145
+
146
+ `stackFile` is the working directory of the run below the repository root plus `alchemy.run.ts`.
147
+ `pullRequest` and `runUrl` are `null` for a push and for a laptop, where `source` is `laptop`. A
148
+ value that the stack itself returns under `samebase.run` stays. A checkout that gives no record,
149
+ such as a tarball without git or a detached HEAD, writes its outputs without one, and the log says
150
+ why. Alchemy writes the output once after a full successful deploy, so a failed deploy leaves the
151
+ previous record, and a deploy of selected resources writes none. A reader gets every stage of a
152
+ stack, with its resources and output, from the store function `state:readStack({ stack })` in one
153
+ call.
154
+
119
155
  ## Resources
120
156
 
121
157
  - `Convex.Project`: `{ team, name, deploymentRegion?, deploymentType? }` creates a project with its
package/lib/index.d.ts CHANGED
@@ -78,8 +78,14 @@ interface ManagementApiService {
78
78
  readonly requestVoid: (operation: string, run: (client: ManagementClient) => Promise<FetchResult<unknown>>) => Effect.Effect<void, ConvexApiError>;
79
79
  /** Run one deployment-scoped call authenticated with a deploy key. */
80
80
  readonly deploymentRequest: <T>(operation: string, deploymentUrlOrName: string, deployKey: Redacted.Redacted<string>, run: (client: DeploymentClient) => Promise<FetchResult<T>>) => Effect.Effect<T, ConvexApiError>;
81
- /** Team id for a slug or numeric id. */
82
- readonly resolveTeamId: (team: string | number) => Effect.Effect<number, ConvexApiError>;
81
+ /**
82
+ * Team id for a numeric id, a slug, or nothing. A team access token names
83
+ * its team (`GET /token_details`); a slug is then checked against the
84
+ * `teamSlug` of the team's projects, so a token of another team fails. The
85
+ * Convex CLI login and a personal token answer 403 there, so a slug goes to
86
+ * the dashboard's team list, and nothing is an error.
87
+ */
88
+ readonly resolveTeamId: (team: string | number | undefined) => Effect.Effect<number, ConvexApiError>;
83
89
  /**
84
90
  * True when a deploy key that Convex returned carries the token of the
85
91
  * Management API credential itself. With an OAuth token, create_deploy_key
@@ -341,18 +347,68 @@ export declare class ProjectNotFound extends ProjectNotFound_base {
341
347
  get message(): string;
342
348
  }
343
349
  //#endregion
350
+ //#region src/RunRecord.d.ts
351
+ /** The key of the record in the stage output document. */
352
+ export declare const RUN_RECORD_KEY = "samebase.run";
353
+ /** Version 1 of the record. A reader validates `version` first. */
354
+ interface RunRecord {
355
+ readonly version: 1;
356
+ /** GitHub `owner/name`. */
357
+ readonly repository: string;
358
+ /** Path of the stack file from the repository root, POSIX separators, such as `apps/samebase/alchemy.run.ts`. */
359
+ readonly stackFile: string;
360
+ /** The branch that was deployed: the head branch of a pull request, else the pushed branch. */
361
+ readonly branch: string;
362
+ /** The pull request whose head was deployed, or null for a push and for a laptop. */
363
+ readonly pullRequest: number | null;
364
+ /** The commit that was deployed: the head of the pull request, else GITHUB_SHA, else git HEAD. */
365
+ readonly headCommit: string;
366
+ /** The GitHub Actions run and attempt, or null for a laptop. */
367
+ readonly runUrl: string | null;
368
+ readonly source: "github-actions" | "laptop";
369
+ /** ISO 8601, when the record was written. */
370
+ readonly recordedAt: string;
371
+ }
372
+ declare const RunRecordError_base: Schema.Class<RunRecordError, Schema.TaggedStruct<"RunRecordError", {
373
+ readonly reason: Schema.String;
374
+ }>, import("effect/Cause").YieldableError>;
375
+ export declare class RunRecordError extends RunRecordError_base {
376
+ get message(): string;
377
+ }
378
+ /** Where the run happens: the working directory of the stack and the process environment. */
379
+ interface RunEnvironment {
380
+ readonly cwd: string;
381
+ readonly env: Readonly<Record<string, string | undefined>>;
382
+ }
383
+ /**
384
+ * The record of this run: from the GitHub Actions variables and the event
385
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
386
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
387
+ * missing; nothing is guessed.
388
+ */
389
+ export declare const runRecord: (where: RunEnvironment) => Effect.Effect<RunRecord, RunRecordError>;
390
+ /**
391
+ * The output document of a stage: the stack's output plus the record under
392
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
393
+ * key: a string, an array, null, or an object that is not plain, such as a
394
+ * Date. A value that the stack itself put under the key stays.
395
+ */
396
+ export declare const withRunRecord: (output: unknown, record: RunRecord) => object | undefined;
397
+ //#endregion
344
398
  //#region src/State.d.ts
345
399
  /** The name of the state project in each team. */
346
400
  export declare const STORE_PROJECT_NAME = "alchemy-state";
347
401
  /**
348
402
  * Alchemy state in the `alchemy-state` project of `team`, with the team token
349
- * in CONVEX_ACCESS_TOKEN or the Convex CLI login. The first use of the run
350
- * finds or creates the project and pushes the store when it is missing or
351
- * older. `alchemy plan` on another stack never touches Convex.
403
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
404
+ * its team, so `team` is optional with one; the Convex CLI login needs the
405
+ * slug or the id. The first use of the run finds or creates the project and
406
+ * pushes the store when it is missing or older. `alchemy plan` on another
407
+ * stack never touches Convex.
352
408
  */
353
- export declare const state: (options: {
354
- readonly team: string | number;
409
+ export declare const state: (options?: {
410
+ readonly team?: string | number;
355
411
  }) => Layer.Layer<State, never, ChildProcessSpawner.ChildProcessSpawner>;
356
412
  //#endregion
357
- export type { DeploymentAttributes, DeploymentProps, DeploymentRegion, EnvValue, ProjectAttributes, ProjectProps, Source };
413
+ export type { DeploymentAttributes, DeploymentProps, DeploymentRegion, EnvValue, ProjectAttributes, ProjectProps, RunRecord, Source };
358
414
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Project.ts","../src/State.ts"],"mappings":";;;;;;;;;;;;;UA6BiB;;WAEN,aAAa,SAAS;;WAEtB;;UAGM;;WAEN,aAAa,OAAO,OAAO;;WAE3B,gBAAgB,OAAO;;WAEvB,WAAW,OAAO,OAAO,SAAS;;;qBAGhC,oBAAoB;;qBAKpB;;qBAGA;;;;qBAOA,yBAAyB;;;;;;;;;qBAiCzB,kBAAiB,MAAM,MAAM;;qBA0B7B,aAAc;WAChB,cAAc,SAAS;WACvB,YAAY,SAAS;MAC5B,MAAM,MAAM;;;KC3GJ,mBAAmB,kBAAkB;KACrC,mBAAmB,kBAAkB;;UAGhC,YAAY;WAClB,OAAO;WACP;WACA,UAAU;;;;;;;;;;;;;qBAQR,uBAAuB;UAuBnB;;WAEN,UAAU,GACjB,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,cACP,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,0BACxC,OAAO,aAAa;;WAEhB,oBAAoB,GAC3B,mBACA,6BACA,WAAW,SAAS,kBACpB,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,gBAAgB,0BAA0B,OAAO,eAAe;;;;;;;;WAQhE,oBAAoB,mBAAmB,OAAO;;;;;WAK9C,YAAY,OAAO,SAAS,qBAAqB;;;qBAM/C,sBAAsB;;;;qBClFtB,kBAAkB;;;;;;qBAOlB,YAAa,cAAa,MAAM,MAAM,iBAA2B,MAAA,MAAA,8CAAA,mCAAA,oDAAA;;;;KC+BlE,oBAAoB,SAAS;;;;;KAM7B;;WAIG;;;;;;;;WAQA;;;;;;;WAOA;;;;;;qBAkCF,0BAA0B;MAIxB;;;;;;qBAMF,0BAA0B;MAIxB;;;;;;;;;qBAMF,uBAAuB;MAMrB;;;;;;;qBAQF,2BAA2B;MAIzB;;;;;;;qBAMF,oCAAoC;MAIlC;;;;;;qBAMF,kCAAkC;MAIhC;;;qBAuCF,oBACX;EACG;EAAa;;;;UChIR;;WAEC;aAAoB;;;;;;;;WAOpB,SAAS;;WAET,MAAM,SAAS,eAAe;;KAG7B,mBACP;;WAEU;MAEV;;WAEU;;;;;WAKA;;;UAIL;WACC;WACA,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;UAGM,6BAA6B;;WAEnC;;WAEA;;YAGC,aAAa,8BAEvB,iBACA,6BAEA;qBAEW,8BAAU,cAAA;;;;;;;;;qBAKV,iCAAiC;MAU/B;;;;;;qBAMF,qCAAqC;MAInC;;;;;;;;;qBAMF,4BAA4B;MAS1B;;;;;KC5IH,mBAAmB;KAEnB;;WAGG;;WAEA;;WAEA,mBAAmB;;WAEnB;;;WAIA;;WAEA;;UAGE;WACN;WACA;WACA;WACA;WACA;WACA;;YAGC,UAAU,2BAA2B,cAAc,0BAA0B;qBAC5E,2BAAO,cAAA;;;;;;;qBAGP,0BAA0B;MAIxB;;;;;;;;;;;;qBASF,gCAAgC;MAS9B;;;;;;;;qBAOF,yBAAyB;MAKvB;;;;;;qBAMF,+BAA+B;MAI7B;;;;;;;qBAMF,wBAAwB;MAItB;;;;;qBChDF;;;;;;;qBA8UA,QAAS;WACX;MACP,MAAM,MAAM,cAAc,oBAAoB"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../src/Credentials.ts","../src/ManagementApi.ts","../src/Providers.ts","../src/Push.ts","../src/Deployment.ts","../src/Project.ts","../src/RunRecord.ts","../src/State.ts"],"mappings":";;;;;;;;;;;;;UA6BiB;;WAEN,aAAa,SAAS;;WAEtB;;UAGM;;WAEN,aAAa,OAAO,OAAO;;WAE3B,gBAAgB,OAAO;;WAEvB,WAAW,OAAO,OAAO,SAAS;;;qBAGhC,oBAAoB;;qBAKpB;;qBAGA;;;;qBAOA,yBAAyB;;;;;;;;;qBAiCzB,kBAAiB,MAAM,MAAM;;qBA0B7B,aAAc;WAChB,cAAc,SAAS;WACvB,YAAY,SAAS;MAC5B,MAAM,MAAM;;;KC3GJ,mBAAmB,kBAAkB;KACrC,mBAAmB,kBAAkB;;UAGhC,YAAY;WAClB,OAAO;WACP;WACA,UAAU;;;;;;;;;;;;;qBAQR,uBAAuB;UAuBnB;;WAEN,UAAU,GACjB,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;WAEb,cACP,mBACA,MAAM,QAAQ,qBAAqB,QAAQ,0BACxC,OAAO,aAAa;;WAEhB,oBAAoB,GAC3B,mBACA,6BACA,WAAW,SAAS,kBACpB,MAAM,QAAQ,qBAAqB,QAAQ,YAAY,QACpD,OAAO,OAAO,GAAG;;;;;;;;WAQb,gBACP,sCACG,OAAO,eAAe;;;;;;;;WAQlB,oBAAoB,mBAAmB,OAAO;;;;;WAK9C,YAAY,OAAO,SAAS,qBAAqB;;;qBAM/C,sBAAsB;;;;qBC1FtB,kBAAkB;;;;;;qBAOlB,YAAa,cAAa,MAAM,MAAM,iBAA2B,MAAA,MAAA,8CAAA,mCAAA,oDAAA;;;;KC+BlE,oBAAoB,SAAS;;;;;KAM7B;;WAIG;;;;;;;;WAQA;;;;;;;WAOA;;;;;;qBAkCF,0BAA0B;MAIxB;;;;;;qBAMF,0BAA0B;MAIxB;;;;;;;;;qBAMF,uBAAuB;MAMrB;;;;;;;qBAQF,2BAA2B;MAIzB;;;;;;;qBAMF,oCAAoC;MAIlC;;;;;;qBAMF,kCAAkC;MAIhC;;;qBAuCF,oBACX;EACG;EAAa;;;;UChIR;;WAEC;aAAoB;;;;;;;;WAOpB,SAAS;;WAET,MAAM,SAAS,eAAe;;KAG7B,mBACP;;WAEU;MAEV;;WAEU;;;;;WAKA;;;UAIL;WACC;WACA,MAAM;;WAEN;;WAEA;;WAEA;;WAEA;;WAEA;;UAGM,6BAA6B;;WAEnC;;WAEA;;YAGC,aAAa,8BAEvB,iBACA,6BAEA;qBAEW,8BAAU,cAAA;;;;;;;;;qBAKV,iCAAiC;MAU/B;;;;;;qBAMF,qCAAqC;MAInC;;;;;;;;;qBAMF,4BAA4B;MAS1B;;;;;KC5IH,mBAAmB;KAEnB;;WAGG;;WAEA;;WAEA,mBAAmB;;WAEnB;;;WAIA;;WAEA;;UAGE;WACN;WACA;WACA;WACA;WACA;WACA;;YAGC,UAAU,2BAA2B,cAAc,0BAA0B;qBAC5E,2BAAO,cAAA;;;;;;;qBAGP,0BAA0B;MAIxB;;;;;;;;;;;;qBASF,gCAAgC;MAS9B;;;;;;;;qBAOF,yBAAyB;MAKvB;;;;;;qBAMF,+BAA+B;MAI7B;;;;;;;qBAMF,wBAAwB;MAItB;;;;;qBC5GF;;UAMI;WACN;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;;WAEA;WACA;;WAEA;;;;;qBAGE,uBAAuB;MAGrB;;;UAME;WACN;WACA,KAAK,SAAS;;;;;;;;qBAuIZ,YAAa,OAAO,mBAAiB,OAAO,OAAO,WAAW;;;;;;;qBAgB9D,gBAAiB,iBAAiB,QAAQ;;;;qBCxH1C;;;;;;;;;qBAwWA,QACX;WAAoB;MACnB,MAAM,MAAM,cAAc,oBAAoB"}
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, relative, resolve, sep } 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";
@@ -169,15 +170,54 @@ const TeamRow = Schema.Struct({
169
170
  slug: Schema.String
170
171
  });
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
+ })]);
172
183
  /** The service for one way to get the bearer token. */
173
184
  const makeService = (credentials) => {
174
185
  const client = Effect.cached(credentials.pipe(Effect.map((resolved) => createManagementClient(Redacted.value(resolved.accessToken))))).pipe(Effect.flatten);
175
186
  const request = (operation, run) => client.pipe(Effect.flatMap((c) => settle(operation, () => run(c))));
176
187
  const requestVoid = (operation, run) => client.pipe(Effect.flatMap((c) => settleVoid(operation, () => run(c))));
177
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));
178
192
  const resolveTeamId = (team) => Effect.gen(function* () {
179
193
  if (typeof team === "number") return team;
180
- if (/^\d+$/.test(team)) return Number(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
+ });
181
221
  const resolved = yield* credentials;
182
222
  const match = (yield* Effect.tryPromise({
183
223
  try: async () => {
@@ -1290,6 +1330,125 @@ var Providers = class extends Provider.ProviderCollection()("Convex") {};
1290
1330
  */
1291
1331
  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);
1292
1332
  //#endregion
1333
+ //#region src/RunRecord.ts
1334
+ /** The key of the record in the stage output document. */
1335
+ const RUN_RECORD_KEY = "samebase.run";
1336
+ /** 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. */
1337
+ const STACK_FILE_NAME = "alchemy.run.ts";
1338
+ var RunRecordError = class extends Schema.TaggedError()("RunRecordError", { reason: Schema.String }) {
1339
+ get message() {
1340
+ return `The run record of the stage output cannot be made: ${this.reason}`;
1341
+ }
1342
+ };
1343
+ /** The part of the GitHub Actions event payload that the record reads. A push event has no `pull_request`. */
1344
+ const EventPayload = Schema.Struct({ pull_request: Schema.optionalKey(Schema.Struct({
1345
+ number: Schema.Number,
1346
+ head: Schema.Struct({ sha: Schema.String })
1347
+ })) });
1348
+ /** `owner/name` of an https, ssh, or scp-like git URL with a host. A local path is not one. */
1349
+ const REMOTE_URL = /^(?:[a-z+]+:\/\/[^/]+\/|[^/@:]+@[^:/]+:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
1350
+ const execFileAsync = promisify(execFile);
1351
+ const now = Clock.currentTimeMillis.pipe(Effect.map((millis) => new Date(millis).toISOString()));
1352
+ const git = (cwd, args) => Effect.tryPromise({
1353
+ try: async () => (await execFileAsync("git", [
1354
+ "-C",
1355
+ cwd,
1356
+ ...args
1357
+ ], { encoding: "utf8" })).stdout.trim(),
1358
+ catch: (error) => new RunRecordError({ reason: `git ${args.join(" ")} failed in ${cwd} (${error instanceof Error ? error.message.split("\n")[0] : String(error)})` })
1359
+ });
1360
+ /** The stack file from the repository root: the working directory below the git top level, plus {@link STACK_FILE_NAME}. */
1361
+ const stackFileOf = (cwd) => Effect.gen(function* () {
1362
+ const top = yield* git(cwd, ["rev-parse", "--show-toplevel"]);
1363
+ const [realTop, realCwd] = yield* Effect.tryPromise({
1364
+ try: () => Promise.all([realpath(top), realpath(cwd)]),
1365
+ catch: () => new RunRecordError({ reason: `${cwd} is gone` })
1366
+ });
1367
+ return [...relative(realTop, realCwd).split(sep).filter((part) => part !== ""), STACK_FILE_NAME].join("/");
1368
+ });
1369
+ const variable = (env, name) => Effect.suspend(() => {
1370
+ const value = env[name];
1371
+ return value === void 0 || value === "" ? Effect.fail(new RunRecordError({ reason: `GITHUB_ACTIONS is true, and ${name} is not set` })) : Effect.succeed(value);
1372
+ });
1373
+ const fromGitHubActions = (where) => Effect.gen(function* () {
1374
+ const { env } = where;
1375
+ const repository = yield* variable(env, "GITHUB_REPOSITORY");
1376
+ const refName = yield* variable(env, "GITHUB_REF_NAME");
1377
+ const sha = yield* variable(env, "GITHUB_SHA");
1378
+ const serverUrl = yield* variable(env, "GITHUB_SERVER_URL");
1379
+ const runId = yield* variable(env, "GITHUB_RUN_ID");
1380
+ const runAttempt = yield* variable(env, "GITHUB_RUN_ATTEMPT");
1381
+ const eventPath = env["GITHUB_EVENT_PATH"];
1382
+ const pullRequest = (eventPath === void 0 || eventPath === "" ? void 0 : yield* Effect.tryPromise({
1383
+ try: () => readFile(eventPath, "utf8"),
1384
+ catch: () => new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} cannot be read` })
1385
+ }).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;
1386
+ const headCommit = pullRequest?.head.sha ?? sha;
1387
+ const headRef = env["GITHUB_HEAD_REF"];
1388
+ const mergeRef = /^refs\/pull\/(\d+)\/merge$/.exec(env["GITHUB_REF"] ?? "");
1389
+ return {
1390
+ version: 1,
1391
+ repository,
1392
+ stackFile: yield* stackFileOf(where.cwd),
1393
+ branch: headRef === void 0 || headRef === "" ? refName : headRef,
1394
+ pullRequest: pullRequest?.number ?? (mergeRef === null ? null : Number(mergeRef[1])),
1395
+ headCommit,
1396
+ runUrl: `${serverUrl}/${repository}/actions/runs/${runId}/attempts/${runAttempt}`,
1397
+ source: "github-actions",
1398
+ recordedAt: yield* now
1399
+ };
1400
+ });
1401
+ const fromGit = (where) => Effect.gen(function* () {
1402
+ const { cwd } = where;
1403
+ const origin = yield* git(cwd, [
1404
+ "remote",
1405
+ "get-url",
1406
+ "origin"
1407
+ ]);
1408
+ const match = REMOTE_URL.exec(origin);
1409
+ 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}` });
1410
+ const branch = yield* git(cwd, [
1411
+ "rev-parse",
1412
+ "--abbrev-ref",
1413
+ "HEAD"
1414
+ ]);
1415
+ if (branch === "HEAD") return yield* new RunRecordError({ reason: `the checkout at ${cwd} is on no branch (detached HEAD)` });
1416
+ return {
1417
+ version: 1,
1418
+ repository: `${match[1]}/${match[2]}`,
1419
+ stackFile: yield* stackFileOf(cwd),
1420
+ branch,
1421
+ pullRequest: null,
1422
+ headCommit: yield* git(cwd, ["rev-parse", "HEAD"]),
1423
+ runUrl: null,
1424
+ source: "laptop",
1425
+ recordedAt: yield* now
1426
+ };
1427
+ });
1428
+ /**
1429
+ * The record of this run: from the GitHub Actions variables and the event
1430
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
1431
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
1432
+ * missing; nothing is guessed.
1433
+ */
1434
+ const runRecord = (where) => where.env["GITHUB_ACTIONS"] === "true" ? fromGitHubActions(where) : fromGit(where);
1435
+ /** 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. */
1436
+ const isPlainObject = (value) => {
1437
+ if (typeof value !== "object" || value === null) return false;
1438
+ const prototype = Object.getPrototypeOf(value);
1439
+ return prototype === Object.prototype || prototype === null;
1440
+ };
1441
+ /**
1442
+ * The output document of a stage: the stack's output plus the record under
1443
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
1444
+ * key: a string, an array, null, or an object that is not plain, such as a
1445
+ * Date. A value that the stack itself put under the key stays.
1446
+ */
1447
+ const withRunRecord = (output, record) => isPlainObject(output) ? "samebase.run" in output ? output : {
1448
+ ...output,
1449
+ [RUN_RECORD_KEY]: record
1450
+ } : void 0;
1451
+ //#endregion
1293
1452
  //#region src/State.ts
1294
1453
  /** The name of the state project in each team. */
1295
1454
  const STORE_PROJECT_NAME = "alchemy-state";
@@ -1410,7 +1569,7 @@ const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..",
1410
1569
  */
1411
1570
  const pushStore = (options, deployment) => Effect.gen(function* () {
1412
1571
  const cli = yield* installedCli(join(options.store, ".."));
1413
- yield* Effect.logInfo(`Convex state: pushing the store version 1 to ${deployment.name}`);
1572
+ yield* Effect.logInfo(`Convex state: pushing the store version 2 to ${deployment.name}`);
1414
1573
  yield* withPushKey(options.api, deployment.name, PUSH_KEY_ACTIONS, (key) => push(options.spawner, key, {
1415
1574
  deployment: deployment.name,
1416
1575
  url: deployment.deploymentUrl,
@@ -1455,8 +1614,9 @@ const createStoreProject = (api, teamId, team) => Effect.gen(function* () {
1455
1614
  * written with the keys of this run.
1456
1615
  */
1457
1616
  const makeConvexState = (options) => Effect.gen(function* () {
1458
- const { api, team } = options;
1459
- const teamId = yield* api.resolveTeamId(team);
1617
+ const { api } = options;
1618
+ const teamId = yield* api.resolveTeamId(options.team);
1619
+ const team = options.team ?? teamId;
1460
1620
  const project = (yield* findProjectByName(api, teamId, "alchemy-state")) ?? (yield* createStoreProject(api, teamId, team));
1461
1621
  const deployment = yield* prodDeploymentOf(api, project.id);
1462
1622
  if (deployment === void 0) return yield* new ProductionDeploymentNotFound({ projectId: project.id });
@@ -1472,16 +1632,31 @@ const makeConvexState = (options) => Effect.gen(function* () {
1472
1632
  return next.call(path, args);
1473
1633
  })))) };
1474
1634
  const version = yield* storeVersion(functions);
1475
- if (version === void 0 || version < 1) {
1635
+ if (version === void 0 || version < 2) {
1476
1636
  if (version === void 0 && deployment.lastDeployTime) return yield* failure(`project ${STORE_PROJECT_NAME} (${project.id}) of team ${team} has code that is not the state store, and Convex.state does not push over it. Rename that project in the Convex dashboard.`);
1477
1637
  const now = yield* storeVersion(functions);
1478
- if (now === void 0 || now < 1) {
1638
+ if (now === void 0 || now < 2) {
1479
1639
  yield* pushStore(options, deployment);
1480
1640
  const pushed = yield* storeVersion(functions);
1481
- if (pushed === void 0 || pushed < 1) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 1.`);
1641
+ if (pushed === void 0 || pushed < 2) return yield* failure(`${deployment.name} answers store version ${pushed ?? "none"} after the push of version 2.`);
1482
1642
  }
1483
- } else if (version > 1) yield* Effect.logInfo(`Convex state: the store of team ${team} is version ${version}, newer than version 1 of this package, and it stays.`);
1643
+ } 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.`);
1484
1644
  const remote = remoteOf(functions);
1645
+ /** The stack's output plus the record, when the run has one and the output has a place for it. */
1646
+ const documentOf = (request) => Effect.gen(function* () {
1647
+ const { run } = options;
1648
+ if (run === void 0) return request.value;
1649
+ const recordedAt = new Date(yield* Clock.currentTimeMillis).toISOString();
1650
+ const document = withRunRecord(request.value, {
1651
+ ...run,
1652
+ recordedAt
1653
+ });
1654
+ if (document === void 0) {
1655
+ yield* Effect.logWarning(`Convex state: the output of ${request.stack}/${request.stage} is not a plain object, so it carries no run record.`);
1656
+ return request.value;
1657
+ }
1658
+ return document;
1659
+ });
1485
1660
  return {
1486
1661
  id: "convex",
1487
1662
  getVersion: () => Effect.succeed(STATE_STORE_VERSION),
@@ -1494,31 +1669,46 @@ const makeConvexState = (options) => Effect.gen(function* () {
1494
1669
  set: (request) => remote.set(request).pipe(Effect.as(request.value)),
1495
1670
  delete: (request) => remote.delete(request).pipe(Effect.asVoid),
1496
1671
  deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
1497
- setOutput: (request) => remote.setOutput(request).pipe(Effect.as(request.value))
1672
+ setOutput: (request) => Effect.gen(function* () {
1673
+ const value = yield* documentOf(request);
1674
+ yield* remote.setOutput({
1675
+ stack: request.stack,
1676
+ stage: request.stage,
1677
+ value
1678
+ });
1679
+ return value;
1680
+ })
1498
1681
  };
1499
1682
  });
1500
1683
  /**
1501
1684
  * Alchemy state in the `alchemy-state` project of `team`, with the team token
1502
- * in CONVEX_ACCESS_TOKEN or the Convex CLI login. The first use of the run
1503
- * finds or creates the project and pushes the store when it is missing or
1504
- * older. `alchemy plan` on another stack never touches Convex.
1685
+ * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
1686
+ * its team, so `team` is optional with one; the Convex CLI login needs the
1687
+ * slug or the id. The first use of the run finds or creates the project and
1688
+ * pushes the store when it is missing or older. `alchemy plan` on another
1689
+ * stack never touches Convex.
1505
1690
  */
1506
- const state = (options) => Layer.effect(State, Effect.gen(function* () {
1691
+ const state = (options = {}) => Layer.effect(State, Effect.gen(function* () {
1507
1692
  const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
1508
1693
  const scope = yield* Scope.Scope;
1509
1694
  const context = yield* Effect.context();
1510
1695
  return yield* Effect.cached(Effect.gen(function* () {
1511
1696
  const api = yield* ManagementApi.pipe(Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))));
1697
+ const run = yield* runRecord({
1698
+ cwd: process.cwd(),
1699
+ env: process.env
1700
+ }).pipe(Effect.catch((error) => Effect.logWarning(`Convex state: the stage outputs of this run carry no record. ${error.message}`).pipe(Effect.as(void 0))));
1512
1701
  return yield* makeConvexState({
1513
1702
  team: options.team,
1514
1703
  api,
1515
1704
  spawner,
1516
1705
  scope,
1517
- store: storeDirectory()
1706
+ store: storeDirectory(),
1707
+ run
1518
1708
  });
1519
1709
  }).pipe(Effect.provideContext(context), Effect.orDie));
1520
1710
  }));
1521
1711
  //#endregion
1522
- export { ACCESS_TOKEN_ENV, AmbiguousDeployment, AmbiguousProject, CodePushFailed, ConvexApiError, ConvexAppNotFound, ConvexCliNotFound, CreatedProjectNotFound, Credentials, CredentialsError, DEPLOY_KEY_ENV, Deployment, DeploymentIdentityChange, EnvironmentNeedsDeployKey, ManagementApi, ProductionDeploymentNotFound, Project, ProjectDeploymentChange, ProjectNotFound, ProjectTeamChange, Providers, PushTargetMismatch, STORE_PROJECT_NAME, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, state };
1712
+ 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, RUN_RECORD_KEY, RunRecordError, STORE_PROJECT_NAME, VariablesOwnedBySomeoneElse, fromConfig, fromValues, parseDeployOutput, providers, runRecord, state, withRunRecord };
1523
1713
 
1524
1714
  //# sourceMappingURL=index.js.map