@samebase/alchemy-convex 0.7.0 → 0.9.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 +50 -15
- package/lib/index.d.ts +49 -11
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +126 -66
- package/lib/index.js.map +1 -1
- package/package.json +3 -3
- package/src/Deployment.ts +97 -18
- package/src/Providers.ts +2 -2
- package/src/Push.ts +58 -48
- package/src/State.ts +35 -29
- package/src/index.ts +2 -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
|
@@ -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;
|