From 99fc76f688393e82276ac63f6f375c5309b9f762 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 30 Oct 2026 04:53:20 +0200 Subject: [PATCH 1/5] Export the store's functions; state:version answers { version, host } (store version 5) src/store/functions.ts (`@samebase/alchemy-convex/state/functions`) builds the twelve `state:*` functions with `stateFunctions(alchemyState, host)` and the two `reader:*` functions with `readerFunctions(alchemyState)`, with the validators and semantics of store version 4. `state:version` answers `{ version, host }`. src/store/authConfig.ts (`@samebase/alchemy-convex/state/auth.config`) gives the auth config. The store app of src/store/convex uses the exports as a seed. The package also exports `./state/_generated/component.js`, the `ComponentApi` type that the Convex codegen of a mounting app imports, and the modules of src/store import with `.js` specifiers, so that a base app typechecks them with the Convex tsconfig. function_version.json is re-recorded from the development deployment after the push; the old recording is function_version_1.json, the bare number of an older store. Co-Authored-By: Claude Fable 5.1 --- package.json | 12 ++ src/State.ts | 40 ++-- src/store/authConfig.ts | 17 ++ src/store/component/reader.ts | 2 +- src/store/component/schema.ts | 2 +- src/store/component/state.ts | 2 +- src/store/convex/auth.config.ts | 14 +- src/store/convex/convex.config.ts | 2 +- src/store/convex/reader.ts | 52 +---- src/store/convex/state.ts | 141 ++------------ src/store/convex/version.ts | 15 +- src/store/functions.ts | 202 ++++++++++++++++++++ test/State.test.ts | 7 +- test/fakeConvex.ts | 27 ++- test/fixtures/state/README.md | 7 +- test/fixtures/state/function_version.json | 2 +- test/fixtures/state/function_version_1.json | 1 + test/store.test.ts | 28 ++- test/storeTest.ts | 5 +- 19 files changed, 355 insertions(+), 223 deletions(-) create mode 100644 src/store/authConfig.ts create mode 100644 src/store/functions.ts create mode 100644 test/fixtures/state/function_version_1.json diff --git a/package.json b/package.json index 199ba63..4fc821f 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,18 @@ "./state/convex.config": { "types": "./src/store/component/convex.config.ts", "default": "./src/store/component/convex.config.ts" + }, + "./state/_generated/component.js": { + "types": "./src/store/component/_generated/component.ts", + "default": "./src/store/component/_generated/component.ts" + }, + "./state/functions": { + "types": "./src/store/functions.ts", + "default": "./src/store/functions.ts" + }, + "./state/auth.config": { + "types": "./src/store/authConfig.ts", + "default": "./src/store/authConfig.ts" } }, "publishConfig": { diff --git a/src/State.ts b/src/State.ts index 300a116..df1ab15 100644 --- a/src/State.ts +++ b/src/State.ts @@ -229,17 +229,31 @@ const remoteOf = (api: FunctionApi, record: RunRecord | undefined) => { }; }; -/** The version that the store answers, or undefined when the deployment has no `state:version`. */ +/** + * The reply of `state:version` since store version 5: the store version and + * the app that serves it, `seed` (the store app of this package) or `base` + * (the base app of the team). A store of version 4 or older answers a bare + * number, and only the store app of this package served those versions. + */ +const StoreVersion = Schema.Union([ + Schema.Struct({ version: Schema.Number, host: Schema.Literals(["seed", "base"]) }), + Schema.Number, +]); + +/** The version and host that the store answers, or undefined when the deployment has no `state:version`. */ const storeVersion = (api: FunctionApi) => - api - .call("state:version", {}) - .pipe( - Effect.flatMap((reply) => - reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) - ? Effect.succeed(undefined) - : valueOf(reply).pipe(Effect.flatMap(decode(Schema.Number, "store version"))), - ), - ); + api.call("state:version", {}).pipe( + Effect.flatMap((reply) => + reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) + ? Effect.succeed(undefined) + : valueOf(reply).pipe( + Effect.flatMap(decode(StoreVersion, "store version")), + Effect.map((store) => + typeof store === "number" ? { version: store, host: "seed" as const } : store, + ), + ), + ), + ); /** * The functions directory of the store app, `src/store/convex` of this @@ -379,7 +393,7 @@ export const makeConvexState = (options: StoreOptions) => ), }; - let version = yield* storeVersion(functions); + let version = (yield* storeVersion(functions))?.version; if (version === undefined || version < STORE_VERSION) { // Code without `state:version` is another app with the name of the store. if (version === undefined && deployment.lastDeployTime) { @@ -389,10 +403,10 @@ export const makeConvexState = (options: StoreOptions) => } // Another run can push a newer store in the meantime: read again right // before the push. The push itself has no lock (see the design note). - version = yield* storeVersion(functions); + version = (yield* storeVersion(functions))?.version; if (version === undefined || version < STORE_VERSION) { yield* pushStore(options, deployment); - version = yield* storeVersion(functions); + version = (yield* storeVersion(functions))?.version; } } if (version !== STORE_VERSION) { diff --git a/src/store/authConfig.ts b/src/store/authConfig.ts new file mode 100644 index 0000000..5a0ef1c --- /dev/null +++ b/src/store/authConfig.ts @@ -0,0 +1,17 @@ +// The auth config of each app that serves the store: the app verifies the +// read tokens that `reader:authorize` signs with the key of its own +// deployment. Exported as `@samebase/alchemy-convex/state/auth.config`. +import type { AuthConfig } from "convex/server"; + +/** The auth config of the store readers on the deployment at `cloudUrl`, the app's `env.CONVEX_CLOUD_URL`. */ +export const authConfig = (cloudUrl: string): AuthConfig => ({ + providers: [ + { + type: "customJwt", + issuer: cloudUrl, + applicationID: `${cloudUrl}/alchemy-reader`, + jwks: `${cloudUrl}/.well-known/jwks.json`, + algorithm: "ES256", + }, + ], +}); diff --git a/src/store/component/reader.ts b/src/store/component/reader.ts index a7ca2f3..cdf263f 100644 --- a/src/store/component/reader.ts +++ b/src/store/component/reader.ts @@ -3,7 +3,7 @@ // `ctx.auth`. import { v, type Infer } from "convex/values"; import { query } from "./_generated/server.js"; -import { runRecord, validate } from "./runRecord.ts"; +import { runRecord, validate } from "./runRecord.js"; const stage = v.object({ name: v.string(), diff --git a/src/store/component/schema.ts b/src/store/component/schema.ts index 013039f..1b0e743 100644 --- a/src/store/component/schema.ts +++ b/src/store/component/schema.ts @@ -1,6 +1,6 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -import { runRecord } from "./runRecord.ts"; +import { runRecord } from "./runRecord.js"; // `value` is the JSON text of Alchemy's encodeState(...): the same text that // Alchemy's local state writes to a file. A Convex object would reject keys diff --git a/src/store/component/state.ts b/src/store/component/state.ts index 5b32b3f..5b59950 100644 --- a/src/store/component/state.ts +++ b/src/store/component/state.ts @@ -8,7 +8,7 @@ import { v, type Infer } from "convex/values"; import type { MutationCtx, QueryCtx } from "./_generated/server.js"; import { mutation, query } from "./_generated/server.js"; -import { runRecord } from "./runRecord.ts"; +import { runRecord } from "./runRecord.js"; const stage = { stack: v.string(), stage: v.string() }; const resource = { ...stage, fqn: v.string() }; diff --git a/src/store/convex/auth.config.ts b/src/store/convex/auth.config.ts index 0917038..0a126c7 100644 --- a/src/store/convex/auth.config.ts +++ b/src/store/convex/auth.config.ts @@ -1,14 +1,4 @@ -import type { AuthConfig } from "convex/server"; +import { authConfig } from "../authConfig.js"; import { env } from "./_generated/server.js"; -export default { - providers: [ - { - type: "customJwt", - issuer: env.CONVEX_CLOUD_URL, - applicationID: `${env.CONVEX_CLOUD_URL}/alchemy-reader`, - jwks: `${env.CONVEX_CLOUD_URL}/.well-known/jwks.json`, - algorithm: "ES256", - }, - ], -} satisfies AuthConfig; +export default authConfig(env.CONVEX_CLOUD_URL); diff --git a/src/store/convex/convex.config.ts b/src/store/convex/convex.config.ts index bb21316..3373438 100644 --- a/src/store/convex/convex.config.ts +++ b/src/store/convex/convex.config.ts @@ -1,5 +1,5 @@ import { defineApp } from "convex/server"; -import alchemyState from "../component/convex.config.ts"; +import alchemyState from "../component/convex.config.js"; const app = defineApp(); app.use(alchemyState); diff --git a/src/store/convex/reader.ts b/src/store/convex/reader.ts index ae8acfa..f2eb3da 100644 --- a/src/store/convex/reader.ts +++ b/src/store/convex/reader.ts @@ -1,51 +1,5 @@ -// Authenticated reads stay in the app: `authorize` signs and `repository` -// verifies with `ctx.auth`, which a component does not have. The rows and the -// redaction are in the alchemyState component. -import { v } from "convex/values"; -import { repositoryReply } from "../component/reader.ts"; +// The authenticated, redacted reads of the store app, from src/store/functions.ts. +import { readerFunctions } from "../functions.js"; import { components } from "./_generated/api.js"; -import { env, internalMutation, query } from "./_generated/server.js"; -const repositoryName = /^[^/]+\/[^/]+$/; - -/** Only a deployment credential can exchange an approved repository for a read token. */ -export const authorize = internalMutation({ - args: { repository: v.string(), subject: v.string() }, - returns: v.object({ token: v.string() }), - handler: async (ctx, args) => { - if (!repositoryName.test(args.repository) || args.subject === "") { - throw new Error("A repository and subject are required."); - } - return { - token: await ctx.auth.signJwt({ - expiresInSeconds: 300, - claims: { - sub: args.subject, - aud: `${env.CONVEX_CLOUD_URL}/alchemy-reader`, - repository: args.repository, - scope: "alchemy:read", - }, - }), - }; - }, -}); - -/** A native subscription reads only the repository named in its verified capability. */ -export const repository = query({ - args: {}, - returns: repositoryReply, - handler: async (ctx) => { - const identity = await ctx.auth.getUserIdentity(); - if ( - identity === null || - identity.scope !== "alchemy:read" || - typeof identity.repository !== "string" || - !repositoryName.test(identity.repository) - ) { - throw new Error("Alchemy repository read access is required."); - } - return await ctx.runQuery(components.alchemyState.reader.repository, { - repository: identity.repository, - }); - }, -}); +export const { authorize, repository } = readerFunctions(components.alchemyState); diff --git a/src/store/convex/state.ts b/src/store/convex/state.ts index 0d346ff..d607224 100644 --- a/src/store/convex/state.ts +++ b/src/store/convex/state.ts @@ -1,123 +1,22 @@ -// The state functions of the samebase project of a team: one project for each -// Convex team, pushed by Convex.state() from this directory. These raw state -// functions are internal. reader.ts exposes authenticated, redacted reads. -// -// The rows are in the alchemyState component (src/store/component). Each -// function here forwards to the component function of the same name, with the -// same results; an absent `run` or `stage` goes to the component as null. -// These names and arguments are the wire contract of STORE_VERSION. Samebase -// reads `stacks`, `getOutput`, and `readStack` by name: a renamed function is -// a version bump of the store and of Samebase. -import { v } from "convex/values"; -import { runRecord } from "../component/runRecord.ts"; -import { stackReply } from "../component/state.ts"; +// The state functions of the store app of this package, a seed: Convex.state() +// pushes this directory to the samebase project of a team. The raw state +// functions are internal; reader.ts exposes authenticated, redacted reads. +// The functions come from src/store/functions.ts, which a base app imports +// too. Each export has its own name, so that Convex finds it. +import { stateFunctions } from "../functions.js"; import { components } from "./_generated/api.js"; -import { internalMutation, internalQuery } from "./_generated/server.js"; -import { STORE_VERSION } from "./version.ts"; -const state = components.alchemyState.state; - -/** The wire version of this store, compared with the client's STORE_VERSION. */ -export const version = internalQuery({ - args: {}, - returns: v.number(), - handler: async () => STORE_VERSION, -}); - -/** Stacks with at least one resource or one output, sorted. */ -export const stacks = internalQuery({ - args: {}, - returns: v.array(v.string()), - handler: (ctx) => ctx.runQuery(state.stacks, {}), -}); - -/** Stages of a stack with at least one resource or one output, sorted. */ -export const stages = internalQuery({ - args: { stack: v.string() }, - returns: v.array(v.string()), - handler: (ctx, args) => ctx.runQuery(state.stages, { stack: args.stack }), -}); - -export const list = internalQuery({ - args: { stack: v.string(), stage: v.string() }, - returns: v.array(v.string()), - handler: (ctx, args) => ctx.runQuery(state.list, { stack: args.stack, stage: args.stage }), -}); - -export const get = internalQuery({ - args: { stack: v.string(), stage: v.string(), fqn: v.string() }, - returns: v.union(v.string(), v.null()), - handler: (ctx, args) => - ctx.runQuery(state.get, { stack: args.stack, stage: args.stage, fqn: args.fqn }), -}); - -/** Resources whose state has `status: "replaced"`. */ -export const replaced = internalQuery({ - args: { stack: v.string(), stage: v.string() }, - returns: v.array(v.string()), - handler: (ctx, args) => ctx.runQuery(state.replaced, { stack: args.stack, stage: args.stage }), -}); - -export const set = internalMutation({ - args: { - stack: v.string(), - stage: v.string(), - fqn: v.string(), - value: v.string(), - run: v.optional(runRecord), - }, - returns: v.null(), - handler: (ctx, args) => - ctx.runMutation(state.set, { - stack: args.stack, - stage: args.stage, - fqn: args.fqn, - value: args.value, - run: args.run ?? null, - }), -}); - -export const remove = internalMutation({ - args: { stack: v.string(), stage: v.string(), fqn: v.string(), run: v.optional(runRecord) }, - returns: v.null(), - handler: (ctx, args) => - ctx.runMutation(state.remove, { - stack: args.stack, - stage: args.stage, - fqn: args.fqn, - run: args.run ?? null, - }), -}); - -/** Deletes the resources and outputs of one stage, or of every stage of the stack. */ -export const removeStack = internalMutation({ - args: { stack: v.string(), stage: v.optional(v.string()) }, - returns: v.null(), - handler: (ctx, args) => - ctx.runMutation(state.removeStack, { stack: args.stack, stage: args.stage ?? null }), -}); - -export const getOutput = internalQuery({ - args: { stack: v.string(), stage: v.string() }, - returns: v.union(v.string(), v.null()), - handler: (ctx, args) => ctx.runQuery(state.getOutput, { stack: args.stack, stage: args.stage }), -}); - -export const setOutput = internalMutation({ - args: { stack: v.string(), stage: v.string(), value: v.string(), run: v.optional(runRecord) }, - returns: v.null(), - handler: (ctx, args) => - ctx.runMutation(state.setOutput, { - stack: args.stack, - stage: args.stage, - value: args.value, - run: args.run ?? null, - }), -}); - -/** Every stage of a stack with its resources, output, and current record, in one call. Since store version 2. */ -export const readStack = internalQuery({ - args: { stack: v.string() }, - returns: stackReply, - handler: (ctx, args) => ctx.runQuery(state.readStack, { stack: args.stack }), -}); +export const { + version, + stacks, + stages, + list, + get, + replaced, + set, + remove, + removeStack, + getOutput, + setOutput, + readStack, +} = stateFunctions(components.alchemyState, "seed"); diff --git a/src/store/convex/version.ts b/src/store/convex/version.ts index 7e4f837..0577681 100644 --- a/src/store/convex/version.ts +++ b/src/store/convex/version.ts @@ -1,14 +1,15 @@ // The wire version of the store: the `state:version` query answers it, and -// Convex.state() pushes this directory when the store of the team answers an -// older one or has no `version` yet, and fails the run on a newer one. Raise -// it with each change of a function of this app or of the alchemyState -// component (src/store/component). The store and its readers (Samebase) are -// released and upgraded together: a renamed function is a version bump on -// both sides. +// Convex.state() pushes the store app when a seed answers an older one or the +// deployment has no `version` yet. It fails the run when a base answers an +// older one, and on a newer one. Raise it with each change of a function of +// src/store/functions.ts or of the alchemyState component +// (src/store/component). The store and its readers (Samebase) are released +// and upgraded together: a renamed function is a version bump on both sides. // // 1: `version`, `stacks`, `stages`, `list`, `get`, `replaced`, `getOutput`, // `set`, `remove`, `removeStack`, `setOutput` (0.6.0). // 2: `readStack` (0.7.0). // 3: authenticated subscriptions and current stage run records (0.9.0). // 4: the store is a component (same functions). -export const STORE_VERSION = 4; +// 5: `version` answers `{ version, host }`; the functions come from `@samebase/alchemy-convex/state/functions`. +export const STORE_VERSION = 5; diff --git a/src/store/functions.ts b/src/store/functions.ts new file mode 100644 index 0000000..7a23c07 --- /dev/null +++ b/src/store/functions.ts @@ -0,0 +1,202 @@ +// The functions of the state store, for each app that serves the store. Two +// apps do: the store app of this package (src/store/convex), which +// Convex.state() pushes to the samebase project of a team, and the base app of +// a team, which mounts the alchemyState component next to its own components +// in the same project. A push replaces every function of a deployment, so both +// apps serve these functions with the same names. Exported as +// `@samebase/alchemy-convex/state/functions`. +// +// Each `state:*` function forwards to the component function of the same +// name, with the same results; an absent `run` or `stage` goes to the +// component as null. These names and arguments are the wire contract of +// STORE_VERSION. Samebase reads `stacks`, `getOutput`, `readStack`, and +// `reader:repository` by name: a renamed function is a version bump of the +// store and of Samebase. +import { internalMutationGeneric, internalQueryGeneric, queryGeneric } from "convex/server"; +import { type Infer, v } from "convex/values"; +import type { ComponentApi } from "./component/_generated/component.js"; +import { env } from "./component/_generated/server.js"; +import { repositoryReply } from "./component/reader.js"; +import { runRecord } from "./component/runRecord.js"; +import { stackReply } from "./component/state.js"; +import { STORE_VERSION } from "./convex/version.js"; + +/** + * The app that serves the store. `seed`: the store app of this package, which + * Convex.state() pushes. `base`: the base app of the team, which + * Convex.state() never pushes over. + */ +const storeHost = v.union(v.literal("seed"), v.literal("base")); + +/** + * The internal `state:*` functions over the mounted component `alchemyState`. + * `state:version` answers `host`, the app that serves them. Export each one + * by name from the app's `convex/state.ts`. + */ +export const stateFunctions = (alchemyState: ComponentApi, host: Infer) => { + const state = alchemyState.state; + return { + /** The wire version of this store, compared with the client's STORE_VERSION, and the app that serves it. */ + version: internalQueryGeneric({ + args: {}, + returns: v.object({ version: v.number(), host: storeHost }), + handler: async () => ({ version: STORE_VERSION, host }), + }), + + /** Stacks with at least one resource or one output, sorted. */ + stacks: internalQueryGeneric({ + args: {}, + returns: v.array(v.string()), + handler: (ctx) => ctx.runQuery(state.stacks, {}), + }), + + /** Stages of a stack with at least one resource or one output, sorted. */ + stages: internalQueryGeneric({ + args: { stack: v.string() }, + returns: v.array(v.string()), + handler: (ctx, args) => ctx.runQuery(state.stages, { stack: args.stack }), + }), + + list: internalQueryGeneric({ + args: { stack: v.string(), stage: v.string() }, + returns: v.array(v.string()), + handler: (ctx, args) => ctx.runQuery(state.list, { stack: args.stack, stage: args.stage }), + }), + + get: internalQueryGeneric({ + args: { stack: v.string(), stage: v.string(), fqn: v.string() }, + returns: v.union(v.string(), v.null()), + handler: (ctx, args) => + ctx.runQuery(state.get, { stack: args.stack, stage: args.stage, fqn: args.fqn }), + }), + + /** Resources whose state has `status: "replaced"`. */ + replaced: internalQueryGeneric({ + args: { stack: v.string(), stage: v.string() }, + returns: v.array(v.string()), + handler: (ctx, args) => + ctx.runQuery(state.replaced, { stack: args.stack, stage: args.stage }), + }), + + set: internalMutationGeneric({ + args: { + stack: v.string(), + stage: v.string(), + fqn: v.string(), + value: v.string(), + run: v.optional(runRecord), + }, + returns: v.null(), + handler: (ctx, args) => + ctx.runMutation(state.set, { + stack: args.stack, + stage: args.stage, + fqn: args.fqn, + value: args.value, + run: args.run ?? null, + }), + }), + + remove: internalMutationGeneric({ + args: { stack: v.string(), stage: v.string(), fqn: v.string(), run: v.optional(runRecord) }, + returns: v.null(), + handler: (ctx, args) => + ctx.runMutation(state.remove, { + stack: args.stack, + stage: args.stage, + fqn: args.fqn, + run: args.run ?? null, + }), + }), + + /** Deletes the resources and outputs of one stage, or of every stage of the stack. */ + removeStack: internalMutationGeneric({ + args: { stack: v.string(), stage: v.optional(v.string()) }, + returns: v.null(), + handler: (ctx, args) => + ctx.runMutation(state.removeStack, { stack: args.stack, stage: args.stage ?? null }), + }), + + getOutput: internalQueryGeneric({ + args: { stack: v.string(), stage: v.string() }, + returns: v.union(v.string(), v.null()), + handler: (ctx, args) => + ctx.runQuery(state.getOutput, { stack: args.stack, stage: args.stage }), + }), + + setOutput: internalMutationGeneric({ + args: { stack: v.string(), stage: v.string(), value: v.string(), run: v.optional(runRecord) }, + returns: v.null(), + handler: (ctx, args) => + ctx.runMutation(state.setOutput, { + stack: args.stack, + stage: args.stage, + value: args.value, + run: args.run ?? null, + }), + }), + + /** Every stage of a stack with its resources, output, and current record, in one call. Since store version 2. */ + readStack: internalQueryGeneric({ + args: { stack: v.string() }, + returns: stackReply, + handler: (ctx, args) => ctx.runQuery(state.readStack, { stack: args.stack }), + }), + }; +}; + +const repositoryName = /^[^/]+\/[^/]+$/; + +/** + * The `reader:*` functions over the mounted component `alchemyState`. Export + * each one by name from the app's `convex/reader.ts`. They stay in the app: + * `authorize` signs and `repository` verifies with `ctx.auth`, which a + * component does not have. The rows and the redaction are in the component. + * The token audience is `${CONVEX_CLOUD_URL}/alchemy-reader`, the application + * id of authConfig.ts. CONVEX_CLOUD_URL is a system variable of the + * deployment, the same for the app and the component, so the typed `env` of + * the component's codegen gives it. + */ +export const readerFunctions = (alchemyState: ComponentApi) => ({ + /** Only a deployment credential can exchange an approved repository for a read token. */ + authorize: internalMutationGeneric({ + args: { repository: v.string(), subject: v.string() }, + returns: v.object({ token: v.string() }), + handler: async (ctx, args) => { + if (!repositoryName.test(args.repository) || args.subject === "") { + throw new Error("A repository and subject are required."); + } + return { + token: await ctx.auth.signJwt({ + expiresInSeconds: 300, + claims: { + sub: args.subject, + aud: `${env.CONVEX_CLOUD_URL}/alchemy-reader`, + repository: args.repository, + scope: "alchemy:read", + }, + }), + }; + }, + }), + + /** A native subscription reads only the repository named in its verified capability. */ + repository: queryGeneric({ + args: {}, + returns: repositoryReply, + handler: async (ctx) => { + const identity = await ctx.auth.getUserIdentity(); + if ( + identity === null || + identity.scope !== "alchemy:read" || + typeof identity.repository !== "string" || + !repositoryName.test(identity.repository) + ) { + throw new Error("Alchemy repository read access is required."); + } + return await ctx.runQuery(alchemyState.reader.repository, { + repository: identity.repository, + }); + }, + }), +}); diff --git a/test/State.test.ts b/test/State.test.ts index ce29e90..5d1795b 100644 --- a/test/State.test.ts +++ b/test/State.test.ts @@ -247,11 +247,12 @@ describe("Convex.state", () => { const deployment = fake.project(fake.addProject({ name: STORE_PROJECT_NAME, teamId: TEAM })) ?.prodDeploymentName ?? ""; - // The deployment answers version 0 until the fake CLI "pushed", then the current version. + // The deployment answers version 0, a bare number as a seed of store + // version 4 or older does, until the fake CLI "pushed", then the current version. const store = fake.addStore(deployment, STORE_VERSION); fake.scripted.set(`POST ${deployment}.convex.cloud/api/function`, [ - { status: 200, body: { ...stateFixture("function_version.json"), value: 0 } }, - { status: 200, body: { ...stateFixture("function_version.json"), value: 0 } }, + { status: 200, body: { ...stateFixture("function_version_1.json"), value: 0 } }, + { status: 200, body: { ...stateFixture("function_version_1.json"), value: 0 } }, ]); await run(open()); expect(await run(runs())).toHaveLength(1); diff --git a/test/fakeConvex.ts b/test/fakeConvex.ts index 5c9210f..933a8be 100644 --- a/test/fakeConvex.ts +++ b/test/fakeConvex.ts @@ -55,9 +55,11 @@ export interface StoreRow { readonly value: string; } -/** The store of src/store/convex on one deployment: its version, rows, and outputs. */ +/** The store on one deployment: its version, the app that serves it, its rows, and outputs. */ export interface Store { version: number; + /** `seed`: the store app of src/store/convex. `base`: the base app of the team. */ + host: "seed" | "base"; rows: StoreRow[]; readonly outputs: Map; } @@ -76,6 +78,8 @@ const STORE_QUERIES = [ const STORE_MUTATIONS = ["set", "remove", "removeStack", "setOutput"]; /** The store version that added a function; every other function is in version 1 (0.6.0). */ const STORE_FUNCTION_SINCE: Record = { readStack: 2 }; +/** The store version whose `version` answers `{ version, host }`; an older seed answers a bare number. */ +const VERSION_HOST_SINCE = 5; const FunctionCall = Schema.Struct({ path: Schema.String, args: Schema.Struct({ @@ -200,9 +204,9 @@ export class FakeConvex { return id; } - /** Gives a deployment the store at `version`, with no rows, and returns it. */ - addStore(deployment: string, version: number) { - const store: Store = { version, rows: [], outputs: new Map() }; + /** Gives a deployment the store at `version` that `host` serves, with no rows, and returns it. */ + addStore(deployment: string, version: number, host: Store["host"] = "seed") { + const store: Store = { version, host, rows: [], outputs: new Map() }; this.stores.set(deployment, store); return store; } @@ -468,11 +472,14 @@ export class FakeConvex { const { path, args } = Schema.decodeUnknownSync(FunctionCall)(body); const name = path.replace(/^state:/, ""); // A push of the fake CLI with a key of this deployment installs the - // store, or raises an older one to the current version with its rows. + // store app, or replaces an older store with it and keeps the rows. if (this.pushedByCli(deployment)) { const pushed = this.stores.get(deployment); if (pushed === undefined) this.addStore(deployment, STORE_VERSION); - else if (pushed.version < STORE_VERSION) pushed.version = STORE_VERSION; + else if (pushed.version < STORE_VERSION) { + pushed.version = STORE_VERSION; + pushed.host = "seed"; + } } const store = this.stores.get(deployment); const reply = (value: unknown) => @@ -502,8 +509,14 @@ export class FakeConvex { const index = store.rows.findIndex((row) => inStage(row) && row.fqn === fqn); const output = `${stack}/${stage}`; switch (name) { + // A base answers `{ version, host }` at each version: a test of an + // older base stands for a base that is older than the package. case "version": - return reply(store.version); + return reply( + store.host === "seed" && store.version < VERSION_HOST_SINCE + ? store.version + : { version: store.version, host: store.host }, + ); case "stacks": return reply([...new Set(store.rows.map((row) => row.stack))].sort()); case "stages": diff --git a/test/fixtures/state/README.md b/test/fixtures/state/README.md index 402f7d5..9b015db 100644 --- a/test/fixtures/state/README.md +++ b/test/fixtures/state/README.md @@ -7,7 +7,7 @@ Ids are real and harmless. No file contains a key. | File | Request | | --- | --- | -| function_version.json | POST /api/function `state:version` with a key that has `deployment:functions:runInternalQueries` | +| function_version_1.json | POST /api/function `state:version` of store version 1 with a key that has `deployment:functions:runInternalQueries`: a bare number, as a store of version 4 or older answers | | function_version_missing.json | POST /api/function for a function that the deployment does not have | | function_no_key.json | POST /api/function without a key (403) | | function_not_permitted.json | POST /api/function `state:set` with a key that has only `deployment:functions:runInternalQueries` | @@ -17,6 +17,11 @@ Ids are real and harmless. No file contains a key. | function_stacks.json | POST /api/function `state:stacks` | | public_mutation_no_key.json | POST /api/mutation `state:set` without a key, while the store still had public functions (200): the reason every function is internal | +`function_version.json` was captured on 2026-10-30 against the development deployment +`gallant-skunk-112` of the project `samebase` in team `nicu`, after a push of `src/store/convex` +(store version 5) with `npx convex dev --once`: POST /api/function `state:version` with a key that +has `deployment:functions:runInternalQueries` only. The key was deleted after the call. + Facts learned: `deployment:data:view` and `deployment:data:write` do not run an internal function (the reply names `deployment:functions:runInternalQueries` or `runInternalMutations`). An internal function through `/api/query` without a key answers a bare "Server Error". A deploy key must expire diff --git a/test/fixtures/state/function_version.json b/test/fixtures/state/function_version.json index 0ad44ad..9f11afd 100644 --- a/test/fixtures/state/function_version.json +++ b/test/fixtures/state/function_version.json @@ -1 +1 @@ -{"status":"success","value":1.0} +{"status":"success","value":{"host":"seed","version":5.0}} \ No newline at end of file diff --git a/test/fixtures/state/function_version_1.json b/test/fixtures/state/function_version_1.json new file mode 100644 index 0000000..0ad44ad --- /dev/null +++ b/test/fixtures/state/function_version_1.json @@ -0,0 +1 @@ +{"status":"success","value":1.0} diff --git a/test/store.test.ts b/test/store.test.ts index a53fd7d..da014b6 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -1,16 +1,38 @@ // The store functions of src/store/convex and its alchemyState component in convex-test. import { describe, expect, it } from "vitest"; -import { internal } from "../src/store/convex/_generated/api.js"; +import { components, internal } from "../src/store/convex/_generated/api.js"; import { STORE_VERSION } from "../src/store/convex/version.ts"; +import { stateFunctions } from "../src/store/functions.ts"; import { storeTest } from "./storeTest.ts"; const main = { stack: "samebase/app", stage: "main" }; const preview = { stack: "samebase/app", stage: "pr-1" }; describe("the state store", () => { - it("answers its wire version", async () => { + it("answers its wire version and its host", async () => { const t = storeTest(); - expect(await t.query(internal.state.version, {})).toBe(STORE_VERSION); + expect(await t.query(internal.state.version, {})).toEqual({ + version: STORE_VERSION, + host: "seed", + }); + }); + + it("serves the same functions from a base app, which answers host base", async () => { + // A base app: the component mounted, and `state:*` from the factory with host "base". + const t = storeTest({ + "../src/store/convex/state.ts": async () => stateFunctions(components.alchemyState, "base"), + }); + expect(await t.query(internal.state.version, {})).toEqual({ + version: STORE_VERSION, + host: "base", + }); + await t.mutation(internal.state.set, { ...main, fqn: "Project", value: "{}" }); + expect(await t.query(internal.state.stacks, {})).toEqual(["samebase/app"]); + expect(await t.query(internal.state.readStack, { stack: "samebase/app" })).toEqual({ + stages: [ + { stage: "main", resources: [{ fqn: "Project", value: "{}" }], output: null, record: null }, + ], + }); }); it("does not read a row of store version 3, which sits in a root table", async () => { diff --git a/test/storeTest.ts b/test/storeTest.ts index 7a32c16..b1c4093 100644 --- a/test/storeTest.ts +++ b/test/storeTest.ts @@ -7,8 +7,9 @@ import schema from "../src/store/component/schema.ts"; const app = import.meta.glob("../src/store/convex/**/*.ts"); const component = import.meta.glob("../src/store/component/**/*.ts"); -export const storeTest = () => { - const t = convexTest({ modules: app }); +/** The store app, with the modules of `replace` in place of its own modules of the same path. */ +export const storeTest = (replace: Record Promise> = {}) => { + const t = convexTest({ modules: { ...app, ...replace } }); t.registerComponent("alchemyState", schema, component); return t; }; From d94bb82150bb7dd46b8f86fd4068b3e7bf08a409 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 30 Oct 2026 04:53:39 +0200 Subject: [PATCH 2/5] Convex.state never pushes its store app over a base A base app serves the store functions next to its own functions, and a push of the store app would replace them. The run pushes only over no store or a seed of an older version. A base of an older version fails the run with the exported OutdatedBase, which names the dependency @samebase/alchemy-convex of the base app; a newer store fails the run from either host, as before. Co-Authored-By: Claude Fable 5.1 --- src/State.ts | 69 +++++++++++++++++++++++++++++++--------------- src/index.ts | 2 +- test/State.test.ts | 29 +++++++++++++++++++ 3 files changed, 77 insertions(+), 23 deletions(-) diff --git a/src/State.ts b/src/State.ts index df1ab15..109c477 100644 --- a/src/State.ts +++ b/src/State.ts @@ -4,18 +4,21 @@ // every stack and stage of the team in its production deployment: the // tables `resources (stack, stage, fqn, value)` and `outputs (stack, stage, // value)` of the alchemyState component (src/store/component), served by the -// functions of src/store/convex. The store lives -// outside every resource that a stack manages, so a stack can create and -// destroy its own project. See docs/design/convex-state.md. +// functions of src/store/functions.ts. Two apps serve them: the store app of +// src/store/convex (a seed), which this provider pushes, and the base app of +// the team (a base), which mounts the component next to its own components. +// The store lives outside every resource that a stack manages, so a stack +// can create and destroy its own project. See docs/design/convex-state.md. // // Bootstrap, on the first call of the run: the team token (CONVEX_ACCESS_TOKEN, // or the Convex CLI login) finds the project by name, or creates it; a deploy // key of its production deployment that can run internal queries and // mutations, and nothing else, is created for the run and deleted at its -// end; the `state:version` query says whether the deployment has the store -// of this version. Without one, or with an older one, the installed Convex -// CLI pushes src/store/convex to it, with a push key that is deleted after -// the push. The raw state functions are internal; authenticated readers get +// end; the `state:version` query says the store version and the app that +// serves it. Without one, or with an older seed, the installed Convex CLI +// pushes src/store/convex to it, with a push key that is deleted after the +// push. An older base fails the run: a push of the store app would erase the +// base app. The raw state functions are internal; authenticated readers get // repository-scoped subscriptions with secret values removed. A key that stops working during a // long run (it expires after two hours) is replaced once per call. // @@ -26,8 +29,8 @@ // Samebase reads `stacks`, `getOutput`, `readStack`, and `reader:repository` // by name. The store and its readers are released and upgraded together: // STORE_VERSION rises with each change of a function, and a client works only -// with a store of its own version. An older store is pushed over; a newer one -// fails the run. +// with a store of its own version. An older seed is pushed over; an older base +// and a newer store fail the run. // // The stage output that `setOutput` stores is the stack's output plus the // run record of src/RunRecord.ts under the key `samebase.run`: the @@ -255,6 +258,21 @@ const storeVersion = (api: FunctionApi) => ), ); +/** + * The base app of the team serves an older store. Convex.state() never + * pushes its store app over a base, because the push replaces every function + * of the deployment and so erases the base app. + */ +export class OutdatedBase extends Schema.TaggedError()("OutdatedBase", { + team: Schema.Union([Schema.String, Schema.Number]), + deployment: Schema.String, + version: Schema.Number, +}) { + override get message() { + return `The base of team ${this.team} (${this.deployment}) answers store version ${this.version}, and this package needs store version ${STORE_VERSION}. Convex.state does not push over a base. Upgrade the dependency @samebase/alchemy-convex of the base app to the version of this package, and push the base app.`; + } +} + /** * The functions directory of the store app, `src/store/convex` of this * package. This file is `src/State.ts` in a checkout and a chunk in `lib/` @@ -344,8 +362,8 @@ const createStoreProject = (api: ManagementApiService, teamId: number, team: str /** * The StateService over the `samebase` project of the team: found or - * created, its store pushed when it is missing or older, and read and - * written with the keys of this run. + * created, its store app pushed when the store is missing or an older seed, + * and read and written with the keys of this run. */ export const makeConvexState = (options: StoreOptions) => Effect.gen(function* () { @@ -393,25 +411,31 @@ export const makeConvexState = (options: StoreOptions) => ), }; - let version = (yield* storeVersion(functions))?.version; - if (version === undefined || version < STORE_VERSION) { + // The store app is pushed only over no store or an older seed. A base + // serves the same functions, and a push would erase the base app. + let store = yield* storeVersion(functions); + if (store === undefined || (store.host === "seed" && store.version < STORE_VERSION)) { // Code without `state:version` is another app with the name of the store. - if (version === undefined && deployment.lastDeployTime) { + if (store === undefined && 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.`, ); } - // Another run can push a newer store in the meantime: read again right - // before the push. The push itself has no lock (see the design note). - version = (yield* storeVersion(functions))?.version; - if (version === undefined || version < STORE_VERSION) { + // Another run can push a newer store, or the base app, in the meantime: + // read again right before the push. The push itself has no lock (see + // the design note). + store = yield* storeVersion(functions); + if (store === undefined || (store.host === "seed" && store.version < STORE_VERSION)) { yield* pushStore(options, deployment); - version = (yield* storeVersion(functions))?.version; + store = yield* storeVersion(functions); } } - if (version !== STORE_VERSION) { + if (store?.host === "base" && store.version < STORE_VERSION) { + return yield* new OutdatedBase({ team, deployment: deployment.name, version: store.version }); + } + if (store?.version !== STORE_VERSION) { return yield* failure( - `${deployment.name} answers store version ${version ?? "none"}, and this package needs store version ${STORE_VERSION}.`, + `${deployment.name} answers store version ${store?.version ?? "none"}, and this package needs store version ${STORE_VERSION}.`, ); } @@ -459,7 +483,8 @@ export const makeConvexState = (options: StoreOptions) => * in CONVEX_ACCESS_TOKEN or the Convex CLI login. A team access token names * its team, so `team` is optional with one; the Convex CLI login needs the * slug or the id. The first use of the run finds or creates the project and - * pushes the store when it is missing or older. `alchemy plan` on another + * pushes the store app when the store is missing or an older seed. An older + * base fails the run with {@link OutdatedBase}. `alchemy plan` on another * stack never touches Convex. */ export const state = ( diff --git a/src/index.ts b/src/index.ts index 2a33d07..6b14c28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -38,7 +38,7 @@ export { runRecord, withRunRecord, } from "./RunRecord.ts"; -export { state, STORE_PROJECT_NAME } from "./State.ts"; +export { OutdatedBase, state, STORE_PROJECT_NAME } from "./State.ts"; export { CodePushFailed, ConvexAppNotFound, diff --git a/test/State.test.ts b/test/State.test.ts index 5d1795b..0a425e0 100644 --- a/test/State.test.ts +++ b/test/State.test.ts @@ -25,6 +25,7 @@ import { RUN_RECORD_KEY, type RunRecord } from "../src/RunRecord.ts"; import { makeConvexState, makeFunctionApi, + OutdatedBase, RUN_KEY_ACTIONS, state, STORE_PROJECT_NAME, @@ -264,6 +265,34 @@ describe("Convex.state", () => { expect(await run(runs())).toHaveLength(1); }); + it("runs on a base of its version, and never pushes over a base", async () => { + const fake = new FakeConvex().install(); + const deployment = + fake.project(fake.addProject({ name: STORE_PROJECT_NAME, teamId: TEAM })) + ?.prodDeploymentName ?? ""; + // The base app of the team serves the store functions next to its own functions. + fake.markDeployed(deployment); + const store = fake.addStore(deployment, STORE_VERSION, "base"); + const { service } = await run(open()); + await run(service.set({ ...stack, fqn: "Project", value: created("Project") })); + expect(await run(service.list(stack))).toEqual(["Project"]); + + // An older base fails the run with the versions and the dependency to upgrade. + store.version = STORE_VERSION - 1; + const outdated = await run(Effect.flip(open())); + expect(outdated).toBeInstanceOf(OutdatedBase); + expect(outdated.message).toBe( + `The base of team ${TEAM} (${deployment}) answers store version ${STORE_VERSION - 1}, and this package needs store version ${STORE_VERSION}. Convex.state does not push over a base. Upgrade the dependency @samebase/alchemy-convex of the base app to the version of this package, and push the base app.`, + ); + // A newer base fails the run as a newer seed does. + store.version = STORE_VERSION + 1; + expect(await failureOf(open())).toContain( + `answers store version ${STORE_VERSION + 1}, and this package needs store version ${STORE_VERSION}`, + ); + expect(await run(runs())).toEqual([]); + expect(store.host).toBe("base"); + }); + it("stores the stack output with the run record, and the stack's own value under the key", async () => { const fake = new FakeConvex().install(); const deployment = From 11992feb05d367abe0dbdffee2854dfbd85e6b5b Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 30 Oct 2026 04:53:59 +0200 Subject: [PATCH 3/5] Docs and release 0.14.0: store version 5, the exports, and the base rule The design note gets the section "The store's functions are exported, and a base is never pushed over (store version 5)" and the measurements on the development deployment gallant-skunk-112. README, AGENTS, and CHANGELOG describe the exports and the rule. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 12 +++-- CHANGELOG.md | 28 ++++++++++ README.md | 16 ++++-- docs/design/convex-state.md | 100 +++++++++++++++++++++++++++++++++++- package.json | 2 +- 5 files changed, 149 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fcd82fa..3aad9ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,14 @@ - This package manages only the Convex plane: projects, deployments, their environment variables, and the push of the functions inside `Convex.Deployment`. See `docs/design/convex-plane.md`. -- `Convex.state({ team })` keeps Alchemy state in the `samebase` project of the team, with the - functions of `src/store/convex/` and the `alchemyState` component of `src/store/component/`, - which the store pushes itself. Convex codegen writes `src/store/convex/_generated/` and - `src/store/component/_generated/`. The store and its readers (Samebase) are released and upgraded +- `Convex.state({ team })` keeps Alchemy state in the `samebase` project of the team: the + `alchemyState` component of `src/store/component/` and the functions of + `src/store/functions.ts` (exported as `@samebase/alchemy-convex/state/functions`). The store app + `src/store/convex/` serves them as a seed, which the provider pushes itself. The base app of a + team serves the same exports, and the provider never pushes over a base. Convex codegen writes + `src/store/convex/_generated/` and `src/store/component/_generated/`. The modules of + `src/store/` import with `.js` specifiers, because a base app typechecks them without + `allowImportingTsExtensions`. The store and its readers (Samebase) are released and upgraded together; a renamed function is a version bump on both sides. See `docs/design/convex-state.md`. - The Convex CLI owns typecheck, codegen, bundling, and the code push. `Convex.Deployment` only runs the installed `convex deploy`. Do not move work of the Convex CLI into this package. diff --git a/CHANGELOG.md b/CHANGELOG.md index e0c5ee3..6ec1c77 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## 0.14.0 (unreleased) + +The package exports the functions of the state store, so that the base app of a team serves the +store next to its own components. `Convex.state()` never pushes its store app over a base. Store +version 5. + +- Breaking: store version 5. `state:version` answers `{ version, host }` (was a number). `host` is + `"seed"` for the store app of this package and `"base"` for the base app of a team. The next + provider run of each team pushes store version 5 to its seed. The other functions, their + arguments, their results, and the rows do not change. +- The package exports the store functions as code, and its store app `src/store/convex` uses the + same exports: + - `@samebase/alchemy-convex/state/functions`: `stateFunctions(alchemyState, host)`, the twelve + internal `state:*` functions, and `readerFunctions(alchemyState)`, `reader:authorize` and + `reader:repository`. + - `@samebase/alchemy-convex/state/auth.config`: `authConfig(cloudUrl)`, the auth config that + verifies the read tokens. + - `@samebase/alchemy-convex/state/_generated/component.js`: the `ComponentApi` type, which the + Convex codegen of an app that mounts the component imports. + + A base app mounts the component and writes three files; see + [the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-store-version-5). + +- Breaking: `Convex.state()` never pushes its store app over a base, because the push replaces + every function of the deployment. A base that answers an older store version fails the run with + `OutdatedBase` (exported): upgrade `@samebase/alchemy-convex` in the base app and push the base + app. A newer store fails the run as before, from either host. + ## 0.13.0 (unreleased) The state project of a team is named `samebase`. diff --git a/README.md b/README.md index 988b645..d08e894 100644 --- a/README.md +++ b/README.md @@ -185,16 +185,26 @@ Every run needs the team token in `CONVEX_ACCESS_TOKEN`, or the Convex CLI login team access token names its team, so `Convex.state()` needs no `team` with one; a `team` slug that is set must be the slug of that team, checked against its projects, and a token of another team fails the run. The Convex CLI login needs the slug or the id. The first run of a team creates -the project `samebase` and pushes the store functions to its +the project `samebase` and pushes the store app of this package to its production deployment with the Convex CLI of the app; a later version of this package pushes a -newer store when it finds an older one. A store that is newer than the package fails the run with -both versions, so the repositories of a team upgrade the package together. Each run makes a +newer store app when it finds an older one. A store that is newer than the package fails the run +with both versions, so the repositories of a team upgrade the package together. Each run makes a deploy key of that deployment that can only run the store's internal functions, and deletes it at the end. Rows are in the tables `resources` and `outputs` of the component `alchemyState` in the dashboard, under the stack name. The store has no lock: run one deploy of a stage at a time, such as with a CI concurrency group. The store and its readers (Samebase) are released and upgraded together. +The base app of a team can serve the store in the `samebase` project, next to its own components. +It mounts the component from `@samebase/alchemy-convex/state/convex.config`. It exports the +store functions of `@samebase/alchemy-convex/state/functions`: +`stateFunctions(components.alchemyState, "base")` and `readerFunctions(components.alchemyState)`. +It exports its auth config from `@samebase/alchemy-convex/state/auth.config`. Then +`state:version` answers `host: "base"`, and `Convex.state()` never pushes over that app: a base +that is older than the package fails the run with `OutdatedBase`, and the base app upgrades its +`@samebase/alchemy-convex`. See +[the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-store-version-5). + Store version 3 supports native subscriptions. A trusted server checks repository access, then calls internal `reader:authorize({ repository, subject })` with a deployment credential. It gives the returned token to a separate `ConvexClient` connected to the state deployment. The client diff --git a/docs/design/convex-state.md b/docs/design/convex-state.md index 6b0c4f5..4994479 100644 --- a/docs/design/convex-state.md +++ b/docs/design/convex-state.md @@ -275,6 +275,88 @@ the team upgrades a consumer: the move comes before the first upgrade, and no de This version does not export the component from the package. A later version exports it, so that other apps can mount it. +## The store's functions are exported, and a base is never pushed over (store version 5) + +A base is the app of a team that holds the team-level components in the `samebase` project: the +hub, which mounts `@samebase/identity-broker`, and the `alchemyState` component of this package. +The store app of this package, `src/store/convex`, is a seed: the provider pushes it to a project +that has no base. A push replaces every function of a deployment. Thus a base serves the same +fourteen functions as the seed, with the same names and the same results, and the same auth +config. The provider never pushes its store app over a base, because that push erases the hub. + +The base app does not copy the functions. The package exports them as code, and the store app of +the package uses the same exports: + +- `@samebase/alchemy-convex/state/functions`: + - `stateFunctions(alchemyState, host)`: the twelve internal functions `version`, `stacks`, + `stages`, `list`, `get`, `replaced`, `set`, `remove`, `removeStack`, `getOutput`, `setOutput`, + and `readStack`. `alchemyState` is `components.alchemyState` of the app, of the type + `ComponentApi`. `host` is `"seed"` or `"base"`. Each function has the arguments, the result + validator, and the semantics of store version 4. Only `version` changes. + - `readerFunctions(alchemyState)`: the internal mutation `authorize` and the public query + `repository`. +- `@samebase/alchemy-convex/state/auth.config`: `authConfig(cloudUrl)`, the auth config that + verifies the read tokens of `reader:authorize`. +- `@samebase/alchemy-convex/state/_generated/component.js`: the `ComponentApi` type. The Convex + codegen of an app that mounts the component imports it for `components.alchemyState`. + +The exported modules import other modules with `.js` specifiers. A base app typechecks them with +its own Convex tsconfig, which does not set `allowImportingTsExtensions`. + +`state:version` answers `{ version, host }` (was a number). `Convex.state()` reads it and does this: + +| `state:version` answers | The run | +| --------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| No function, and the deployment has no code | Pushes the store app. | +| No function, and the deployment has code | Fails: another app has the name of the store. | +| `host: "seed"` and an older version, or a number (store version 4 or older) | Pushes the store app. | +| `host: "base"` and an older version | Fails with `OutdatedBase`, which names the dependency to upgrade. | +| The version of the package, from either host | Reads and writes. | +| A newer version, from either host | Fails with both versions, as before. | + +The run reads `state:version` again right before a push, because another run can push a newer +store, or the base app, in the meantime. The push has no lock (see Limits). + +The base app mounts the component without a name, so that its rows are the rows of the seed: +`app.use(alchemyState)` in `convex.config.ts`, with +`import alchemyState from "@samebase/alchemy-convex/state/convex.config"`. Then it writes three +files. Each file names every export, so that the Convex analysis finds each function. + +```ts +// convex/state.ts +export const { + version, + stacks, + stages, + list, + get, + replaced, + set, + remove, + removeStack, + getOutput, + setOutput, + readStack, +} = stateFunctions(components.alchemyState, "base"); +// convex/reader.ts +export const { authorize, repository } = readerFunctions(components.alchemyState); +// convex/auth.config.ts +export default authConfig(env.CONVEX_CLOUD_URL); +``` + +`stateFunctions` and `readerFunctions` come from `@samebase/alchemy-convex/state/functions`, +`authConfig` from `@samebase/alchemy-convex/state/auth.config`, `components` from +`./_generated/api`, and `env` from `./_generated/server`. A base app that has more auth providers +puts the providers of `authConfig(env.CONVEX_CLOUD_URL)` into its own list. + +The store version of a base is the store version of the `@samebase/alchemy-convex` that the base +app installs. The base app and the consumers of the team upgrade together, as the seed and its +consumers do: a consumer fails on a base that is older or newer than the consumer. + +The rows do not change. The first provider run of 0.14.0 pushes store version 5 to a seed, which +keeps its rows. A base app that replaces a seed mounts the same component name, so the base serves +the rows of the seed. + ## Measured Proof: the public starter `samebase/base-alchemy` on a local branch, with the stack named @@ -327,6 +409,21 @@ store was not touched. The probe rows were deleted at the end. | The rows of store version 3 | A row that store version 3 wrote stayed in the root table `resources`, and `state:stacks` answered `[]`. After the import of the root tables into the component (step 3 above), `state:stacks` and `state:readStack` answered the row again. The Convex import keeps `_id` and `_creationTime`. | | Not measured | The production store of a team, and the move of its rows. | +### Measured for store version 5 (0.14.0) + +Proof on 2026-10-30 in team `nicu` on the development deployment `gallant-skunk-112` of `samebase`, +with the Convex CLI login and `convex` 1.48.0. The production deployment of the store was not +touched. The probe rows were deleted at the end, and the deployment is a seed again. + +| Check | Result | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| The seed | `npx convex dev --once` in `src/store`: `state:version` answered `{ "host": "seed", "version": 5 }`, `state:stacks` `[]`. The committed `_generated` files did not change. `reader:repository` with an `alchemy:read` identity answered `{ stacks: [], store: "found" }`; without one it refused. | +| The reader token | A token of `reader:authorize` (audience `/alchemy-reader`, 300 s) read a probe stack through `ConvexHttpClient`, with the `__redacted__` value removed. Without a token, the read refused. | +| The base | A scratch app installed the packed tarball and `convex` 1.48.0, mounted the component, and wrote the three files with host `"base"`. `tsc` with the tsconfig of the Convex codegen passed, and `components.alchemyState` had the type `ComponentApi<"alchemyState">`. `npx convex dev --once --typecheck enable`: `state:version` answered `{ "host": "base", "version": 5 }`. The reader token read the probe stack as on the seed. | +| The rows across the hosts | A row that the seed wrote: the base read it, and the seed read it again after its next push. | +| The push of the provider | `convex deploy --typecheck disable --codegen disable` in `node_modules/@samebase/alchemy-convex/src/store` of the scratch app, with a key of `PUSH_KEY_ACTIONS` only and an empty `HOME`, passed; `state:version` answered the seed. | +| Not measured | The rule of `Convex.state()` on a real base: the provider uses the production deployment, and the unit tests cover the rule. The hub as a base. | + ## Limits - No lock: keep the CI concurrency group for each stage. No encryption beyond Convex at rest; a @@ -338,7 +435,8 @@ store was not touched. The probe rows were deleted at the end. - The push of the store has no lock. Two runs of two package versions that both find an older store can push in the order new, then old, in the seconds between the second read of the version and the end of the push; the newer run then fails with both versions, and its next run pushes its - store again. The upgrade happens once for each new package version in a team. + store again. The upgrade happens once for each new package version in a team. A base app that is + pushed in the same seconds is replaced by the store app until the next push of the base app. - A team access token (0.6.1): `GET /token_details` names its team, and the dashboard's team list answers it HTTP 403, so a slug is checked against the `teamSlug` of the team's projects instead; a team without a project needs the numeric id. Measured with a temporary team token on a diff --git a/package.json b/package.json index 4fc821f..ea2c68e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samebase/alchemy-convex", - "version": "0.13.0", + "version": "0.14.0", "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions", "keywords": [ "alchemy", From 11e1fbf19d20f825544530e775b3634eaf632beb Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 30 Oct 2026 05:09:25 +0200 Subject: [PATCH 4/5] Fold the review: the reader binds its issuer, the fake applies every push, the consequence for 0.13.0 reader:repository accepts only a token of the deployment's own issuer, the one that authorize signs, so a base that accepts other providers does not widen who can read a repository. The fake CLI push now installs the store app once for each push, a base too, as a real push does. The changelog says that a 0.13.0 run fails with "bad store version" after the first 0.14.0 run, and the design note says when a team pushes its base for the first time and how the base upgrades itself. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 4 +++- docs/design/convex-state.md | 8 ++++++-- src/store/functions.ts | 3 +++ test/Component.test.ts | 5 +++++ test/fakeConvex.ts | 24 +++++++++++++++--------- test/reader.test.ts | 11 ++++++++++- 6 files changed, 42 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ec1c77..f1b4d06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,9 @@ version 5. - Breaking: store version 5. `state:version` answers `{ version, host }` (was a number). `host` is `"seed"` for the store app of this package and `"base"` for the base app of a team. The next - provider run of each team pushes store version 5 to its seed. The other functions, their + provider run of each team pushes store version 5 to its seed. From then on, a run on 0.13.0 or + older fails with `bad store version` until that consumer moves to this version: every consumer + of a team moves at once. The other functions, their arguments, their results, and the rows do not change. - The package exports the store functions as code, and its store app `src/store/convex` uses the same exports: diff --git a/docs/design/convex-state.md b/docs/design/convex-state.md index 4994479..7193518 100644 --- a/docs/design/convex-state.md +++ b/docs/design/convex-state.md @@ -277,7 +277,7 @@ other apps can mount it. ## The store's functions are exported, and a base is never pushed over (store version 5) -A base is the app of a team that holds the team-level components in the `samebase` project: the +A base is the app of a team in the `samebase` project. It holds the team-level components: the hub, which mounts `@samebase/identity-broker`, and the `alchemyState` component of this package. The store app of this package, `src/store/convex`, is a seed: the provider pushes it to a project that has no base. A push replaces every function of a deployment. Thus a base serves the same @@ -436,7 +436,11 @@ touched. The probe rows were deleted at the end, and the deployment is a seed ag store can push in the order new, then old, in the seconds between the second read of the version and the end of the push; the newer run then fails with both versions, and its next run pushes its store again. The upgrade happens once for each new package version in a team. A base app that is - pushed in the same seconds is replaced by the store app until the next push of the base app. + pushed in the same seconds is replaced by the store app, and the hub with it, until the next push + of the base app. So a team pushes its base for the first time when no stack of the team runs; + after that the provider never pushes, because the store answers `base`. The base repository pins + one version of this package for its stack and its app, so one bump of that pin upgrades the store + functions and the provider together. - A team access token (0.6.1): `GET /token_details` names its team, and the dashboard's team list answers it HTTP 403, so a slug is checked against the `teamSlug` of the team's projects instead; a team without a project needs the numeric id. Measured with a temporary team token on a diff --git a/src/store/functions.ts b/src/store/functions.ts index 7a23c07..bf4a601 100644 --- a/src/store/functions.ts +++ b/src/store/functions.ts @@ -186,8 +186,11 @@ export const readerFunctions = (alchemyState: ComponentApi) => ({ returns: repositoryReply, handler: async (ctx) => { const identity = await ctx.auth.getUserIdentity(); + // Only a token of the deployment's own issuer reads, the one that `authorize` signs: a base + // can accept other providers too, and a token of one of those with the same claims does not. if ( identity === null || + identity.issuer !== env.CONVEX_CLOUD_URL || identity.scope !== "alchemy:read" || typeof identity.repository !== "string" || !repositoryName.test(identity.repository) diff --git a/test/Component.test.ts b/test/Component.test.ts index 63c5a33..253e9d9 100644 --- a/test/Component.test.ts +++ b/test/Component.test.ts @@ -14,6 +14,10 @@ import { api, internal } from "../src/store/convex/_generated/api.js"; import { FakeConvex } from "./fakeConvex.ts"; import { storeTest } from "./storeTest.ts"; +/** The deployment's own URL, the issuer of the reader's tokens; convex-test has none of its own. */ +const readerIssuer = "https://fake-deployment.convex.cloud"; +process.env["CONVEX_CLOUD_URL"] = readerIssuer; + type Consumer = Resource<"Test.UrlConsumer", { url: string }, { url: string; revision: number }>; const Consumer = Resource("Test.UrlConsumer"); const { test } = Test.make({ @@ -105,6 +109,7 @@ test.provider("changes component bindings without replacing resources or pushing }); const result = await t .withIdentity({ + issuer: readerIssuer, subject: "test-reader", scope: "alchemy:read", repository: "samebase/mail", diff --git a/test/fakeConvex.ts b/test/fakeConvex.ts index 933a8be..18102c1 100644 --- a/test/fakeConvex.ts +++ b/test/fakeConvex.ts @@ -154,6 +154,8 @@ export class FakeConvex { readonly deploymentKeys = new Map(); /** The store of each deployment that has one: POST /api/function answers its functions. */ readonly stores = new Map(); + /** The push events of the fake CLI that this fake applied already, one install for each. */ + private appliedPushes = new Set(); /** The store functions that ran, as " " lines. */ readonly functionCalls: string[] = []; /** Every deploy key secret ever created for each deployment, also the deleted ones. */ @@ -471,12 +473,13 @@ export class FakeConvex { if (key === undefined) return json(403, stateFixture("function_no_key.json")); const { path, args } = Schema.decodeUnknownSync(FunctionCall)(body); const name = path.replace(/^state:/, ""); - // A push of the fake CLI with a key of this deployment installs the - // store app, or replaces an older store with it and keeps the rows. - if (this.pushedByCli(deployment)) { + // Each push of the fake CLI with a key of this deployment installs the store app once: as a + // real push, it replaces whatever answered, a base too, and keeps the rows. + for (const push of this.newCliPushes(deployment)) { + this.appliedPushes.add(push); const pushed = this.stores.get(deployment); if (pushed === undefined) this.addStore(deployment, STORE_VERSION); - else if (pushed.version < STORE_VERSION) { + else { pushed.version = STORE_VERSION; pushed.host = "seed"; } @@ -594,15 +597,18 @@ export class FakeConvex { } /** True when the fake CLI log (fixtures/fake-convex-cli.ts) has a run with a key of `deployment`. */ - private pushedByCli(deployment: string) { + /** The push events of the fake CLI with a key of this deployment that are not applied yet. */ + private newCliPushes(deployment: string): string[] { const log = process.env["ALCHEMY_CONVEX_FAKE_CLI_LOG"]; - if (log === undefined || !existsSync(log)) return false; + if (log === undefined || !existsSync(log)) return []; const hashes = new Set(this.keysOf(this.createdSecrets, deployment).map(sha256)); return readFileSync(log, "utf8") .split("\n") - .filter((line) => line !== "") - .some((line) => - hashes.has(Schema.decodeUnknownSync(Schema.fromJsonString(CliRun))(line).keySha256), + .filter( + (line) => + line !== "" && + !this.appliedPushes.has(line) && + hashes.has(Schema.decodeUnknownSync(Schema.fromJsonString(CliRun))(line).keySha256), ); } diff --git a/test/reader.test.ts b/test/reader.test.ts index fc809e9..c17655b 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -14,7 +14,15 @@ const run: RunRecord = { source: "laptop", recordedAt: "2026-10-24T10:00:00.000Z", }; -const identity = { subject: "reader", scope: "alchemy:read", repository: run.repository }; +/** The deployment's own URL, the issuer of the reader's tokens; convex-test has none of its own. */ +const cloudUrl = "https://fake-deployment.convex.cloud"; +process.env["CONVEX_CLOUD_URL"] = cloudUrl; +const identity = { + issuer: cloudUrl, + subject: "reader", + scope: "alchemy:read", + repository: run.repository, +}; describe("repository reader", () => { it("denies anonymous, malformed, and wrong-scope identities", async () => { @@ -22,6 +30,7 @@ describe("repository reader", () => { await expect(t.query(api.reader.repository, {})).rejects.toThrow("read access"); for (const claims of [ { ...identity, scope: "other" }, + { ...identity, issuer: "https://other-provider.example" }, { ...identity, repository: "samebase" }, { ...identity, repository: 1 }, { subject: "reader", repository: run.repository }, From b9619235efb8455bb92b9a6b18c4374a844d20b2 Mon Sep 17 00:00:00 2001 From: Nicu Chiciuc Date: Fri, 30 Oct 2026 10:28:07 +0200 Subject: [PATCH 5/5] Keep store version 4: answer the host in a new function state:version keeps its number and state:host answers the app that serves the store, so 0.14.0 changes no function and a consumer on 0.13.0 keeps working. STORE_VERSION is now a wire contract: it rises only when a function that a released client calls changes or goes away, because each raise moves every consumer of a team at once. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- CHANGELOG.md | 26 ++++--- README.md | 4 +- docs/design/convex-state.md | 66 +++++++++-------- src/State.ts | 82 ++++++++++++--------- src/store/convex/state.ts | 1 + src/store/convex/version.ts | 16 ++-- src/store/functions.ts | 15 +++- test/State.test.ts | 12 ++- test/fakeConvex.ts | 27 ++++--- test/fixtures/state/README.md | 7 +- test/fixtures/state/function_version.json | 2 +- test/fixtures/state/function_version_1.json | 1 - test/store.test.ts | 12 +-- 14 files changed, 152 insertions(+), 123 deletions(-) delete mode 100644 test/fixtures/state/function_version_1.json diff --git a/AGENTS.md b/AGENTS.md index 3aad9ad..3b453bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,9 @@ team serves the same exports, and the provider never pushes over a base. Convex codegen writes `src/store/convex/_generated/` and `src/store/component/_generated/`. The modules of `src/store/` import with `.js` specifiers, because a base app typechecks them without - `allowImportingTsExtensions`. The store and its readers (Samebase) are released and upgraded + `allowImportingTsExtensions`. `STORE_VERSION` is a wire contract: raise it only when a function + that a released client calls changes or goes away, because each raise moves every consumer of a + team at once. The store and its readers (Samebase) are released and upgraded together; a renamed function is a version bump on both sides. See `docs/design/convex-state.md`. - The Convex CLI owns typecheck, codegen, bundling, and the code push. `Convex.Deployment` only runs the installed `convex deploy`. Do not move work of the Convex CLI into this package. diff --git a/CHANGELOG.md b/CHANGELOG.md index f1b4d06..8c26b42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,15 +3,15 @@ ## 0.14.0 (unreleased) The package exports the functions of the state store, so that the base app of a team serves the -store next to its own components. `Convex.state()` never pushes its store app over a base. Store -version 5. - -- Breaking: store version 5. `state:version` answers `{ version, host }` (was a number). `host` is - `"seed"` for the store app of this package and `"base"` for the base app of a team. The next - provider run of each team pushes store version 5 to its seed. From then on, a run on 0.13.0 or - older fails with `bad store version` until that consumer moves to this version: every consumer - of a team moves at once. The other functions, their - arguments, their results, and the rows do not change. +store next to its own components. `Convex.state()` never pushes its store app over a base. The +store version stays 4, so a consumer on 0.13.0 keeps working and moves when it wants to. + +- New function `state:host` answers `"seed"` for the store app of this package and `"base"` for the + base app of a team. A store without it is a seed. No other function, argument, result, or row + changes. +- `STORE_VERSION` rises only when a function that a released client calls changes or goes away. + Each consumer needs the exact number, so only such a change moves every consumer of a team at + once. - The package exports the store functions as code, and its store app `src/store/convex` uses the same exports: - `@samebase/alchemy-convex/state/functions`: `stateFunctions(alchemyState, host)`, the twelve @@ -23,12 +23,14 @@ version 5. Convex codegen of an app that mounts the component imports. A base app mounts the component and writes three files; see - [the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-store-version-5). + [the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-0140). -- Breaking: `Convex.state()` never pushes its store app over a base, because the push replaces - every function of the deployment. A base that answers an older store version fails the run with +- `Convex.state()` never pushes its store app over a base, because the push replaces every + function of the deployment. A base that answers an older store version fails the run with `OutdatedBase` (exported): upgrade `@samebase/alchemy-convex` in the base app and push the base app. A newer store fails the run as before, from either host. +- `reader:repository` accepts only a token of the deployment's own issuer, so a base that accepts + other auth providers does not widen who can read a repository. ## 0.13.0 (unreleased) diff --git a/README.md b/README.md index d08e894..a4a8945 100644 --- a/README.md +++ b/README.md @@ -200,10 +200,10 @@ It mounts the component from `@samebase/alchemy-convex/state/convex.config`. It store functions of `@samebase/alchemy-convex/state/functions`: `stateFunctions(components.alchemyState, "base")` and `readerFunctions(components.alchemyState)`. It exports its auth config from `@samebase/alchemy-convex/state/auth.config`. Then -`state:version` answers `host: "base"`, and `Convex.state()` never pushes over that app: a base +`state:host` answers `"base"`, and `Convex.state()` never pushes over that app: a base that is older than the package fails the run with `OutdatedBase`, and the base app upgrades its `@samebase/alchemy-convex`. See -[the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-store-version-5). +[the design note](docs/design/convex-state.md#the-stores-functions-are-exported-and-a-base-is-never-pushed-over-0140). Store version 3 supports native subscriptions. A trusted server checks repository access, then calls internal `reader:authorize({ repository, subject })` with a deployment credential. It gives diff --git a/docs/design/convex-state.md b/docs/design/convex-state.md index 7193518..f699087 100644 --- a/docs/design/convex-state.md +++ b/docs/design/convex-state.md @@ -275,24 +275,24 @@ the team upgrades a consumer: the move comes before the first upgrade, and no de This version does not export the component from the package. A later version exports it, so that other apps can mount it. -## The store's functions are exported, and a base is never pushed over (store version 5) +## The store's functions are exported, and a base is never pushed over (0.14.0) A base is the app of a team in the `samebase` project. It holds the team-level components: the hub, which mounts `@samebase/identity-broker`, and the `alchemyState` component of this package. The store app of this package, `src/store/convex`, is a seed: the provider pushes it to a project that has no base. A push replaces every function of a deployment. Thus a base serves the same -fourteen functions as the seed, with the same names and the same results, and the same auth +fifteen functions as the seed, with the same names and the same results, and the same auth config. The provider never pushes its store app over a base, because that push erases the hub. The base app does not copy the functions. The package exports them as code, and the store app of the package uses the same exports: - `@samebase/alchemy-convex/state/functions`: - - `stateFunctions(alchemyState, host)`: the twelve internal functions `version`, `stacks`, - `stages`, `list`, `get`, `replaced`, `set`, `remove`, `removeStack`, `getOutput`, `setOutput`, + - `stateFunctions(alchemyState, host)`: the thirteen internal functions `version`, `host`, + `stacks`, `stages`, `list`, `get`, `replaced`, `set`, `remove`, `removeStack`, `getOutput`, `setOutput`, and `readStack`. `alchemyState` is `components.alchemyState` of the app, of the type `ComponentApi`. `host` is `"seed"` or `"base"`. Each function has the arguments, the result - validator, and the semantics of store version 4. Only `version` changes. + validator, and the semantics of store version 4. `host` is new. - `readerFunctions(alchemyState)`: the internal mutation `authorize` and the public query `repository`. - `@samebase/alchemy-convex/state/auth.config`: `authConfig(cloudUrl)`, the auth config that @@ -303,19 +303,27 @@ the package uses the same exports: The exported modules import other modules with `.js` specifiers. A base app typechecks them with its own Convex tsconfig, which does not set `allowImportingTsExtensions`. -`state:version` answers `{ version, host }` (was a number). `Convex.state()` reads it and does this: +`state:version` answers a number, as before. `state:host` answers `"seed"` or `"base"`. A store +without `state:host` was pushed before 0.14.0, and it is a seed. `Convex.state()` reads +`state:version` on each run, and `state:host` only when the store is older. It does this: -| `state:version` answers | The run | -| --------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| No function, and the deployment has no code | Pushes the store app. | -| No function, and the deployment has code | Fails: another app has the name of the store. | -| `host: "seed"` and an older version, or a number (store version 4 or older) | Pushes the store app. | -| `host: "base"` and an older version | Fails with `OutdatedBase`, which names the dependency to upgrade. | -| The version of the package, from either host | Reads and writes. | -| A newer version, from either host | Fails with both versions, as before. | +| The store | The run | +| -------------------------------------------- | ----------------------------------------------------------------- | +| No function, and the deployment has no code | Pushes the store app. | +| No function, and the deployment has code | Fails: another app has the name of the store. | +| A seed of an older version | Pushes the store app. | +| A base of an older version | Fails with `OutdatedBase`, which names the dependency to upgrade. | +| The version of the package, from either host | Reads and writes. | +| A newer version, from either host | Fails with both versions, as before. | -The run reads `state:version` again right before a push, because another run can push a newer -store, or the base app, in the meantime. The push has no lock (see Limits). +The run reads the store again right before a push, because another run can push a newer store, or +the base app, in the meantime. The push has no lock (see Limits). + +`STORE_VERSION` is a wire contract, not a release number. Each consumer needs the exact number, so +a raise moves every consumer of a team at once. So it rises only when a function that a released +client calls changes its arguments, its result, or its meaning, or goes away. A new function does +not raise it. 0.14.0 adds `state:host` and changes no function, so the number stays 4: a consumer on +0.13.0 keeps working, also next to a base, and it moves when it wants to. The base app mounts the component without a name, so that its rows are the rows of the seed: `app.use(alchemyState)` in `convex.config.ts`, with @@ -326,6 +334,7 @@ files. Each file names every export, so that the Convex analysis finds each func // convex/state.ts export const { version, + host, stacks, stages, list, @@ -350,11 +359,10 @@ export default authConfig(env.CONVEX_CLOUD_URL); puts the providers of `authConfig(env.CONVEX_CLOUD_URL)` into its own list. The store version of a base is the store version of the `@samebase/alchemy-convex` that the base -app installs. The base app and the consumers of the team upgrade together, as the seed and its -consumers do: a consumer fails on a base that is older or newer than the consumer. +app installs. When the number rises, the base app moves first and each consumer follows. A consumer +of a team fails on a base that is older or newer than the consumer. -The rows do not change. The first provider run of 0.14.0 pushes store version 5 to a seed, which -keeps its rows. A base app that replaces a seed mounts the same component name, so the base serves +The rows do not change, and 0.14.0 pushes nothing to a store of version 4. A base app that replaces a seed mounts the same component name, so the base serves the rows of the seed. ## Measured @@ -409,20 +417,20 @@ store was not touched. The probe rows were deleted at the end. | The rows of store version 3 | A row that store version 3 wrote stayed in the root table `resources`, and `state:stacks` answered `[]`. After the import of the root tables into the component (step 3 above), `state:stacks` and `state:readStack` answered the row again. The Convex import keeps `_id` and `_creationTime`. | | Not measured | The production store of a team, and the move of its rows. | -### Measured for store version 5 (0.14.0) +### Measured for 0.14.0 Proof on 2026-10-30 in team `nicu` on the development deployment `gallant-skunk-112` of `samebase`, with the Convex CLI login and `convex` 1.48.0. The production deployment of the store was not touched. The probe rows were deleted at the end, and the deployment is a seed again. -| Check | Result | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| The seed | `npx convex dev --once` in `src/store`: `state:version` answered `{ "host": "seed", "version": 5 }`, `state:stacks` `[]`. The committed `_generated` files did not change. `reader:repository` with an `alchemy:read` identity answered `{ stacks: [], store: "found" }`; without one it refused. | -| The reader token | A token of `reader:authorize` (audience `/alchemy-reader`, 300 s) read a probe stack through `ConvexHttpClient`, with the `__redacted__` value removed. Without a token, the read refused. | -| The base | A scratch app installed the packed tarball and `convex` 1.48.0, mounted the component, and wrote the three files with host `"base"`. `tsc` with the tsconfig of the Convex codegen passed, and `components.alchemyState` had the type `ComponentApi<"alchemyState">`. `npx convex dev --once --typecheck enable`: `state:version` answered `{ "host": "base", "version": 5 }`. The reader token read the probe stack as on the seed. | -| The rows across the hosts | A row that the seed wrote: the base read it, and the seed read it again after its next push. | -| The push of the provider | `convex deploy --typecheck disable --codegen disable` in `node_modules/@samebase/alchemy-convex/src/store` of the scratch app, with a key of `PUSH_KEY_ACTIONS` only and an empty `HOME`, passed; `state:version` answered the seed. | -| Not measured | The rule of `Convex.state()` on a real base: the provider uses the production deployment, and the unit tests cover the rule. The hub as a base. | +| Check | Result | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| The seed | `npx convex dev --once` in `src/store`: `state:version` answered `4`, `state:host` `"seed"`, `state:stacks` `[]`. The committed `_generated` files did not change. | +| The reader token | A token of `reader:authorize` read a probe stack through `ConvexHttpClient` with the issuer check in place, on the seed and on the base. The `__redacted__` value was not in the reply. A read without a token and a read with a changed token were refused. | +| The base | A scratch app installed the packed tarball and `convex` 1.48.0, mounted the component, and wrote the three files with host `"base"`. `tsc` with the tsconfig of the Convex codegen passed. `state:version` answered `4`, `state:host` `"base"`. | +| The rows across the hosts | A row that the seed wrote: the base read it, and the seed read it again after its next push. | +| The push of the provider | `convex deploy --typecheck disable --codegen disable` in `node_modules/@samebase/alchemy-convex/src/store` of the scratch app, with a key of `PUSH_KEY_ACTIONS` only and an empty `HOME`, passed. | +| Not measured | The rule of `Convex.state()` on a real base: the provider uses the production deployment, and the unit tests cover the rule. The hub as a base. | ## Limits diff --git a/src/State.ts b/src/State.ts index 109c477..45262ba 100644 --- a/src/State.ts +++ b/src/State.ts @@ -27,8 +27,8 @@ // anything, and uses the oldest: both runs end on one store. // // Samebase reads `stacks`, `getOutput`, `readStack`, and `reader:repository` -// by name. The store and its readers are released and upgraded together: -// STORE_VERSION rises with each change of a function, and a client works only +// by name. STORE_VERSION is the wire contract: it rises only when a function +// that a released client calls changes or goes away, and a client works only // with a store of its own version. An older seed is pushed over; an older base // and a newer store fail the run. // @@ -232,31 +232,35 @@ const remoteOf = (api: FunctionApi, record: RunRecord | undefined) => { }; }; +/** The version that the store answers, or undefined when the deployment has no `state:version`. */ +const storeVersion = (api: FunctionApi) => + api + .call("state:version", {}) + .pipe( + Effect.flatMap((reply) => + reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) + ? Effect.succeed(undefined) + : valueOf(reply).pipe(Effect.flatMap(decode(Schema.Number, "store version"))), + ), + ); + /** - * The reply of `state:version` since store version 5: the store version and - * the app that serves it, `seed` (the store app of this package) or `base` - * (the base app of the team). A store of version 4 or older answers a bare - * number, and only the store app of this package served those versions. + * The app that serves the store. A store without `state:host` is a seed: it + * was pushed before 0.14.0, and only the store app of this package served + * those stores. */ -const StoreVersion = Schema.Union([ - Schema.Struct({ version: Schema.Number, host: Schema.Literals(["seed", "base"]) }), - Schema.Number, -]); - -/** The version and host that the store answers, or undefined when the deployment has no `state:version`. */ -const storeVersion = (api: FunctionApi) => - api.call("state:version", {}).pipe( - Effect.flatMap((reply) => - reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) - ? Effect.succeed(undefined) - : valueOf(reply).pipe( - Effect.flatMap(decode(StoreVersion, "store version")), - Effect.map((store) => - typeof store === "number" ? { version: store, host: "seed" as const } : store, +const storeHost = (api: FunctionApi) => + api + .call("state:host", {}) + .pipe( + Effect.flatMap((reply) => + reply.status === "error" && MISSING_FUNCTION.test(reply.errorMessage) + ? Effect.succeed("seed" as const) + : valueOf(reply).pipe( + Effect.flatMap(decode(Schema.Literals(["seed", "base"]), "store host")), ), - ), - ), - ); + ), + ); /** * The base app of the team serves an older store. Convex.state() never @@ -413,10 +417,16 @@ export const makeConvexState = (options: StoreOptions) => // The store app is pushed only over no store or an older seed. A base // serves the same functions, and a push would erase the base app. - let store = yield* storeVersion(functions); - if (store === undefined || (store.host === "seed" && store.version < STORE_VERSION)) { + const needsPush = (found: number | undefined) => + found === undefined + ? Effect.succeed(true) + : found < STORE_VERSION + ? storeHost(functions).pipe(Effect.map((host) => host === "seed")) + : Effect.succeed(false); + let version = yield* storeVersion(functions); + if (yield* needsPush(version)) { // Code without `state:version` is another app with the name of the store. - if (store === undefined && deployment.lastDeployTime) { + if (version === undefined && 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.`, ); @@ -424,18 +434,22 @@ export const makeConvexState = (options: StoreOptions) => // Another run can push a newer store, or the base app, in the meantime: // read again right before the push. The push itself has no lock (see // the design note). - store = yield* storeVersion(functions); - if (store === undefined || (store.host === "seed" && store.version < STORE_VERSION)) { + version = yield* storeVersion(functions); + if (yield* needsPush(version)) { yield* pushStore(options, deployment); - store = yield* storeVersion(functions); + version = yield* storeVersion(functions); } } - if (store?.host === "base" && store.version < STORE_VERSION) { - return yield* new OutdatedBase({ team, deployment: deployment.name, version: store.version }); + if ( + version !== undefined && + version < STORE_VERSION && + (yield* storeHost(functions)) === "base" + ) { + return yield* new OutdatedBase({ team, deployment: deployment.name, version }); } - if (store?.version !== STORE_VERSION) { + if (version !== STORE_VERSION) { return yield* failure( - `${deployment.name} answers store version ${store?.version ?? "none"}, and this package needs store version ${STORE_VERSION}.`, + `${deployment.name} answers store version ${version ?? "none"}, and this package needs store version ${STORE_VERSION}.`, ); } diff --git a/src/store/convex/state.ts b/src/store/convex/state.ts index d607224..2afa4bb 100644 --- a/src/store/convex/state.ts +++ b/src/store/convex/state.ts @@ -8,6 +8,7 @@ import { components } from "./_generated/api.js"; export const { version, + host, stacks, stages, list, diff --git a/src/store/convex/version.ts b/src/store/convex/version.ts index 0577681..a94ad35 100644 --- a/src/store/convex/version.ts +++ b/src/store/convex/version.ts @@ -1,15 +1,17 @@ -// The wire version of the store: the `state:version` query answers it, and +// The wire contract of the store: the `state:version` query answers it, and // Convex.state() pushes the store app when a seed answers an older one or the // deployment has no `version` yet. It fails the run when a base answers an -// older one, and on a newer one. Raise it with each change of a function of -// src/store/functions.ts or of the alchemyState component -// (src/store/component). The store and its readers (Samebase) are released -// and upgraded together: a renamed function is a version bump on both sides. +// older one, and on a newer one. +// +// Each consumer of a team needs this exact number, so a raise moves every +// consumer of the team at once. Raise it only when a function that a released +// client calls changes its arguments, its result, or its meaning, or goes +// away. A new function does not raise it. The store and its readers (Samebase) +// move together on a raise: a renamed function is a raise on both sides. // // 1: `version`, `stacks`, `stages`, `list`, `get`, `replaced`, `getOutput`, // `set`, `remove`, `removeStack`, `setOutput` (0.6.0). // 2: `readStack` (0.7.0). // 3: authenticated subscriptions and current stage run records (0.9.0). // 4: the store is a component (same functions). -// 5: `version` answers `{ version, host }`; the functions come from `@samebase/alchemy-convex/state/functions`. -export const STORE_VERSION = 5; +export const STORE_VERSION = 4; diff --git a/src/store/functions.ts b/src/store/functions.ts index bf4a601..b800671 100644 --- a/src/store/functions.ts +++ b/src/store/functions.ts @@ -30,17 +30,24 @@ const storeHost = v.union(v.literal("seed"), v.literal("base")); /** * The internal `state:*` functions over the mounted component `alchemyState`. - * `state:version` answers `host`, the app that serves them. Export each one + * `state:host` answers `host`, the app that serves them. Export each one * by name from the app's `convex/state.ts`. */ export const stateFunctions = (alchemyState: ComponentApi, host: Infer) => { const state = alchemyState.state; return { - /** The wire version of this store, compared with the client's STORE_VERSION, and the app that serves it. */ + /** The wire version of this store, compared with the client's STORE_VERSION. */ version: internalQueryGeneric({ args: {}, - returns: v.object({ version: v.number(), host: storeHost }), - handler: async () => ({ version: STORE_VERSION, host }), + returns: v.number(), + handler: async () => STORE_VERSION, + }), + + /** The app that serves this store. Convex.state() reads it before it pushes over an older store. */ + host: internalQueryGeneric({ + args: {}, + returns: storeHost, + handler: async () => host, }), /** Stacks with at least one resource or one output, sorted. */ diff --git a/test/State.test.ts b/test/State.test.ts index 0a425e0..a3d15b1 100644 --- a/test/State.test.ts +++ b/test/State.test.ts @@ -248,12 +248,16 @@ describe("Convex.state", () => { const deployment = fake.project(fake.addProject({ name: STORE_PROJECT_NAME, teamId: TEAM })) ?.prodDeploymentName ?? ""; - // The deployment answers version 0, a bare number as a seed of store - // version 4 or older does, until the fake CLI "pushed", then the current version. + // The deployment answers version 0 and has no `state:host`, as a seed from before 0.14.0, + // until the fake CLI "pushed", then the current version. const store = fake.addStore(deployment, STORE_VERSION); + const older = { status: 200, body: { ...stateFixture("function_version.json"), value: 0 } }; + const noHost = { status: 200, body: stateFixture("function_version_missing.json") }; fake.scripted.set(`POST ${deployment}.convex.cloud/api/function`, [ - { status: 200, body: { ...stateFixture("function_version_1.json"), value: 0 } }, - { status: 200, body: { ...stateFixture("function_version_1.json"), value: 0 } }, + older, + noHost, + older, + noHost, ]); await run(open()); expect(await run(runs())).toHaveLength(1); diff --git a/test/fakeConvex.ts b/test/fakeConvex.ts index 18102c1..533b80d 100644 --- a/test/fakeConvex.ts +++ b/test/fakeConvex.ts @@ -58,8 +58,11 @@ export interface StoreRow { /** The store on one deployment: its version, the app that serves it, its rows, and outputs. */ export interface Store { version: number; - /** `seed`: the store app of src/store/convex. `base`: the base app of the team. */ - host: "seed" | "base"; + /** + * `seed`: the store app of src/store/convex. `base`: the base app of the team. Undefined for a + * store without `state:host`, from before 0.14.0. + */ + host: "seed" | "base" | undefined; rows: StoreRow[]; readonly outputs: Map; } @@ -67,6 +70,7 @@ export interface Store { /** The internal functions of the store that a query key runs, and those that need a mutation key. */ const STORE_QUERIES = [ "version", + "host", "stacks", "stages", "list", @@ -78,8 +82,6 @@ const STORE_QUERIES = [ const STORE_MUTATIONS = ["set", "remove", "removeStack", "setOutput"]; /** The store version that added a function; every other function is in version 1 (0.6.0). */ const STORE_FUNCTION_SINCE: Record = { readStack: 2 }; -/** The store version whose `version` answers `{ version, host }`; an older seed answers a bare number. */ -const VERSION_HOST_SINCE = 5; const FunctionCall = Schema.Struct({ path: Schema.String, args: Schema.Struct({ @@ -207,7 +209,7 @@ export class FakeConvex { } /** Gives a deployment the store at `version` that `host` serves, with no rows, and returns it. */ - addStore(deployment: string, version: number, host: Store["host"] = "seed") { + addStore(deployment: string, version: number, host?: Store["host"]) { const store: Store = { version, host, rows: [], outputs: new Map() }; this.stores.set(deployment, store); return store; @@ -478,7 +480,7 @@ export class FakeConvex { for (const push of this.newCliPushes(deployment)) { this.appliedPushes.add(push); const pushed = this.stores.get(deployment); - if (pushed === undefined) this.addStore(deployment, STORE_VERSION); + if (pushed === undefined) this.addStore(deployment, STORE_VERSION, "seed"); else { pushed.version = STORE_VERSION; pushed.host = "seed"; @@ -492,7 +494,8 @@ export class FakeConvex { if ( store === undefined || !(STORE_QUERIES.includes(name) || STORE_MUTATIONS.includes(name)) || - store.version < (STORE_FUNCTION_SINCE[name] ?? 1) + store.version < (STORE_FUNCTION_SINCE[name] ?? 1) || + (name === "host" && store.host === undefined) ) { return error( `[Request ID: fake] Server Error\nCould not find function for '${path}'. Did you forget to run \`npx convex dev\`?\n`, @@ -512,14 +515,10 @@ export class FakeConvex { const index = store.rows.findIndex((row) => inStage(row) && row.fqn === fqn); const output = `${stack}/${stage}`; switch (name) { - // A base answers `{ version, host }` at each version: a test of an - // older base stands for a base that is older than the package. case "version": - return reply( - store.host === "seed" && store.version < VERSION_HOST_SINCE - ? store.version - : { version: store.version, host: store.host }, - ); + return reply(store.version); + case "host": + return reply(store.host); case "stacks": return reply([...new Set(store.rows.map((row) => row.stack))].sort()); case "stages": diff --git a/test/fixtures/state/README.md b/test/fixtures/state/README.md index 9b015db..402f7d5 100644 --- a/test/fixtures/state/README.md +++ b/test/fixtures/state/README.md @@ -7,7 +7,7 @@ Ids are real and harmless. No file contains a key. | File | Request | | --- | --- | -| function_version_1.json | POST /api/function `state:version` of store version 1 with a key that has `deployment:functions:runInternalQueries`: a bare number, as a store of version 4 or older answers | +| function_version.json | POST /api/function `state:version` with a key that has `deployment:functions:runInternalQueries` | | function_version_missing.json | POST /api/function for a function that the deployment does not have | | function_no_key.json | POST /api/function without a key (403) | | function_not_permitted.json | POST /api/function `state:set` with a key that has only `deployment:functions:runInternalQueries` | @@ -17,11 +17,6 @@ Ids are real and harmless. No file contains a key. | function_stacks.json | POST /api/function `state:stacks` | | public_mutation_no_key.json | POST /api/mutation `state:set` without a key, while the store still had public functions (200): the reason every function is internal | -`function_version.json` was captured on 2026-10-30 against the development deployment -`gallant-skunk-112` of the project `samebase` in team `nicu`, after a push of `src/store/convex` -(store version 5) with `npx convex dev --once`: POST /api/function `state:version` with a key that -has `deployment:functions:runInternalQueries` only. The key was deleted after the call. - Facts learned: `deployment:data:view` and `deployment:data:write` do not run an internal function (the reply names `deployment:functions:runInternalQueries` or `runInternalMutations`). An internal function through `/api/query` without a key answers a bare "Server Error". A deploy key must expire diff --git a/test/fixtures/state/function_version.json b/test/fixtures/state/function_version.json index 9f11afd..0ad44ad 100644 --- a/test/fixtures/state/function_version.json +++ b/test/fixtures/state/function_version.json @@ -1 +1 @@ -{"status":"success","value":{"host":"seed","version":5.0}} \ No newline at end of file +{"status":"success","value":1.0} diff --git a/test/fixtures/state/function_version_1.json b/test/fixtures/state/function_version_1.json deleted file mode 100644 index 0ad44ad..0000000 --- a/test/fixtures/state/function_version_1.json +++ /dev/null @@ -1 +0,0 @@ -{"status":"success","value":1.0} diff --git a/test/store.test.ts b/test/store.test.ts index da014b6..88fa38e 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -11,10 +11,8 @@ const preview = { stack: "samebase/app", stage: "pr-1" }; describe("the state store", () => { it("answers its wire version and its host", async () => { const t = storeTest(); - expect(await t.query(internal.state.version, {})).toEqual({ - version: STORE_VERSION, - host: "seed", - }); + expect(await t.query(internal.state.version, {})).toBe(STORE_VERSION); + expect(await t.query(internal.state.host, {})).toBe("seed"); }); it("serves the same functions from a base app, which answers host base", async () => { @@ -22,10 +20,8 @@ describe("the state store", () => { const t = storeTest({ "../src/store/convex/state.ts": async () => stateFunctions(components.alchemyState, "base"), }); - expect(await t.query(internal.state.version, {})).toEqual({ - version: STORE_VERSION, - host: "base", - }); + expect(await t.query(internal.state.version, {})).toBe(STORE_VERSION); + expect(await t.query(internal.state.host, {})).toBe("base"); await t.mutation(internal.state.set, { ...main, fqn: "Project", value: "{}" }); expect(await t.query(internal.state.stacks, {})).toEqual(["samebase/app"]); expect(await t.query(internal.state.readStack, { stack: "samebase/app" })).toEqual({