@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 +38 -2
- package/lib/index.d.ts +64 -8
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +208 -18
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/ManagementApi.ts +85 -3
- package/src/RunRecord.ts +208 -0
- package/src/State.ts +62 -16
- package/src/index.ts +7 -0
- package/src/store/convex/state.ts +48 -2
- package/src/store/convex/version.ts +8 -4
package/package.json
CHANGED
package/src/ManagementApi.ts
CHANGED
|
@@ -69,8 +69,16 @@ export interface ManagementApiService {
|
|
|
69
69
|
deployKey: Redacted.Redacted<string>,
|
|
70
70
|
run: (client: DeploymentClient) => Promise<FetchResult<T>>,
|
|
71
71
|
) => Effect.Effect<T, ConvexApiError>;
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Team id for a numeric id, a slug, or nothing. A team access token names
|
|
74
|
+
* its team (`GET /token_details`); a slug is then checked against the
|
|
75
|
+
* `teamSlug` of the team's projects, so a token of another team fails. The
|
|
76
|
+
* Convex CLI login and a personal token answer 403 there, so a slug goes to
|
|
77
|
+
* the dashboard's team list, and nothing is an error.
|
|
78
|
+
*/
|
|
79
|
+
readonly resolveTeamId: (
|
|
80
|
+
team: string | number | undefined,
|
|
81
|
+
) => Effect.Effect<number, ConvexApiError>;
|
|
74
82
|
/**
|
|
75
83
|
* True when a deploy key that Convex returned carries the token of the
|
|
76
84
|
* Management API credential itself. With an OAuth token, create_deploy_key
|
|
@@ -179,6 +187,17 @@ export const absentAsUndefined = <A, R>(
|
|
|
179
187
|
const TeamRow = Schema.Struct({ id: Schema.Number, slug: Schema.String });
|
|
180
188
|
const TeamRows = Schema.Array(TeamRow);
|
|
181
189
|
|
|
190
|
+
/** The first page of GET /teams/{team_id}/projects: every row carries the slug of the team. */
|
|
191
|
+
const ProjectSlugRows = Schema.Struct({
|
|
192
|
+
items: Schema.Array(Schema.Struct({ teamSlug: Schema.String })),
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
/** GET /token_details: the team of a team token, or the project of a project token. */
|
|
196
|
+
const TokenDetails = Schema.Union([
|
|
197
|
+
Schema.Struct({ type: Schema.Literal("teamToken"), teamId: Schema.Number }),
|
|
198
|
+
Schema.Struct({ type: Schema.Literal("projectToken"), projectId: Schema.Number }),
|
|
199
|
+
]);
|
|
200
|
+
|
|
182
201
|
/** The service for one way to get the bearer token. */
|
|
183
202
|
const makeService = (credentials: Effect.Effect<ResolvedCredentials>): ManagementApiService => {
|
|
184
203
|
const client = Effect.cached(
|
|
@@ -203,13 +222,76 @@ const makeService = (credentials: Effect.Effect<ResolvedCredentials>): Managemen
|
|
|
203
222
|
run(createDeploymentClient(deploymentUrlOrName, Redacted.value(deployKey))),
|
|
204
223
|
);
|
|
205
224
|
|
|
225
|
+
// Measured: a team access token answers `GET /token_details` with its
|
|
226
|
+
// team id and gets HTTP 403 from the dashboard's team list; the Convex CLI
|
|
227
|
+
// login answers the team list and gets HTTP 403 `NotOAuthTeamToken` from
|
|
228
|
+
// token_details. Read once for each service.
|
|
229
|
+
// `Effect.cached` only allocates the cache, so runSync makes one for this service.
|
|
230
|
+
const tokenDetails = Effect.runSync(
|
|
231
|
+
Effect.cached(
|
|
232
|
+
request("get token details", (c) => c.GET("/token_details")).pipe(
|
|
233
|
+
Effect.map((body) => Schema.decodeUnknownSync(TokenDetails)(body)),
|
|
234
|
+
Effect.catchIf(
|
|
235
|
+
(error) => error.status === 403,
|
|
236
|
+
() => Effect.succeed(undefined),
|
|
237
|
+
),
|
|
238
|
+
),
|
|
239
|
+
),
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
/** The slug of the team, from the first project of its listing, or undefined for a team without one. Read once. */
|
|
243
|
+
const teamSlug = (teamId: number) =>
|
|
244
|
+
request("list projects", (c) =>
|
|
245
|
+
c.GET("/teams/{team_id}/projects", { params: { path: { team_id: teamId } } }),
|
|
246
|
+
).pipe(
|
|
247
|
+
Effect.map((body) => Schema.decodeUnknownSync(ProjectSlugRows)(body).items[0]?.teamSlug),
|
|
248
|
+
);
|
|
249
|
+
|
|
206
250
|
// The Management API addresses teams by numeric id. The only slug lookup
|
|
207
251
|
// it offers is per project, so a slug falls back to the dashboard's team
|
|
208
252
|
// list, which is unofficial. Pass the numeric team id to skip this.
|
|
209
253
|
const resolveTeamId: ManagementApiService["resolveTeamId"] = (team) =>
|
|
210
254
|
Effect.gen(function* () {
|
|
211
255
|
if (typeof team === "number") return team;
|
|
212
|
-
if (/^\d+$/.test(team)) return Number(team);
|
|
256
|
+
if (team !== undefined && /^\d+$/.test(team)) return Number(team);
|
|
257
|
+
const details = yield* tokenDetails;
|
|
258
|
+
if (details?.type === "teamToken") {
|
|
259
|
+
if (team === undefined) return details.teamId;
|
|
260
|
+
// The Management API has no team-by-id call, so the slug of the
|
|
261
|
+
// team comes from any of its projects. A team without a project
|
|
262
|
+
// cannot be checked: the numeric id, or no team, is the fix.
|
|
263
|
+
const slug = yield* teamSlug(details.teamId);
|
|
264
|
+
if (slug === undefined) {
|
|
265
|
+
return yield* new ConvexApiError({
|
|
266
|
+
operation: "resolve team",
|
|
267
|
+
status: 0,
|
|
268
|
+
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.`,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (slug !== team) {
|
|
272
|
+
return yield* new ConvexApiError({
|
|
273
|
+
operation: "resolve team",
|
|
274
|
+
status: 403,
|
|
275
|
+
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}.`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
return details.teamId;
|
|
279
|
+
}
|
|
280
|
+
if (details?.type === "projectToken") {
|
|
281
|
+
return yield* new ConvexApiError({
|
|
282
|
+
operation: "resolve team",
|
|
283
|
+
status: 403,
|
|
284
|
+
message: `the access token belongs to project ${details.projectId}, and a project token cannot create projects. Use a team access token.`,
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if (team === undefined) {
|
|
288
|
+
return yield* new ConvexApiError({
|
|
289
|
+
operation: "resolve team",
|
|
290
|
+
status: 0,
|
|
291
|
+
message:
|
|
292
|
+
"set team to the slug or the id of the Convex team: this access token does not name a team.",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
213
295
|
const resolved = yield* credentials;
|
|
214
296
|
const rows = yield* Effect.tryPromise({
|
|
215
297
|
try: async () => {
|
package/src/RunRecord.ts
ADDED
|
@@ -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
|
|
27
|
-
// `getOutput` by name, and an older client calls an older
|
|
28
|
-
// name is never renamed or removed. STORE_VERSION rises when
|
|
29
|
-
// changes in a way that an older client cannot use,
|
|
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. */
|
|
@@ -233,14 +240,16 @@ export const storeDirectory = () =>
|
|
|
233
240
|
join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex");
|
|
234
241
|
|
|
235
242
|
export interface StoreOptions {
|
|
236
|
-
/** Team slug or numeric team id of the `alchemy-state` project. */
|
|
237
|
-
readonly team: string | number;
|
|
243
|
+
/** Team slug or numeric team id of the `alchemy-state` project, or undefined with a team access token, which names its team. */
|
|
244
|
+
readonly team: string | number | undefined;
|
|
238
245
|
readonly api: ManagementApiService;
|
|
239
246
|
readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
|
|
240
247
|
/** The key of the run is deleted when this scope closes. */
|
|
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
|
/**
|
|
@@ -313,8 +322,9 @@ const createStoreProject = (api: ManagementApiService, teamId: number, team: str
|
|
|
313
322
|
*/
|
|
314
323
|
export const makeConvexState = (options: StoreOptions) =>
|
|
315
324
|
Effect.gen(function* () {
|
|
316
|
-
const { api
|
|
317
|
-
const teamId = yield* api.resolveTeamId(team);
|
|
325
|
+
const { api } = options;
|
|
326
|
+
const teamId = yield* api.resolveTeamId(options.team);
|
|
327
|
+
const team = options.team ?? teamId;
|
|
318
328
|
const found = yield* findProjectByName(api, teamId, STORE_PROJECT_NAME);
|
|
319
329
|
const project = found ?? (yield* createStoreProject(api, teamId, team));
|
|
320
330
|
const deployment = yield* prodDeploymentOf(api, project.id);
|
|
@@ -384,6 +394,21 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
384
394
|
}
|
|
385
395
|
|
|
386
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
|
+
});
|
|
387
412
|
const service: StateService = {
|
|
388
413
|
id: "convex",
|
|
389
414
|
getVersion: () => Effect.succeed(STATE_STORE_VERSION),
|
|
@@ -396,20 +421,28 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
396
421
|
set: (request) => remote.set(request).pipe(Effect.as(request.value)),
|
|
397
422
|
delete: (request) => remote.delete(request).pipe(Effect.asVoid),
|
|
398
423
|
deleteStack: (request) => remote.deleteStack(request).pipe(Effect.asVoid),
|
|
399
|
-
|
|
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
|
+
}),
|
|
400
431
|
};
|
|
401
432
|
return service;
|
|
402
433
|
});
|
|
403
434
|
|
|
404
435
|
/**
|
|
405
436
|
* Alchemy state in the `alchemy-state` project of `team`, with the team token
|
|
406
|
-
* in CONVEX_ACCESS_TOKEN or the Convex CLI login.
|
|
407
|
-
*
|
|
408
|
-
*
|
|
437
|
+
* in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names
|
|
438
|
+
* its team, so `team` is optional with one; the Convex CLI login needs the
|
|
439
|
+
* slug or the id. The first use of the run finds or creates the project and
|
|
440
|
+
* pushes the store when it is missing or older. `alchemy plan` on another
|
|
441
|
+
* stack never touches Convex.
|
|
409
442
|
*/
|
|
410
|
-
export const state = (
|
|
411
|
-
readonly team
|
|
412
|
-
|
|
443
|
+
export const state = (
|
|
444
|
+
options: { readonly team?: string | number } = {},
|
|
445
|
+
): Layer.Layer<State, never, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
413
446
|
Layer.effect(
|
|
414
447
|
State,
|
|
415
448
|
Effect.gen(function* () {
|
|
@@ -422,12 +455,25 @@ export const state = (options: {
|
|
|
422
455
|
const api = yield* ManagementApi.pipe(
|
|
423
456
|
Effect.provide(ManagementApiLive().pipe(Layer.provide(fromConfig()))),
|
|
424
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
|
+
);
|
|
425
470
|
return yield* makeConvexState({
|
|
426
471
|
team: options.team,
|
|
427
472
|
api,
|
|
428
473
|
spawner,
|
|
429
474
|
scope,
|
|
430
475
|
store: storeDirectory(),
|
|
476
|
+
run,
|
|
431
477
|
});
|
|
432
478
|
}).pipe(Effect.provideContext(context), Effect.orDie),
|
|
433
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 `
|
|
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
|
|
4
|
-
// that an older client cannot use. Never rename or remove a
|
|
5
|
-
// older client, and Samebase, call the store by these names.
|
|
6
|
-
|
|
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;
|