@samebase/alchemy-convex 0.8.0 → 0.10.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 +60 -2
- package/lib/index.d.ts +26 -4
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +76 -21
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Component.ts +54 -0
- package/src/Deployment.ts +20 -6
- package/src/State.ts +34 -29
- package/src/index.ts +1 -0
- package/src/store/convex/_generated/api.d.ts +4 -0
- package/src/store/convex/auth.config.ts +14 -0
- package/src/store/convex/reader.ts +131 -0
- package/src/store/convex/runRecord.ts +41 -0
- package/src/store/convex/schema.ts +3 -1
- package/src/store/convex/state.ts +47 -21
- package/src/store/convex/version.ts +6 -4
- package/src/store/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@samebase/alchemy-convex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"@effect/vitest": "4.0.4",
|
|
57
57
|
"@types/node": "24.19.2",
|
|
58
58
|
"alchemy": "2.0.0-beta.83",
|
|
59
|
-
"convex": "1.
|
|
59
|
+
"convex": "1.48.0",
|
|
60
60
|
"convex-test": "0.0.60",
|
|
61
61
|
"effect": "4.0.4",
|
|
62
62
|
"publint": "0.3.25",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"peerDependencies": {
|
|
68
68
|
"@effect/platform-node": "^4.0.0",
|
|
69
69
|
"alchemy": "2.0.0-beta.83",
|
|
70
|
-
"convex": "^1.
|
|
70
|
+
"convex": "^1.48.0",
|
|
71
71
|
"effect": "^4.0.0"
|
|
72
72
|
},
|
|
73
73
|
"engines": {
|
package/src/Component.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { toPath } from "alchemy/FQN";
|
|
2
|
+
import type { NamespaceNode } from "alchemy/Namespace";
|
|
3
|
+
import { Stack } from "alchemy/Stack";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as Schema from "effect/Schema";
|
|
6
|
+
import type { Deployment } from "./Deployment.ts";
|
|
7
|
+
|
|
8
|
+
/** A component's resources occupy this namespace in the deployment's Alchemy stack and stage. */
|
|
9
|
+
export interface ComponentBinding {
|
|
10
|
+
readonly type: "component";
|
|
11
|
+
readonly path: string;
|
|
12
|
+
readonly namespace: readonly string[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Associate a native namespace with an installed component. `component` is the
|
|
17
|
+
* installed name from the app's `convex.config.ts`; a nested install joins the
|
|
18
|
+
* names with slashes, such as `"workspace/mail"`. An undefined namespace
|
|
19
|
+
* associates the stack root. This records display metadata; it does not install
|
|
20
|
+
* the component, set its environment, or change removal policies.
|
|
21
|
+
*/
|
|
22
|
+
export const bindComponent = (options: {
|
|
23
|
+
deployment: Deployment;
|
|
24
|
+
component: string;
|
|
25
|
+
namespace: NamespaceNode | undefined;
|
|
26
|
+
}) =>
|
|
27
|
+
Effect.gen(function* () {
|
|
28
|
+
if (options.component === "") {
|
|
29
|
+
return yield* Effect.die(new Error("The component name is empty."));
|
|
30
|
+
}
|
|
31
|
+
const namespace = toPath(options.namespace);
|
|
32
|
+
const sid = `convex.component:${JSON.stringify(namespace)}`;
|
|
33
|
+
const stack = yield* Stack;
|
|
34
|
+
const previous = stack.bindings[options.deployment.FQN]?.find((binding) => binding.sid === sid);
|
|
35
|
+
// Alchemy keeps the last binding with a given sid. Reject conflicting owners before that happens.
|
|
36
|
+
if (previous !== undefined) {
|
|
37
|
+
const existing = Schema.decodeUnknownSync(Schema.Struct({ path: Schema.String }))(
|
|
38
|
+
previous.data,
|
|
39
|
+
);
|
|
40
|
+
if (existing.path !== options.component) {
|
|
41
|
+
return yield* Effect.die(
|
|
42
|
+
new Error(
|
|
43
|
+
`Alchemy namespace ${JSON.stringify(namespace)} is already bound to ${existing.path}.`,
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
yield* options.deployment.bind(sid, {
|
|
50
|
+
type: "component",
|
|
51
|
+
path: options.component,
|
|
52
|
+
namespace,
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/Deployment.ts
CHANGED
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
import { posix, win32 } from "node:path";
|
|
55
55
|
import { Resource } from "alchemy";
|
|
56
56
|
import { OwnedBySomeoneElse, Unowned } from "alchemy/AdoptPolicy";
|
|
57
|
-
import { isResolved } from "alchemy/Diff";
|
|
57
|
+
import { diffBindings, isResolved } from "alchemy/Diff";
|
|
58
58
|
import type { InputProps } from "alchemy/Input";
|
|
59
59
|
import * as Provider from "alchemy/Provider";
|
|
60
60
|
import * as Effect from "effect/Effect";
|
|
@@ -63,6 +63,7 @@ import * as Redacted from "effect/Redacted";
|
|
|
63
63
|
import * as Schema from "effect/Schema";
|
|
64
64
|
import { shouldAdopt } from "./Adoption.ts";
|
|
65
65
|
import { Credentials } from "./Credentials.ts";
|
|
66
|
+
import type { ComponentBinding } from "./Component.ts";
|
|
66
67
|
import {
|
|
67
68
|
absentAsUndefined,
|
|
68
69
|
type ConvexApiError,
|
|
@@ -145,7 +146,7 @@ export type Deployment = Resource<
|
|
|
145
146
|
"Convex.Deployment",
|
|
146
147
|
DeploymentProps,
|
|
147
148
|
DeploymentAttributes,
|
|
148
|
-
|
|
149
|
+
ComponentBinding,
|
|
149
150
|
Providers
|
|
150
151
|
>;
|
|
151
152
|
/** The resource class. Stacks call {@link Deployment}, which writes `rootDir` into the props. */
|
|
@@ -597,9 +598,10 @@ export const DeploymentProvider = () =>
|
|
|
597
598
|
// the refused props. Only `rootDir` compares with `olds`, because it
|
|
598
599
|
// is in the props only. A refused update leaves the row `updating`,
|
|
599
600
|
// and the next plan updates it again anyway.
|
|
600
|
-
diff: Effect.fn(function* ({ news, olds, output }) {
|
|
601
|
+
diff: Effect.fn(function* ({ news, olds, output, oldBindings, newBindings }) {
|
|
601
602
|
if (!isResolved(news) || output === undefined) return undefined;
|
|
602
603
|
yield* checkIdentity(output, news);
|
|
604
|
+
if (!isResolved(newBindings)) return undefined;
|
|
603
605
|
// State from 0.7 has no rootDir; the constructor always writes it.
|
|
604
606
|
const rootDir = news.rootDir ?? ".";
|
|
605
607
|
// Also here, so that a wrong rootDir stops the plan before Apply
|
|
@@ -617,9 +619,21 @@ export const DeploymentProvider = () =>
|
|
|
617
619
|
const current = yield* live(access, output);
|
|
618
620
|
if (current?.name !== output.name) return { action: "update" } as const;
|
|
619
621
|
const hash = yield* wantedHash(news, rootDir, output.name);
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
622
|
+
if (hash !== output.hash) return { action: "update" } as const;
|
|
623
|
+
if (diffBindings(oldBindings, newBindings).every((binding) => binding.action === "noop"))
|
|
624
|
+
return { action: "noop" } as const;
|
|
625
|
+
// Binding metadata does not change deployment outputs. Keep unchanged
|
|
626
|
+
// values available to consumers so the plan does not update them too.
|
|
627
|
+
return {
|
|
628
|
+
action: "update" as const,
|
|
629
|
+
stables: [
|
|
630
|
+
"hash",
|
|
631
|
+
"envNames",
|
|
632
|
+
...(
|
|
633
|
+
["projectId", "type", "previewName", "name", "reference", "url", "siteUrl"] as const
|
|
634
|
+
).filter((key) => current[key] === output[key]),
|
|
635
|
+
],
|
|
636
|
+
};
|
|
623
637
|
}),
|
|
624
638
|
|
|
625
639
|
read: Effect.fn(function* ({ olds, output }) {
|
package/src/State.ts
CHANGED
|
@@ -14,20 +14,19 @@
|
|
|
14
14
|
// end; the `state:version` query says whether the deployment has the store
|
|
15
15
|
// of this version. Without one, or with an older one, the installed Convex
|
|
16
16
|
// CLI pushes src/store/convex to it, with a push key that is deleted after
|
|
17
|
-
// the push. The functions are internal
|
|
18
|
-
//
|
|
19
|
-
// knows the deployment URL (measured). A key that stops working during a
|
|
17
|
+
// the push. The raw state functions are internal; authenticated readers get
|
|
18
|
+
// repository-scoped subscriptions with secret values removed. A key that stops working during a
|
|
20
19
|
// long run (it expires after two hours) is replaced once per call.
|
|
21
20
|
//
|
|
22
21
|
// Two first runs of a team can both create a project named `alchemy-state`.
|
|
23
22
|
// The run whose project is not the oldest deletes its own, before it writes
|
|
24
23
|
// anything, and uses the oldest: both runs end on one store.
|
|
25
24
|
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
// a
|
|
30
|
-
//
|
|
25
|
+
// Samebase reads `stacks`, `getOutput`, `readStack`, and `reader:repository`
|
|
26
|
+
// by name. The store and its readers are released and upgraded together:
|
|
27
|
+
// STORE_VERSION rises with each change of a function, and a client works only
|
|
28
|
+
// with a store of its own version. An older store is pushed over; a newer one
|
|
29
|
+
// fails the run.
|
|
31
30
|
//
|
|
32
31
|
// The stage output that `setOutput` stores is the stack's output plus the
|
|
33
32
|
// run record of src/RunRecord.ts under the key `samebase.run`: the
|
|
@@ -157,7 +156,7 @@ export interface FunctionApi {
|
|
|
157
156
|
/** POST /api/function, the call that `convex run` makes: the reply, which can be a function error. */
|
|
158
157
|
readonly call: (
|
|
159
158
|
path: string,
|
|
160
|
-
args: Record<string, string | undefined>,
|
|
159
|
+
args: Record<string, string | RunRecord | undefined>,
|
|
161
160
|
) => Effect.Effect<Reply, StateStoreError>;
|
|
162
161
|
}
|
|
163
162
|
|
|
@@ -177,9 +176,20 @@ const valueOf = (reply: Reply) =>
|
|
|
177
176
|
: Effect.fail(failure(reply.errorMessage.trim()));
|
|
178
177
|
|
|
179
178
|
/** The functions of the store with Alchemy's encoding: the JSON text of encodeState. */
|
|
180
|
-
const remoteOf = (api: FunctionApi) => {
|
|
181
|
-
const run = (name: string, args: Record<string, string | undefined>) =>
|
|
179
|
+
const remoteOf = (api: FunctionApi, record: RunRecord | undefined) => {
|
|
180
|
+
const run = (name: string, args: Record<string, string | RunRecord | undefined>) =>
|
|
182
181
|
api.call(`state:${name}`, args).pipe(Effect.flatMap(valueOf));
|
|
182
|
+
const write = (name: string, args: Record<string, string>) =>
|
|
183
|
+
Effect.gen(function* () {
|
|
184
|
+
const currentRun =
|
|
185
|
+
record === undefined
|
|
186
|
+
? undefined
|
|
187
|
+
: {
|
|
188
|
+
...record,
|
|
189
|
+
recordedAt: new Date(yield* Clock.currentTimeMillis).toISOString(),
|
|
190
|
+
};
|
|
191
|
+
return yield* run(name, { ...args, run: currentRun });
|
|
192
|
+
});
|
|
183
193
|
const strings = (name: string, args: Record<string, string>) =>
|
|
184
194
|
run(name, args).pipe(Effect.flatMap(decode(Schema.Array(Schema.String), name)));
|
|
185
195
|
const optional = (name: string, args: Record<string, string>) =>
|
|
@@ -208,13 +218,13 @@ const remoteOf = (api: FunctionApi) => {
|
|
|
208
218
|
Effect.map((text): unknown => (text === null ? undefined : JSON.parse(text, reviveState))),
|
|
209
219
|
),
|
|
210
220
|
set: (r: { stack: string; stage: string; fqn: string; value: PersistedState }) =>
|
|
211
|
-
|
|
221
|
+
write("set", { stack: r.stack, stage: r.stage, fqn: r.fqn, value: encode(r.value) }),
|
|
212
222
|
delete: (r: { stack: string; stage: string; fqn: string }) =>
|
|
213
|
-
|
|
223
|
+
write("remove", { stack: r.stack, stage: r.stage, fqn: r.fqn }),
|
|
214
224
|
deleteStack: (r: { stack: string; stage?: string }) =>
|
|
215
225
|
run("removeStack", { stack: r.stack, stage: r.stage }),
|
|
216
226
|
setOutput: (r: { stack: string; stage: string; value: unknown }) =>
|
|
217
|
-
|
|
227
|
+
write("setOutput", { stack: r.stack, stage: r.stage, value: encode(r.value) }),
|
|
218
228
|
};
|
|
219
229
|
};
|
|
220
230
|
|
|
@@ -248,7 +258,7 @@ export interface StoreOptions {
|
|
|
248
258
|
readonly scope: Scope.Scope;
|
|
249
259
|
/** The functions directory that an outdated store gets: {@link storeDirectory}. */
|
|
250
260
|
readonly store: string;
|
|
251
|
-
/** The
|
|
261
|
+
/** The run recorded with resource writes and output, or undefined for a run without one. */
|
|
252
262
|
readonly run: RunRecord | undefined;
|
|
253
263
|
}
|
|
254
264
|
|
|
@@ -367,7 +377,7 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
367
377
|
),
|
|
368
378
|
};
|
|
369
379
|
|
|
370
|
-
|
|
380
|
+
let version = yield* storeVersion(functions);
|
|
371
381
|
if (version === undefined || version < STORE_VERSION) {
|
|
372
382
|
// Code without `state:version` is another app with the name of the store.
|
|
373
383
|
if (version === undefined && deployment.lastDeployTime) {
|
|
@@ -377,24 +387,19 @@ export const makeConvexState = (options: StoreOptions) =>
|
|
|
377
387
|
}
|
|
378
388
|
// Another run can push a newer store in the meantime: read again right
|
|
379
389
|
// before the push. The push itself has no lock (see the design note).
|
|
380
|
-
|
|
381
|
-
if (
|
|
390
|
+
version = yield* storeVersion(functions);
|
|
391
|
+
if (version === undefined || version < STORE_VERSION) {
|
|
382
392
|
yield* pushStore(options, deployment);
|
|
383
|
-
|
|
384
|
-
if (pushed === undefined || pushed < STORE_VERSION) {
|
|
385
|
-
return yield* failure(
|
|
386
|
-
`${deployment.name} answers store version ${pushed ?? "none"} after the push of version ${STORE_VERSION}.`,
|
|
387
|
-
);
|
|
388
|
-
}
|
|
393
|
+
version = yield* storeVersion(functions);
|
|
389
394
|
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
yield*
|
|
393
|
-
|
|
395
|
+
}
|
|
396
|
+
if (version !== STORE_VERSION) {
|
|
397
|
+
return yield* failure(
|
|
398
|
+
`${deployment.name} answers store version ${version ?? "none"}, and this package needs store version ${STORE_VERSION}.`,
|
|
394
399
|
);
|
|
395
400
|
}
|
|
396
401
|
|
|
397
|
-
const remote = remoteOf(functions);
|
|
402
|
+
const remote = remoteOf(functions, options.run);
|
|
398
403
|
/** The stack's output plus the record, when the run has one and the output has a place for it. */
|
|
399
404
|
const documentOf = (request: { stack: string; stage: string; value: unknown }) =>
|
|
400
405
|
Effect.gen(function* () {
|
package/src/index.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
* @module
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import type * as reader from "../reader.js";
|
|
12
|
+
import type * as runRecord from "../runRecord.js";
|
|
11
13
|
import type * as state from "../state.js";
|
|
12
14
|
import type * as version from "../version.js";
|
|
13
15
|
|
|
@@ -18,6 +20,8 @@ import type {
|
|
|
18
20
|
} from "convex/server";
|
|
19
21
|
|
|
20
22
|
declare const fullApi: ApiFromModules<{
|
|
23
|
+
reader: typeof reader;
|
|
24
|
+
runRecord: typeof runRecord;
|
|
21
25
|
state: typeof state;
|
|
22
26
|
version: typeof version;
|
|
23
27
|
}>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AuthConfig } from "convex/server";
|
|
2
|
+
import { env } from "./_generated/server.js";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
providers: [
|
|
6
|
+
{
|
|
7
|
+
type: "customJwt",
|
|
8
|
+
issuer: env.CONVEX_CLOUD_URL,
|
|
9
|
+
applicationID: `${env.CONVEX_CLOUD_URL}/alchemy-reader`,
|
|
10
|
+
jwks: `${env.CONVEX_CLOUD_URL}/.well-known/jwks.json`,
|
|
11
|
+
algorithm: "ES256",
|
|
12
|
+
},
|
|
13
|
+
],
|
|
14
|
+
} satisfies AuthConfig;
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { v, type Infer } from "convex/values";
|
|
2
|
+
import { env, internalMutation, query } from "./_generated/server.js";
|
|
3
|
+
import { runRecord, validate } from "./runRecord.ts";
|
|
4
|
+
|
|
5
|
+
const repositoryName = /^[^/]+\/[^/]+$/;
|
|
6
|
+
|
|
7
|
+
/** Only a deployment credential can exchange an approved repository for a read token. */
|
|
8
|
+
export const authorize = internalMutation({
|
|
9
|
+
args: { repository: v.string(), subject: v.string() },
|
|
10
|
+
returns: v.object({ token: v.string() }),
|
|
11
|
+
handler: async (ctx, args) => {
|
|
12
|
+
if (!repositoryName.test(args.repository) || args.subject === "") {
|
|
13
|
+
throw new Error("A repository and subject are required.");
|
|
14
|
+
}
|
|
15
|
+
return {
|
|
16
|
+
token: await ctx.auth.signJwt({
|
|
17
|
+
expiresInSeconds: 300,
|
|
18
|
+
claims: {
|
|
19
|
+
sub: args.subject,
|
|
20
|
+
aud: `${env.CONVEX_CLOUD_URL}/alchemy-reader`,
|
|
21
|
+
repository: args.repository,
|
|
22
|
+
scope: "alchemy:read",
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const stage = v.object({
|
|
30
|
+
name: v.string(),
|
|
31
|
+
record: runRecord,
|
|
32
|
+
deployedHeadCommit: v.union(v.string(), v.null()),
|
|
33
|
+
resources: v.array(v.object({ fqn: v.string(), value: v.string() })),
|
|
34
|
+
output: v.string(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Alchemy encodes a secret as an object with __redacted__; discard that whole object's contents. */
|
|
38
|
+
function withoutSecrets(text: string): unknown {
|
|
39
|
+
return JSON.parse(text, (_key, value: unknown) =>
|
|
40
|
+
typeof value === "object" && value !== null && "__redacted__" in value
|
|
41
|
+
? { __redacted__: null }
|
|
42
|
+
: value,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A native subscription reads only the repository named in its verified capability. */
|
|
47
|
+
export const repository = query({
|
|
48
|
+
args: {},
|
|
49
|
+
returns: v.object({
|
|
50
|
+
store: v.literal("found"),
|
|
51
|
+
stacks: v.array(v.object({ name: v.string(), stages: v.array(stage) })),
|
|
52
|
+
}),
|
|
53
|
+
handler: async (ctx) => {
|
|
54
|
+
const identity = await ctx.auth.getUserIdentity();
|
|
55
|
+
if (
|
|
56
|
+
identity === null ||
|
|
57
|
+
identity.scope !== "alchemy:read" ||
|
|
58
|
+
typeof identity.repository !== "string" ||
|
|
59
|
+
!repositoryName.test(identity.repository)
|
|
60
|
+
) {
|
|
61
|
+
throw new Error("Alchemy repository read access is required.");
|
|
62
|
+
}
|
|
63
|
+
const name = identity.repository;
|
|
64
|
+
// The exact name and its slash-prefixed children exclude names such as owner/app-other.
|
|
65
|
+
// All four reads share the query transaction, including writes made while provisioning.
|
|
66
|
+
const [rootResources, childResources, rootOutputs, childOutputs] = await Promise.all([
|
|
67
|
+
ctx.db
|
|
68
|
+
.query("resources")
|
|
69
|
+
.withIndex("by_fqn", (q) => q.eq("stack", name))
|
|
70
|
+
.collect(),
|
|
71
|
+
ctx.db
|
|
72
|
+
.query("resources")
|
|
73
|
+
.withIndex("by_fqn", (q) => q.gte("stack", `${name}/`).lt("stack", `${name}0`))
|
|
74
|
+
.collect(),
|
|
75
|
+
ctx.db
|
|
76
|
+
.query("outputs")
|
|
77
|
+
.withIndex("by_stage", (q) => q.eq("stack", name))
|
|
78
|
+
.collect(),
|
|
79
|
+
ctx.db
|
|
80
|
+
.query("outputs")
|
|
81
|
+
.withIndex("by_stage", (q) => q.gte("stack", `${name}/`).lt("stack", `${name}0`))
|
|
82
|
+
.collect(),
|
|
83
|
+
]);
|
|
84
|
+
const stacks = new Map<string, Map<string, Infer<typeof stage>>>();
|
|
85
|
+
for (const row of [...rootOutputs, ...childOutputs]) {
|
|
86
|
+
const record = row.currentRun;
|
|
87
|
+
if (record === undefined || record.repository !== name) continue;
|
|
88
|
+
// The output keeps the record of the last completed deploy under `samebase.run`.
|
|
89
|
+
let output = row.value === undefined ? null : withoutSecrets(row.value);
|
|
90
|
+
let deployed: unknown = null;
|
|
91
|
+
if (typeof output === "object" && output !== null && "samebase.run" in output) {
|
|
92
|
+
({ "samebase.run": deployed, ...output } = output);
|
|
93
|
+
}
|
|
94
|
+
let stages = stacks.get(row.stack);
|
|
95
|
+
if (stages === undefined) {
|
|
96
|
+
stages = new Map();
|
|
97
|
+
stacks.set(row.stack, stages);
|
|
98
|
+
}
|
|
99
|
+
stages.set(row.stage, {
|
|
100
|
+
name: row.stage,
|
|
101
|
+
record,
|
|
102
|
+
deployedHeadCommit:
|
|
103
|
+
validate(runRecord, deployed) && deployed.repository === name
|
|
104
|
+
? deployed.headCommit
|
|
105
|
+
: null,
|
|
106
|
+
resources: [],
|
|
107
|
+
output: JSON.stringify(output),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const row of [...rootResources, ...childResources]) {
|
|
111
|
+
stacks
|
|
112
|
+
.get(row.stack)
|
|
113
|
+
?.get(row.stage)
|
|
114
|
+
?.resources.push({
|
|
115
|
+
fqn: row.fqn,
|
|
116
|
+
value: JSON.stringify(withoutSecrets(row.value)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
store: "found" as const,
|
|
121
|
+
stacks: [...stacks]
|
|
122
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
123
|
+
.map(([name, stages]) => ({
|
|
124
|
+
name,
|
|
125
|
+
stages: [...stages.values()].sort((a, b) =>
|
|
126
|
+
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
|
|
127
|
+
),
|
|
128
|
+
})),
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { v, type GenericValidator, type Infer } from "convex/values";
|
|
2
|
+
|
|
3
|
+
export const runRecord = v.object({
|
|
4
|
+
version: v.literal(1),
|
|
5
|
+
repository: v.string(),
|
|
6
|
+
stackFile: v.string(),
|
|
7
|
+
branch: v.string(),
|
|
8
|
+
pullRequest: v.union(v.number(), v.null()),
|
|
9
|
+
headCommit: v.string(),
|
|
10
|
+
runUrl: v.union(v.string(), v.null()),
|
|
11
|
+
source: v.union(v.literal("github-actions"), v.literal("laptop")),
|
|
12
|
+
recordedAt: v.string(),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
/** Whether `value` passes `validator`. Only the kinds of {@link runRecord} pass; another kind fails. */
|
|
16
|
+
export const validate = <V extends GenericValidator>(
|
|
17
|
+
validator: V,
|
|
18
|
+
value: unknown,
|
|
19
|
+
): value is Infer<V> => {
|
|
20
|
+
switch (validator.kind) {
|
|
21
|
+
case "object": {
|
|
22
|
+
if (typeof value !== "object" || value === null) return false;
|
|
23
|
+
const fields = new Map(Object.entries(value));
|
|
24
|
+
return Object.entries(validator.fields).every(([key, field]) =>
|
|
25
|
+
validate(field, fields.get(key)),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
case "union":
|
|
29
|
+
return validator.members.some((member) => validate(member, value));
|
|
30
|
+
case "literal":
|
|
31
|
+
return value === validator.value;
|
|
32
|
+
case "string":
|
|
33
|
+
return typeof value === "string";
|
|
34
|
+
case "float64":
|
|
35
|
+
return typeof value === "number";
|
|
36
|
+
case "null":
|
|
37
|
+
return value === null;
|
|
38
|
+
default:
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineSchema, defineTable } from "convex/server";
|
|
2
2
|
import { v } from "convex/values";
|
|
3
|
+
import { runRecord } from "./runRecord.ts";
|
|
3
4
|
|
|
4
5
|
// `value` is the JSON text of Alchemy's encodeState(...): the same text that
|
|
5
6
|
// Alchemy's local state writes to a file. A Convex object would reject keys
|
|
@@ -14,6 +15,7 @@ export default defineSchema({
|
|
|
14
15
|
outputs: defineTable({
|
|
15
16
|
stack: v.string(),
|
|
16
17
|
stage: v.string(),
|
|
17
|
-
value: v.string(),
|
|
18
|
+
value: v.optional(v.string()),
|
|
19
|
+
currentRun: v.optional(runRecord),
|
|
18
20
|
}).index("by_stage", ["stack", "stage"]),
|
|
19
21
|
});
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// The state functions of the alchemy-state project: one project for each
|
|
2
|
-
// Convex team, pushed by Convex.state() from this directory.
|
|
3
|
-
//
|
|
4
|
-
// deployment URL, with no key (measured). An internal function answers only
|
|
5
|
-
// POST /api/function with a deploy key of the production deployment, the
|
|
6
|
-
// call that `convex run` makes.
|
|
2
|
+
// Convex team, pushed by Convex.state() from this directory. These raw state
|
|
3
|
+
// functions are internal. reader.ts exposes authenticated, redacted reads.
|
|
7
4
|
//
|
|
8
5
|
// A stage holds one row for each resource of its stack, so the reads of one
|
|
9
|
-
// stage collect all rows.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
6
|
+
// stage collect all rows. Samebase reads `stacks`, `getOutput`, and
|
|
7
|
+
// `readStack` by name: a renamed function is a version bump of the store and
|
|
8
|
+
// of Samebase.
|
|
12
9
|
import { v } from "convex/values";
|
|
13
|
-
import type { QueryCtx } from "./_generated/server.js";
|
|
10
|
+
import type { MutationCtx, QueryCtx } from "./_generated/server.js";
|
|
14
11
|
import { internalMutation, internalQuery } from "./_generated/server.js";
|
|
12
|
+
import { runRecord } from "./runRecord.ts";
|
|
13
|
+
import type { RunRecord } from "../../RunRecord.ts";
|
|
15
14
|
import { STORE_VERSION } from "./version.ts";
|
|
16
15
|
|
|
17
16
|
/** The wire version of this store, compared with the client's STORE_VERSION. */
|
|
@@ -44,6 +43,18 @@ const outputOf = (ctx: QueryCtx, args: { stack: string; stage: string }) =>
|
|
|
44
43
|
.withIndex("by_stage", (q) => q.eq("stack", args.stack).eq("stage", args.stage))
|
|
45
44
|
.unique();
|
|
46
45
|
|
|
46
|
+
/** A resource write and its current run become visible in one transaction. */
|
|
47
|
+
const writeCurrentRun = async (
|
|
48
|
+
ctx: MutationCtx,
|
|
49
|
+
args: { stack: string; stage: string; run?: RunRecord | undefined },
|
|
50
|
+
) => {
|
|
51
|
+
const output = await outputOf(ctx, args);
|
|
52
|
+
// A write without a run record clears the record of the previous run.
|
|
53
|
+
if (output !== null) await ctx.db.patch("outputs", output._id, { currentRun: args.run });
|
|
54
|
+
else if (args.run !== undefined)
|
|
55
|
+
await ctx.db.insert("outputs", { stack: args.stack, stage: args.stage, currentRun: args.run });
|
|
56
|
+
};
|
|
57
|
+
|
|
47
58
|
/** The sorted union of two lists of names. */
|
|
48
59
|
const union = (a: readonly string[], b: readonly string[]) => [...new Set([...a, ...b])].sort();
|
|
49
60
|
|
|
@@ -153,22 +164,30 @@ export const replaced = internalQuery({
|
|
|
153
164
|
});
|
|
154
165
|
|
|
155
166
|
export const set = internalMutation({
|
|
156
|
-
args: { ...resource, value: v.string() },
|
|
167
|
+
args: { ...resource, value: v.string(), run: v.optional(runRecord) },
|
|
157
168
|
returns: v.null(),
|
|
158
169
|
handler: async (ctx, args) => {
|
|
159
170
|
const row = await rowOf(ctx, args);
|
|
160
|
-
if (row === null)
|
|
171
|
+
if (row === null)
|
|
172
|
+
await ctx.db.insert("resources", {
|
|
173
|
+
stack: args.stack,
|
|
174
|
+
stage: args.stage,
|
|
175
|
+
fqn: args.fqn,
|
|
176
|
+
value: args.value,
|
|
177
|
+
});
|
|
161
178
|
else await ctx.db.patch("resources", row._id, { value: args.value });
|
|
179
|
+
await writeCurrentRun(ctx, args);
|
|
162
180
|
return null;
|
|
163
181
|
},
|
|
164
182
|
});
|
|
165
183
|
|
|
166
184
|
export const remove = internalMutation({
|
|
167
|
-
args: resource,
|
|
185
|
+
args: { ...resource, run: v.optional(runRecord) },
|
|
168
186
|
returns: v.null(),
|
|
169
187
|
handler: async (ctx, args) => {
|
|
170
188
|
const row = await rowOf(ctx, args);
|
|
171
189
|
if (row !== null) await ctx.db.delete("resources", row._id);
|
|
190
|
+
await writeCurrentRun(ctx, args);
|
|
172
191
|
return null;
|
|
173
192
|
},
|
|
174
193
|
});
|
|
@@ -208,12 +227,14 @@ export const getOutput = internalQuery({
|
|
|
208
227
|
});
|
|
209
228
|
|
|
210
229
|
export const setOutput = internalMutation({
|
|
211
|
-
args: { ...stage, value: v.string() },
|
|
230
|
+
args: { ...stage, value: v.string(), run: v.optional(runRecord) },
|
|
212
231
|
returns: v.null(),
|
|
213
232
|
handler: async (ctx, args) => {
|
|
214
233
|
const row = await outputOf(ctx, args);
|
|
215
|
-
if (row === null)
|
|
234
|
+
if (row === null)
|
|
235
|
+
await ctx.db.insert("outputs", { stack: args.stack, stage: args.stage, value: args.value });
|
|
216
236
|
else await ctx.db.patch("outputs", row._id, { value: args.value });
|
|
237
|
+
await writeCurrentRun(ctx, args);
|
|
217
238
|
return null;
|
|
218
239
|
},
|
|
219
240
|
});
|
|
@@ -235,6 +256,7 @@ export const readStack = internalQuery({
|
|
|
235
256
|
stage: v.string(),
|
|
236
257
|
resources: v.array(v.object({ fqn: v.string(), value: v.string() })),
|
|
237
258
|
output: v.union(v.string(), v.null()),
|
|
259
|
+
record: v.union(runRecord, v.null()),
|
|
238
260
|
}),
|
|
239
261
|
),
|
|
240
262
|
}),
|
|
@@ -253,13 +275,17 @@ export const readStack = internalQuery({
|
|
|
253
275
|
[...new Set(outputs.map((row) => row.stage))],
|
|
254
276
|
);
|
|
255
277
|
return {
|
|
256
|
-
stages: stages.map((stage) =>
|
|
257
|
-
stage
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
278
|
+
stages: stages.map((stage) => {
|
|
279
|
+
const output = outputs.find((row) => row.stage === stage);
|
|
280
|
+
return {
|
|
281
|
+
stage,
|
|
282
|
+
resources: resources
|
|
283
|
+
.filter((row) => row.stage === stage)
|
|
284
|
+
.map((row) => ({ fqn: row.fqn, value: row.value })),
|
|
285
|
+
output: output?.value ?? null,
|
|
286
|
+
record: output?.currentRun ?? null,
|
|
287
|
+
};
|
|
288
|
+
}),
|
|
263
289
|
};
|
|
264
290
|
},
|
|
265
291
|
});
|
|
@@ -1,10 +1,12 @@
|
|
|
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
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// older one or has no `version` yet, and fails the run on a newer one. Raise
|
|
4
|
+
// it with each change of a function. The store and its readers (Samebase) are
|
|
5
|
+
// released and upgraded together: a renamed function is a version bump on
|
|
6
|
+
// both sides.
|
|
6
7
|
//
|
|
7
8
|
// 1: `version`, `stacks`, `stages`, `list`, `get`, `replaced`, `getOutput`,
|
|
8
9
|
// `set`, `remove`, `removeStack`, `setOutput` (0.6.0).
|
|
9
10
|
// 2: `readStack` (0.7.0).
|
|
10
|
-
|
|
11
|
+
// 3: authenticated subscriptions and current stage run records (0.9.0).
|
|
12
|
+
export const STORE_VERSION = 3;
|