@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.
@@ -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. Every function
3
- // is internal: a public function of a root app answers anyone who knows the
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. The function names are a wire contract: Samebase
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.
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) await ctx.db.insert("resources", args);
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) await ctx.db.insert("outputs", args);
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
- 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
- })),
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. 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.
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
- export const STORE_VERSION = 2;
11
+ // 3: authenticated subscriptions and current stage run records (0.9.0).
12
+ export const STORE_VERSION = 3;
@@ -3,6 +3,6 @@
3
3
  "private": true,
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
- "convex": "^1.46.0"
6
+ "convex": "^1.48.0"
7
7
  }
8
8
  }