@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/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.
|
|
111
|
-
|
|
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
|
-
/**
|
|
82
|
-
|
|
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.
|
|
350
|
-
*
|
|
351
|
-
*
|
|
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
|
|
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
|
package/lib/index.d.ts.map
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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 <
|
|
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 <
|
|
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 <
|
|
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 >
|
|
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) =>
|
|
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.
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
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
|