@samebase/alchemy-convex 0.6.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions",
5
5
  "keywords": [
6
6
  "alchemy",
@@ -0,0 +1,208 @@
1
+ // The run record: what the stage output says about the run that wrote it.
2
+ // Alchemy state holds no time, author, commit, branch, or run (measured in
3
+ // the Samebase research of 2026-10-21), so a state layer adds this record to
4
+ // the output document under the reserved key `samebase.run`. The record
5
+ // depends on no store: it comes from the GitHub Actions variables, or from
6
+ // git on a laptop, and holds no secret. See docs/design/convex-state.md.
7
+ import { execFile } from "node:child_process";
8
+ import { readFile, realpath } from "node:fs/promises";
9
+ import { relative, sep } from "node:path";
10
+ import { promisify } from "node:util";
11
+ import * as Clock from "effect/Clock";
12
+ import * as Effect from "effect/Effect";
13
+ import * as Schema from "effect/Schema";
14
+
15
+ /** The key of the record in the stage output document. */
16
+ export const RUN_RECORD_KEY = "samebase.run";
17
+
18
+ /** 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. */
19
+ export const STACK_FILE_NAME = "alchemy.run.ts";
20
+
21
+ /** Version 1 of the record. A reader validates `version` first. */
22
+ export interface RunRecord {
23
+ readonly version: 1;
24
+ /** GitHub `owner/name`. */
25
+ readonly repository: string;
26
+ /** Path of the stack file from the repository root, POSIX separators, such as `apps/samebase/alchemy.run.ts`. */
27
+ readonly stackFile: string;
28
+ /** The branch that was deployed: the head branch of a pull request, else the pushed branch. */
29
+ readonly branch: string;
30
+ /** The pull request whose head was deployed, or null for a push and for a laptop. */
31
+ readonly pullRequest: number | null;
32
+ /** The commit that was deployed: the head of the pull request, else GITHUB_SHA, else git HEAD. */
33
+ readonly headCommit: string;
34
+ /** The GitHub Actions run and attempt, or null for a laptop. */
35
+ readonly runUrl: string | null;
36
+ readonly source: "github-actions" | "laptop";
37
+ /** ISO 8601, when the record was written. */
38
+ readonly recordedAt: string;
39
+ }
40
+
41
+ export class RunRecordError extends Schema.TaggedError<RunRecordError>()("RunRecordError", {
42
+ reason: Schema.String,
43
+ }) {
44
+ override get message() {
45
+ return `The run record of the stage output cannot be made: ${this.reason}`;
46
+ }
47
+ }
48
+
49
+ /** Where the run happens: the working directory of the stack and the process environment. */
50
+ export interface RunEnvironment {
51
+ readonly cwd: string;
52
+ readonly env: Readonly<Record<string, string | undefined>>;
53
+ }
54
+
55
+ /** The part of the GitHub Actions event payload that the record reads. A push event has no `pull_request`. */
56
+ const EventPayload = Schema.Struct({
57
+ pull_request: Schema.optionalKey(
58
+ Schema.Struct({ number: Schema.Number, head: Schema.Struct({ sha: Schema.String }) }),
59
+ ),
60
+ });
61
+
62
+ /** `owner/name` of an https, ssh, or scp-like git URL with a host. A local path is not one. */
63
+ const REMOTE_URL = /^(?:[a-z+]+:\/\/[^/]+\/|[^/@:]+@[^:/]+:)([^/]+)\/([^/]+?)(?:\.git)?\/?$/;
64
+
65
+ const execFileAsync = promisify(execFile);
66
+
67
+ const now = Clock.currentTimeMillis.pipe(Effect.map((millis) => new Date(millis).toISOString()));
68
+
69
+ const git = (cwd: string, args: readonly string[]) =>
70
+ Effect.tryPromise({
71
+ try: async () =>
72
+ (await execFileAsync("git", ["-C", cwd, ...args], { encoding: "utf8" })).stdout.trim(),
73
+ catch: (error) =>
74
+ new RunRecordError({
75
+ reason: `git ${args.join(" ")} failed in ${cwd} (${error instanceof Error ? error.message.split("\n")[0] : String(error)})`,
76
+ }),
77
+ });
78
+
79
+ /** The stack file from the repository root: the working directory below the git top level, plus {@link STACK_FILE_NAME}. */
80
+ const stackFileOf = (cwd: string) =>
81
+ Effect.gen(function* () {
82
+ const top = yield* git(cwd, ["rev-parse", "--show-toplevel"]);
83
+ // git prints the real path, and the working directory can hold a symlink, such as macOS /tmp.
84
+ const [realTop, realCwd] = yield* Effect.tryPromise({
85
+ try: () => Promise.all([realpath(top), realpath(cwd)]),
86
+ catch: () => new RunRecordError({ reason: `${cwd} is gone` }),
87
+ });
88
+ const directory = relative(realTop, realCwd)
89
+ .split(sep)
90
+ .filter((part) => part !== "");
91
+ return [...directory, STACK_FILE_NAME].join("/");
92
+ });
93
+
94
+ const variable = (env: RunEnvironment["env"], name: string) =>
95
+ Effect.suspend(() => {
96
+ const value = env[name];
97
+ return value === undefined || value === ""
98
+ ? Effect.fail(
99
+ new RunRecordError({ reason: `GITHUB_ACTIONS is true, and ${name} is not set` }),
100
+ )
101
+ : Effect.succeed(value);
102
+ });
103
+
104
+ const fromGitHubActions = (where: RunEnvironment) =>
105
+ Effect.gen(function* () {
106
+ const { env } = where;
107
+ const repository = yield* variable(env, "GITHUB_REPOSITORY");
108
+ const refName = yield* variable(env, "GITHUB_REF_NAME");
109
+ const sha = yield* variable(env, "GITHUB_SHA");
110
+ const serverUrl = yield* variable(env, "GITHUB_SERVER_URL");
111
+ const runId = yield* variable(env, "GITHUB_RUN_ID");
112
+ const runAttempt = yield* variable(env, "GITHUB_RUN_ATTEMPT");
113
+ const eventPath = env["GITHUB_EVENT_PATH"];
114
+ const payload =
115
+ eventPath === undefined || eventPath === ""
116
+ ? undefined
117
+ : yield* Effect.tryPromise({
118
+ try: () => readFile(eventPath, "utf8"),
119
+ catch: () =>
120
+ new RunRecordError({ reason: `GITHUB_EVENT_PATH ${eventPath} cannot be read` }),
121
+ }).pipe(
122
+ Effect.flatMap(Schema.decodeUnknownEffect(Schema.fromJsonString(EventPayload))),
123
+ Effect.mapError((error) =>
124
+ error instanceof RunRecordError
125
+ ? error
126
+ : new RunRecordError({
127
+ reason: `GITHUB_EVENT_PATH ${eventPath} is not an event payload`,
128
+ }),
129
+ ),
130
+ );
131
+ const pullRequest = payload?.pull_request;
132
+ // GITHUB_SHA is the merge commit on a pull request event; the head of the pull request is what a person sees.
133
+ const headCommit = pullRequest?.head.sha ?? sha;
134
+ const headRef = env["GITHUB_HEAD_REF"];
135
+ const mergeRef = /^refs\/pull\/(\d+)\/merge$/.exec(env["GITHUB_REF"] ?? "");
136
+ const stackFile = yield* stackFileOf(where.cwd);
137
+ const record: RunRecord = {
138
+ version: 1,
139
+ repository,
140
+ stackFile,
141
+ branch: headRef === undefined || headRef === "" ? refName : headRef,
142
+ pullRequest: pullRequest?.number ?? (mergeRef === null ? null : Number(mergeRef[1])),
143
+ headCommit,
144
+ runUrl: `${serverUrl}/${repository}/actions/runs/${runId}/attempts/${runAttempt}`,
145
+ source: "github-actions",
146
+ recordedAt: yield* now,
147
+ };
148
+ return record;
149
+ });
150
+
151
+ const fromGit = (where: RunEnvironment) =>
152
+ Effect.gen(function* () {
153
+ const { cwd } = where;
154
+ const origin = yield* git(cwd, ["remote", "get-url", "origin"]);
155
+ const match = REMOTE_URL.exec(origin);
156
+ if (match === null) {
157
+ return yield* new RunRecordError({
158
+ reason: `the origin remote of ${cwd} is not a git URL with a host and owner/name: ${origin}`,
159
+ });
160
+ }
161
+ const branch = yield* git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
162
+ if (branch === "HEAD") {
163
+ return yield* new RunRecordError({
164
+ reason: `the checkout at ${cwd} is on no branch (detached HEAD)`,
165
+ });
166
+ }
167
+ const record: RunRecord = {
168
+ version: 1,
169
+ repository: `${match[1]}/${match[2]}`,
170
+ stackFile: yield* stackFileOf(cwd),
171
+ branch,
172
+ pullRequest: null,
173
+ headCommit: yield* git(cwd, ["rev-parse", "HEAD"]),
174
+ runUrl: null,
175
+ source: "laptop",
176
+ recordedAt: yield* now,
177
+ };
178
+ return record;
179
+ });
180
+
181
+ /**
182
+ * The record of this run: from the GitHub Actions variables and the event
183
+ * payload when GITHUB_ACTIONS is `true`, else from git in `cwd`, which needs
184
+ * a branch and an `origin` remote with `owner/name`. Fails when a value is
185
+ * missing; nothing is guessed.
186
+ */
187
+ export const runRecord = (where: RunEnvironment): Effect.Effect<RunRecord, RunRecordError> =>
188
+ where.env["GITHUB_ACTIONS"] === "true" ? fromGitHubActions(where) : fromGit(where);
189
+
190
+ /** 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. */
191
+ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
192
+ if (typeof value !== "object" || value === null) return false;
193
+ const prototype: unknown = Object.getPrototypeOf(value);
194
+ return prototype === Object.prototype || prototype === null;
195
+ };
196
+
197
+ /**
198
+ * The output document of a stage: the stack's output plus the record under
199
+ * {@link RUN_RECORD_KEY}, or undefined for an output with no place for the
200
+ * key: a string, an array, null, or an object that is not plain, such as a
201
+ * Date. A value that the stack itself put under the key stays.
202
+ */
203
+ export const withRunRecord = (output: unknown, record: RunRecord): object | undefined =>
204
+ isPlainObject(output)
205
+ ? RUN_RECORD_KEY in output
206
+ ? output
207
+ : { ...output, [RUN_RECORD_KEY]: record }
208
+ : undefined;
package/src/State.ts CHANGED
@@ -23,13 +23,19 @@
23
23
  // The run whose project is not the oldest deletes its own, before it writes
