@samebase/alchemy-convex 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@samebase/alchemy-convex",
3
- "version": "0.8.0",
3
+ "version": "0.9.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.46.0",
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.46.0",
70
+ "convex": "^1.48.0",
71
71
  "effect": "^4.0.0"
72
72
  },
73
73
  "engines": {
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, so only POST /api/function with such
18
- // a key reaches them: a public function of a root app answers anyone who
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
- // The function names are a wire contract: Samebase reads `stacks`,
27
- // `getOutput`, and `readStack` by name, and an older client calls an older
28
- // set of names. A name is never renamed or removed. STORE_VERSION rises when
29
- // a function is added or changes in a way that an older client cannot use,
30
- // and a client never pushes an older store over a newer one.
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
- run("set", { stack: r.stack, stage: r.stage, fqn: r.fqn, value: encode(r.value) }),
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
- run("remove", { stack: r.stack, stage: r.stage, fqn: r.fqn }),
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
- run("setOutput", { stack: r.stack, stage: r.stage, value: encode(r.value) }),
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 record of this run, which `setOutput` adds to the stage output with the time of the write, or undefined for a run without one. */
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
- const version = yield* storeVersion(functions);
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
- const now = yield* storeVersion(functions);
381
- if (now === undefined || now < STORE_VERSION) {
390
+ version = yield* storeVersion(functions);
391
+ if (version === undefined || version < STORE_VERSION) {
382
392
  yield* pushStore(options, deployment);
383
- const pushed = yield* storeVersion(functions);
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
- } else if (version > STORE_VERSION) {
391
- // The names are a wire contract, so a newer store serves this client.
392
- yield* Effect.logInfo(
393
- `Convex state: the store of team ${team} is version ${version}, newer than version ${STORE_VERSION} of this package, and it stays.`,
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* () {
@@ -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. 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
  }