24
24
  // anything, and uses the oldest: both runs end on one store.
25
25
  //
26
- // The function names are a wire contract: Samebase reads `stacks` and
27
- // `getOutput` by name, and an older client calls an older set of names. A
28
- // name is never renamed or removed. STORE_VERSION rises when a function
29
- // changes in a way that an older client cannot use, and a client never
30
- // pushes an older store over a newer one.
26
+ // The function names are a wire contract: Samebase reads `stacks`,
27
+ // `getOutput`, and `readStack` by name, and an older client calls an older
28
+ // set of names. A name is never renamed or removed. STORE_VERSION rises when
29
+ // a function is added or changes in a way that an older client cannot use,
30
+ // and a client never pushes an older store over a newer one.
31
+ //
32
+ // The stage output that `setOutput` stores is the stack's output plus the
33
+ // run record of src/RunRecord.ts under the key `samebase.run`: the
34
+ // repository, stack file, branch, pull request, commit, and run that wrote
35
+ // the stage. `getOutput` gives the document back as stored.
31
36
  import { dirname, join } from "node:path";
32
37
  import { fileURLToPath } from "node:url";
38
+ import * as Clock from "effect/Clock";
33
39
  import * as Effect from "effect/Effect";
34
40
  import * as Layer from "effect/Layer";
35
41
  import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
@@ -70,6 +76,7 @@ import {
70
76
  PUSH_KEY_ACTIONS,
71
77
  withPushKey,
72
78
  } from "./Push.ts";
79
+ import { type RunRecord, runRecord, withRunRecord } from "./RunRecord.ts";
73
80
  import { STORE_VERSION } from "./store/convex/version.ts";
74
81
 
75
82
  /** The name of the state project in each team. */
@@ -241,6 +248,8 @@ export interface StoreOptions {
241
248
  readonly scope: Scope.Scope;
242
249
  /** The functions directory that an outdated store gets: {@link storeDirectory}. */
243
250
  readonly store: string;
251
+ /** The record of this run, which `setOutput` adds to the stage output with the time of the write, or undefined for a run without one. */
252
+ readonly run: RunRecord | undefined;
244
253
  }
245
254
 
246
255
  /**
@@ -385,6 +394,21 @@ export const makeConvexState = (options: StoreOptions) =>
385
394
  }
386
395
 
387
396
  const remote = remoteOf(functions);
397
+ /** The stack's output plus the record, when the run has one and the output has a place for it. */
398
+ const documentOf = (request: { stack: string; stage: string; value: unknown }) =>
399
+ Effect.gen(function* () {
400
+ const { run } = options;
401
+ if (run === undefined) return request.value;
402
+ const recordedAt = new Date(yield* Clock.currentTimeMillis).toISOString();
403
+ const document = withRunRecord(request.value, { ...run, recordedAt });
404
+ if (document === undefined) {
405
+ yield* Effect.logWarning(
406
+ `Convex state: the output of ${request.stack}/${request.stage} is not a plain object, so it carries no run record.`,
407
+ );
408
+ return request.value;
409
+ }
410
+ return document;
411
+ });
388
412
  const service: StateService = {
389
413
  id: "convex",
390
414
  getVersion: () => Effect.succeed(STATE_STORE_VERSION),
@@ -397,7 +421,13 @@ export const makeConvexState = (options: StoreOptions) =>
397
421
  set: (request) => remote.set(request).pipe(Effect.as(request.value)),
398
422
  delete: (request) => remote.delete(request).pipe(Effect.asVoid),
399
423
  deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
400
- setOutput: (request) => remote.setOutput(request).pipe(Effect.as(request.value)),
424
+ // The document is the stack's output plus the record under `samebase.run`.
425
+ setOutput: (request) =>
426
+ Effect.gen(function* () {
427
+ const value = yield* documentOf(request);
428
+ yield* remote.setOutput({ stack: request.stack, stage: request.stage, value });
429
+ return value;
430
+ }),
401
431
  };
402
432
  return service;
403
433
  });
@@ -425,12 +455,25 @@ export const state = (
425
455
  const api = yield* ManagementApi.pipe(
426
456
  Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))),
427
457
  );
458
+ // The record is derived once, before the first read. A checkout
459
+ // that gives none, such as a tarball without git, a checkout
460
+ // without origin, or a detached HEAD, still reads, plans, and
461
+ // destroys: its outputs are written without the record, and the
462
+ // log says why.
463
+ const run = yield* runRecord({ cwd: process.cwd(), env: process.env }).pipe(
464
+ Effect.catch((error) =>
465
+ Effect.logWarning(
466
+ `Convex state: the stage outputs of this run carry no record. ${error.message}`,
467
+ ).pipe(Effect.as(undefined)),
468
+ ),
469
+ );
428
470
  return yield* makeConvexState({
429
471
  team: options.team,
430
472
  api,
431
473
  spawner,
432
474
  scope,
433
475
  store: storeDirectory(),
476
+ run,
434
477
  });
435
478
  }).pipe(Effect.provideContext(context), Effect.orDie),
436
479
  );
package/src/index.ts CHANGED
@@ -28,6 +28,13 @@ export {
28
28
  ProjectTeamChange,
29
29
  } from "./Project.ts";
30
30
  export { Providers, providers } from "./Providers.ts";
31
+ export {
32
+ RUN_RECORD_KEY,
33
+ type RunRecord,
34
+ RunRecordError,
35
+ runRecord,
36
+ withRunRecord,
37
+ } from "./RunRecord.ts";
31
38
  export { state, STORE_PROJECT_NAME } from "./State.ts";
32
39
  export {
33
40
  CodePushFailed,
@@ -7,8 +7,8 @@
7
7
  //
8
8
  // A stage holds one row for each resource of its stack, so the reads of one
9
9
  // stage collect all rows. The function names are a wire contract: Samebase
10
- // reads `stacks` and `getOutput` by name, and an older client calls an older
11
- // set of names. Add functions; never rename or remove one.
10
+ // reads `stacks`, `getOutput`, and `readStack` by name, and an older client
11
+ // calls an older set of names. Add functions; never rename or remove one.
12
12
  import { v } from "convex/values";
13
13
  import type { QueryCtx } from "./_generated/server.js";
14
14
  import { internalMutation, internalQuery } from "./_generated/server.js";
@@ -217,3 +217,49 @@ export const setOutput = internalMutation({
217
217
  return null;
218
218
  },
219
219
  });
220
+
221
+ /**
222
+ * One call for a reader: every stage of a stack in name order, each with its
223
+ * resources in fqn order and its output, as the JSON texts that the rows
224
+ * hold. A stage with an output and no resource is listed with an empty
225
+ * resource list; a stage with resources and no output with `output: null`.
226
+ * One function reads at most 16 MiB (Convex limit): a stack whose rows go
227
+ * beyond that fails here and is read with `stages`, `list`, `get`, and
228
+ * `getOutput` instead. Since store version 2.
229
+ */
230
+ export const readStack = internalQuery({
231
+ args: { stack: v.string() },
232
+ returns: v.object({
233
+ stages: v.array(
234
+ v.object({
235
+ stage: v.string(),
236
+ resources: v.array(v.object({ fqn: v.string(), value: v.string() })),
237
+ output: v.union(v.string(), v.null()),
238
+ }),
239
+ ),
240
+ }),
241
+ handler: async (ctx, args) => {
242
+ // Both indexes order by stack, then stage, then (for resources) fqn.
243
+ const resources = await ctx.db
244
+ .query("resources")
245
+ .withIndex("by_fqn", (q) => q.eq("stack", args.stack))
246
+ .collect();
247
+ const outputs = await ctx.db
248
+ .query("outputs")
249
+ .withIndex("by_stage", (q) => q.eq("stack", args.stack))
250
+ .collect();
251
+ const stages = union(
252
+ [...new Set(resources.map((row) => row.stage))],
253
+ [...new Set(outputs.map((row) => row.stage))],
254
+ );
255
+ return {
256
+ stages: stages.map((stage) => ({
257
+ stage,
258
+ resources: resources
259
+ .filter((row) => row.stage === stage)
260
+ .map((row) => ({ fqn: row.fqn, value: row.value })),
261
+ output: outputs.find((row) => row.stage === stage)?.value ?? null,
262
+ })),
263
+ };
264
+ },
265
+ });
@@ -1,6 +1,10 @@
1
1
  // The wire version of the store: the `state:version` query answers it, and
2
2
  // Convex.state() pushes this directory when the store of the team answers an
3
- // older one or has no `version` yet. Raise it when a function changes in a way
4
- // that an older client cannot use. Never rename or remove a function: an
5
- // older client, and Samebase, call the store by these names.
6
- export const STORE_VERSION = 1;
3
+ // older one or has no `version` yet. Raise it when a function is added or
4
+ // changes in a way that an older client cannot use. Never rename or remove a
5
+ // function: an older client, and Samebase, call the store by these names.
6
+ //
7
+ // 1: `version`, `stacks`, `stages`, `list`, `get`, `replaced`, `getOutput`,
8
+ // `set`, `remove`, `removeStack`, `setOutput` (0.6.0).
9
+ // 2: `readStack` (0.7.0).
10
+ export const STORE_VERSION = 2;