diff --git a/AGENTS.md b/AGENTS.md index 3b453bb..faa07b2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,16 +7,10 @@ - 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: 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`. `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 +- `Convex.state({ team })` keeps Alchemy state in the `alchemy-state-store` 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 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 e772677..b804cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## 0.16.0 (unreleased) + +The state store has its own project again, `alchemy-state-store`, the name of the Cloudflare state +store of Alchemy. The store and the identity hub have opposite owners: the package pushes the store, +and a team forks and edits the hub. In one deployment, a broken hub deploy stops every stack of the +team, and each store change has to reach the hub first. + +- `Convex.state()` finds or creates `alchemy-state-store`. Copy the rows of the old project first, or + the first run of each stack starts with no state. Move every repository of a team together. +- `Convex.HubClient` finds the hub in the project `samebase`, which it now names as `HUB_PROJECT_NAME`. +- The store app of this package is the only app that serves the store, so `state:host`, `OutdatedBase`, + and the exports `./state/*` go. The store version stays 4. + ## 0.15.0 (unreleased) `Convex.HubClient` registers a deployment as a client of the identity hub of the team, with the diff --git a/README.md b/README.md index 0a1bf98..3809ad6 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,8 @@ values explicitly: `Convex.providers(Convex.fromValues({ accessToken, deployKey ## State in Convex `state: Convex.state({ team })` keeps the Alchemy state of every stack and stage of a Convex team in -one project of that team, `samebase`, so a repository holds one secret and a stack can create +one project of that team, `alchemy-state-store`, the same name as the Cloudflare state store of +Alchemy, so a repository holds one secret and a stack can create and destroy its own project. [docs/design/convex-state.md](docs/design/convex-state.md) records the decisions and the measurements. @@ -185,26 +186,16 @@ 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 app of this package to its +the project `alchemy-state-store` and pushes the store functions to its production deployment with the Convex CLI of the app; a later version of this package pushes 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 +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 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: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-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 the returned token to a separate `ConvexClient` connected to the state deployment. The client @@ -284,8 +275,8 @@ call. has every action, because the command can do more than a push. - `Convex.HubClient`: `{ team, app, redirectUri, deploymentType, clientName, softwareVersion? }` - registers one deployment as a client of the identity hub of the team. The hub is in the base of the - team, the production deployment of the project `samebase`. The resource finds it by name and runs + registers one deployment as a client of the identity hub of the team. The hub is the production + deployment of the project `samebase`. The resource finds it by name and runs its internal function `identityBroker:registerClient` with a short-lived key, so the stack needs no other credential and no hub address. The first client of an `app` creates the app. Outputs `issuer`, `clientId`, `clientSecret`, `redirectUri`, `deploymentType`, and `clientName`, which the diff --git a/docs/design/convex-state.md b/docs/design/convex-state.md index f699087..2c46736 100644 --- a/docs/design/convex-state.md +++ b/docs/design/convex-state.md @@ -188,7 +188,23 @@ an issued token remains valid until the backend rejects its expiry, including it reads at expiry plus six seconds, and the open subscription stopped at expiry plus one second. The trusted server must not treat its deployment credential as a browser credential. -## The project is named samebase (0.13.0) +## The store has its own project (0.16.0) + +The project is named `alchemy-state-store`, the exact name of the Cloudflare state store of Alchemy +(`DEFAULT_STATE_STORE_NAME`). From 0.14.0 to 0.15.0 the store shared a deployment with the identity +hub, so that a team would have one project. That was reversed for three reasons, all seen in use. + +- The two have opposite owners. The package pushes the store, and nobody edits it. A team forks and + edits the hub. Sharing needed a rule for which app serves the store, and an order for every change. +- They fail together. A push replaces every function of a deployment, so a broken hub deploy would + stop the state reads of every stack of the team. +- The one-credential benefit does not need one project: the team token reaches every project of + the team, and `Convex.HubClient` finds the hub by the name of its project. + +The hub stays in the project `samebase`. Renaming it while an older version of this package can run +is unsafe: that version finds no project of that name and creates an empty store. + +## The project was named samebase (0.13.0 to 0.15.0) The project that `Convex.state({ team })` finds or creates is named `samebase` (was `alchemy-state`). Reason: the project becomes the team's base, the deployment that holds the state @@ -275,96 +291,6 @@ 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 (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 -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 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. `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 - 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 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: - -| 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 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 -`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, - host, - 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. 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, 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 Proof: the public starter `samebase/base-alchemy` on a local branch, with the stack named @@ -417,21 +343,6 @@ 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 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 `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 - No lock: keep the CI concurrency group for each stage. No encryption beyond Convex at rest; a @@ -443,12 +354,7 @@ touched. The probe rows were deleted at the end, and the deployment is a seed ag - 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. A base app that is - 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. + store again. The upgrade happens once for each new package version in a team. - 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 d90ee7e..e8045b5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@samebase/alchemy-convex", - "version": "0.15.0", + "version": "0.16.0", "description": "The Convex plane for Alchemy v2: projects, deployments, environment variables, and the push of the functions", "keywords": [ "alchemy", @@ -34,22 +34,6 @@ ".": { "types": "./lib/index.d.ts", "import": "./lib/index.js" - }, - "./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/HubClient.ts b/src/HubClient.ts index 4f49d82..10effad 100644 --- a/src/HubClient.ts +++ b/src/HubClient.ts @@ -1,10 +1,9 @@ // Convex.HubClient: one deployment of an app as a client of the identity hub // of its team. // -// The hub is an app in the base of the team, the production deployment of the -// project `samebase`, the project that holds the state of the team. It has -// the internal functions of `@samebase/identity-broker` in its module -// `identityBroker`. This resource finds the base by name and runs those +// The hub is the production deployment of the project `samebase` of the team. +// It has the internal functions of `@samebase/identity-broker` in its module +// `identityBroker`. This resource finds the project by name and runs those // functions with a short-lived key, so a stack that has the Convex credential // of the team needs no other credential and no hub address. The hub answers // with its issuer and the client. @@ -13,7 +12,7 @@ // of it. A client belongs to one redirect URI: another URI is a new client. A // change of `app` updates the stored props and does not move the client: the // app is the identity of the users of the app, and it does not change. The -// team does not change either: the state of a stack is in the base of its team. +// team does not change either: the state of a stack is in its team. import { Resource } from "alchemy"; import { isResolved } from "alchemy/Diff"; import * as Provider from "alchemy/Provider"; @@ -24,7 +23,10 @@ import { ManagementApi, type ManagementApiService } from "./ManagementApi.ts"; import { findProjectByName, prodDeploymentOf } from "./Project.ts"; import type { Providers } from "./Providers.ts"; import { withPushKey } from "./Push.ts"; -import { type FunctionApi, makeFunctionApi, RUN_KEY_ACTIONS, STORE_PROJECT_NAME } from "./State.ts"; +import { type FunctionApi, makeFunctionApi, RUN_KEY_ACTIONS } from "./State.ts"; + +/** The name of the project of the hub in each team. */ +export const HUB_PROJECT_NAME = "samebase"; export type HubDeploymentType = "production" | "development" | "preview"; @@ -63,13 +65,13 @@ export type HubClient = Resource< >; export const HubClient = Resource("IdentityHub.Client"); -/** The base of the team could not do the call. The reason is the Convex message: it holds no secret. */ +/** The hub of the team could not do the call. The reason is the Convex message: it holds no secret. */ export class HubClientFailed extends Schema.TaggedError()("HubClientFailed", { team: Schema.Union([Schema.String, Schema.Number]), reason: Schema.String, }) { override get message() { - return `Convex.HubClient: the base of team ${this.team}: ${this.reason}.`; + return `Convex.HubClient: the hub of team ${this.team}: ${this.reason}.`; } } @@ -80,26 +82,26 @@ const Registered = Schema.Struct({ clientSecret: Schema.NonEmptyString, }); -/** Runs `use` with the function API of the base of `team`, with a key of this call only. */ -const withBase = ( +/** Runs `use` with the function API of the hub of `team`, with a key of this call only. */ +const withHub = ( api: ManagementApiService, team: string | number, use: (functions: FunctionApi) => Effect.Effect, ) => Effect.gen(function* () { const teamId = yield* api.resolveTeamId(team); - const project = yield* findProjectByName(api, teamId, STORE_PROJECT_NAME); + const project = yield* findProjectByName(api, teamId, HUB_PROJECT_NAME); if (project === undefined) { return yield* new HubClientFailed({ team, - reason: `no project ${STORE_PROJECT_NAME}, which holds the hub`, + reason: `no project ${HUB_PROJECT_NAME}, which holds the hub`, }); } const deployment = yield* prodDeploymentOf(api, project.id); if (deployment === undefined) { return yield* new HubClientFailed({ team, - reason: `project ${STORE_PROJECT_NAME} has no production deployment`, + reason: `project ${HUB_PROJECT_NAME} has no production deployment`, }); } return yield* withPushKey(api, deployment.name, RUN_KEY_ACTIONS, (key) => @@ -128,7 +130,7 @@ const runHub = ( * client on the hub, which signs nobody in. */ export const registerHubClient = (api: ManagementApiService, props: HubClientProps) => - withBase(api, props.team, (functions) => + withHub(api, props.team, (functions) => runHub(functions, props.team, "registerClient", { appId: props.app, redirectUri: props.redirectUri, @@ -154,7 +156,7 @@ export const registerHubClient = (api: ManagementApiService, props: HubClientPro ); const unregister = (api: ManagementApiService, team: string | number, clientId: string) => - withBase(api, team, (functions) => runHub(functions, team, "deleteClient", { clientId })); + withHub(api, team, (functions) => runHub(functions, team, "deleteClient", { clientId })); /** True when the client in state serves the redirect URI and deployment type of `props`. */ const registeredFor = (output: HubClientAttributes, props: HubClientProps) => diff --git a/src/State.ts b/src/State.ts index 45262ba..49790b6 100644 --- a/src/State.ts +++ b/src/State.ts @@ -1,36 +1,33 @@ // Alchemy state in Convex: `state: Convex.state({ team })` in `Alchemy.Stack`. // -// One project for each Convex team, `samebase`, holds the state of +// One project for each Convex team, `alchemy-state-store`, holds the state of // 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/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. +// 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. // // 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 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 +// 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 // 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. // -// Two first runs of a team can both create a project named `samebase`. +// Two first runs of a team can both create a project named `alchemy-state-store`. // The run whose project is not the oldest deletes its own, before it writes // anything, and uses the oldest: both runs end on one store. // // Samebase reads `stacks`, `getOutput`, `readStack`, and `reader:repository` -// 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. +// 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. // // 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 @@ -83,7 +80,7 @@ import { type RunRecord, runRecord, withRunRecord } from "./RunRecord.ts"; import { STORE_VERSION } from "./store/convex/version.ts"; /** The name of the state project in each team. */ -export const STORE_PROJECT_NAME = "samebase"; +export const STORE_PROJECT_NAME = "alchemy-state-store"; /** * The actions of the key of one run: the internal queries and mutations of @@ -244,39 +241,6 @@ const storeVersion = (api: FunctionApi) => ), ); -/** - * 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 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 - * 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/` @@ -288,7 +252,7 @@ export const storeDirectory = () => join(dirname(fileURLToPath(import.meta.url)), "..", "src", "store", "convex"); export interface StoreOptions { - /** Team slug or numeric team id of the `samebase` project, or undefined with a team access token, which names its team. */ + /** Team slug or numeric team id of the `alchemy-state-store` project, or undefined with a team access token, which names its team. */ readonly team: string | number | undefined; readonly api: ManagementApiService; readonly spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; @@ -365,9 +329,9 @@ const createStoreProject = (api: ManagementApiService, teamId: number, team: str }); /** - * The StateService over the `samebase` project of the team: found or - * created, its store app pushed when the store is missing or an older seed, - * and read and written with the keys of this run. + * The StateService over the `alchemy-state-store` 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. */ export const makeConvexState = (options: StoreOptions) => Effect.gen(function* () { @@ -415,38 +379,22 @@ 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. - 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)) { + if (version === undefined || version < STORE_VERSION) { // Code without `state:version` is another app with the name of the store. 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.`, ); } - // 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). + // 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); - if (yield* needsPush(version)) { + if (version === undefined || version < STORE_VERSION) { yield* pushStore(options, deployment); version = yield* storeVersion(functions); } } - if ( - version !== undefined && - version < STORE_VERSION && - (yield* storeHost(functions)) === "base" - ) { - return yield* new OutdatedBase({ team, deployment: deployment.name, version }); - } if (version !== STORE_VERSION) { return yield* failure( `${deployment.name} answers store version ${version ?? "none"}, and this package needs store version ${STORE_VERSION}.`, @@ -493,12 +441,11 @@ export const makeConvexState = (options: StoreOptions) => }); /** - * Alchemy state in the `samebase` project of `team`, with the team token + * Alchemy state in the `alchemy-state-store` project of `team`, with the team token * 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 app when the store is missing or an older seed. An older - * base fails the run with {@link OutdatedBase}. `alchemy plan` on another + * pushes the store when it is missing or older. `alchemy plan` on another * stack never touches Convex. */ export const state = ( diff --git a/src/index.ts b/src/index.ts index 08a8290..7939012 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,7 @@ export { ProductionDeploymentNotFound, } from "./Deployment.ts"; export { + HUB_PROJECT_NAME, HubClient, type HubClientAttributes, HubClientFailed, @@ -45,7 +46,7 @@ export { runRecord, withRunRecord, } from "./RunRecord.ts"; -export { OutdatedBase, state, STORE_PROJECT_NAME } from "./State.ts"; +export { state, STORE_PROJECT_NAME } from "./State.ts"; export { CodePushFailed, ConvexAppNotFound, diff --git a/src/store/authConfig.ts b/src/store/authConfig.ts deleted file mode 100644 index 5a0ef1c..0000000 --- a/src/store/authConfig.ts +++ /dev/null @@ -1,17 +0,0 @@ -// 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 cdf263f..a7ca2f3 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.js"; +import { runRecord, validate } from "./runRecord.ts"; const stage = v.object({ name: v.string(), diff --git a/src/store/component/schema.ts b/src/store/component/schema.ts index 1b0e743..013039f 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.js"; +import { runRecord } from "./runRecord.ts"; // `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 5b59950..5b32b3f 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.js"; +import { runRecord } from "./runRecord.ts"; 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 0a126c7..0917038 100644 --- a/src/store/convex/auth.config.ts +++ b/src/store/convex/auth.config.ts @@ -1,4 +1,14 @@ -import { authConfig } from "../authConfig.js"; +import type { AuthConfig } from "convex/server"; import { env } from "./_generated/server.js"; -export default authConfig(env.CONVEX_CLOUD_URL); +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; diff --git a/src/store/convex/convex.config.ts b/src/store/convex/convex.config.ts index 3373438..bb21316 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.js"; +import alchemyState from "../component/convex.config.ts"; const app = defineApp(); app.use(alchemyState); diff --git a/src/store/convex/reader.ts b/src/store/convex/reader.ts index f2eb3da..ae8acfa 100644 --- a/src/store/convex/reader.ts +++ b/src/store/convex/reader.ts @@ -1,5 +1,51 @@ -// The authenticated, redacted reads of the store app, from src/store/functions.ts. -import { readerFunctions } from "../functions.js"; +// 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"; import { components } from "./_generated/api.js"; +import { env, internalMutation, query } from "./_generated/server.js"; -export const { authorize, repository } = readerFunctions(components.alchemyState); +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, + }); + }, +}); diff --git a/src/store/convex/state.ts b/src/store/convex/state.ts index 2afa4bb..adb4ec2 100644 --- a/src/store/convex/state.ts +++ b/src/store/convex/state.ts @@ -1,23 +1,123 @@ -// 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"; +// The state functions of the alchemy-state-store 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"; import { components } from "./_generated/api.js"; +import { internalMutation, internalQuery } from "./_generated/server.js"; +import { STORE_VERSION } from "./version.ts"; -export const { - version, - host, - stacks, - stages, - list, - get, - replaced, - set, - remove, - removeStack, - getOutput, - setOutput, - readStack, -} = stateFunctions(components.alchemyState, "seed"); +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 }), +}); diff --git a/src/store/convex/version.ts b/src/store/convex/version.ts index a94ad35..3ab6a8b 100644 --- a/src/store/convex/version.ts +++ b/src/store/convex/version.ts @@ -1,13 +1,9 @@ -// 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. -// -// 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. +// 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 only when a function that a released client calls changes or goes away: +// each consumer needs the exact number, so a raise moves every consumer of a +// team at once. // // 1: `version`, `stacks`, `stages`, `list`, `get`, `replaced`, `getOutput`, // `set`, `remove`, `removeStack`, `setOutput` (0.6.0). diff --git a/src/store/functions.ts b/src/store/functions.ts deleted file mode 100644 index 9a9bf7f..0000000 --- a/src/store/functions.ts +++ /dev/null @@ -1,214 +0,0 @@ -// 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: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. */ - version: internalQueryGeneric({ - args: {}, - 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. */ - 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(); - // Custom claims are bracket reads: a base app compiles this file under its own tsconfig, - // which can set noPropertyAccessFromIndexSignature. - const scope = identity?.["scope"]; - const repository = identity?.["repository"]; - // 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 || - scope !== "alchemy:read" || - typeof repository !== "string" || - !repositoryName.test(repository) - ) { - throw new Error("Alchemy repository read access is required."); - } - return await ctx.runQuery(alchemyState.reader.repository, { repository }); - }, - }), -}); diff --git a/src/store/package.json b/src/store/package.json index 81a0be6..36f9c9d 100644 --- a/src/store/package.json +++ b/src/store/package.json @@ -1,5 +1,5 @@ { - "name": "samebase", + "name": "alchemy-state-store", "private": true, "type": "module", "peerDependencies": { diff --git a/test/Component.test.ts b/test/Component.test.ts index 253e9d9..63c5a33 100644 --- a/test/Component.test.ts +++ b/test/Component.test.ts @@ -14,10 +14,6 @@ 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({ @@ -109,7 +105,6 @@ 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/HubClient.test.ts b/test/HubClient.test.ts index eabb917..74afad8 100644 --- a/test/HubClient.test.ts +++ b/test/HubClient.test.ts @@ -9,9 +9,9 @@ import * as Schema from "effect/Schema"; import { describe, expect, it, vi } from "vitest"; import * as Convex from "../src/index.ts"; import { fromValues } from "../src/Credentials.ts"; -import { registerDevelopmentClient } from "../src/HubClient.ts"; +import { HUB_PROJECT_NAME, registerDevelopmentClient } from "../src/HubClient.ts"; import { ManagementApiLive } from "../src/ManagementApi.ts"; -import { STORE_PROJECT_NAME, RUN_KEY_ACTIONS } from "../src/State.ts"; +import { RUN_KEY_ACTIONS } from "../src/State.ts"; import { FakeConvex, Hub } from "./fakeConvex.ts"; const TEAM = 38516; @@ -42,11 +42,11 @@ const client = (input: Convex.HubClientProps) => }; }); -/** A fake with the base of the team: the project of the state, with a hub on its production deployment. */ -const withBase = () => { +/** A fake with the hub of the team on the production deployment of its project. */ +const withHub = () => { const fake = new FakeConvex().install(); const [deployment] = fake.deploymentsOf( - fake.addProject({ name: STORE_PROJECT_NAME, teamId: TEAM }), + fake.addProject({ name: HUB_PROJECT_NAME, teamId: TEAM }), ); if (deployment === undefined) throw new Error("the fake project has no deployment"); const hub = new Hub(ISSUER); @@ -57,9 +57,9 @@ const withBase = () => { const KeyBody = Schema.Struct({ allowedActions: Schema.Array(Schema.String) }); describe("Convex.HubClient", () => { - test.provider("registers with a key of the base that runs internal functions only", (stack) => + test.provider("registers with a key of the hub that runs internal functions only", (stack) => Effect.gen(function* () { - const { fake, hub, deployment } = withBase(); + const { fake, hub, deployment } = withHub(); const out = yield* stack.deploy(client(props())); expect(out).toEqual({ issuer: ISSUER, clientId: "cl_1", secret: "secret-of-cl_1" }); expect(hub.clients.get("cl_1")).toEqual({ @@ -82,7 +82,7 @@ describe("Convex.HubClient", () => { test.provider("a deploy with the same redirect URI does not reach the hub", (stack) => Effect.gen(function* () { - const { fake, hub } = withBase(); + const { fake, hub } = withHub(); const first = yield* stack.deploy(client(props())); const again = yield* stack.deploy( client(props({ clientName: "other", softwareVersion: "d" })), @@ -95,7 +95,7 @@ describe("Convex.HubClient", () => { test.provider("another app or team stores the new props and keeps the client", (stack) => Effect.gen(function* () { - const { fake, hub } = withBase(); + const { fake, hub } = withHub(); const first = yield* stack.deploy(client(props())); const again = yield* stack.deploy(client(props({ app: "other" }))); expect(again).toEqual(first); @@ -106,7 +106,7 @@ describe("Convex.HubClient", () => { test.provider("another redirect URI is a new client, and the old one goes", (stack) => Effect.gen(function* () { - const { hub } = withBase(); + const { hub } = withHub(); yield* stack.deploy(client(props())); const next = yield* stack.deploy(client(props({ redirectUri: `${CALLBACK}2` }))); expect(next.clientId).toBe("cl_2"); @@ -116,14 +116,14 @@ describe("Convex.HubClient", () => { test.provider("a destroy deletes the client", (stack) => Effect.gen(function* () { - const { hub } = withBase(); + const { hub } = withHub(); yield* stack.deploy(client(props())); yield* stack.destroy(); expect(hub.clients.size).toBe(0); }), ); - test.provider("a team without a base fails and names what is missing", (stack) => + test.provider("a team without a hub project fails and names what is missing", (stack) => Effect.gen(function* () { new FakeConvex().install(); const error = yield* Effect.flip(stack.deploy(client(props()))); @@ -132,9 +132,9 @@ describe("Convex.HubClient", () => { }), ); - test.provider("a base without a hub fails with the Convex message", (stack) => + test.provider("a project without the hub functions fails with the Convex message", (stack) => Effect.gen(function* () { - const { fake, deployment } = withBase(); + const { fake, deployment } = withHub(); fake.hubs.delete(deployment); const error = yield* Effect.flip(stack.deploy(client(props()))); expect(error).toBeInstanceOf(Convex.HubClientFailed); @@ -162,7 +162,7 @@ describe("registerDevelopmentClient", () => { ); it("a deployment with all three variables keeps them", async () => { - const { hub } = withBase(); + const { hub } = withHub(); const convex = vi.fn(() => "HUB_ISSUER\nHUB_CLIENT_ID\nHUB_CLIENT_SECRET\nOTHER"); expect(await run(convex)).toBeUndefined(); expect(convex).toHaveBeenCalledTimes(1); @@ -170,7 +170,7 @@ describe("registerDevelopmentClient", () => { }); it("a deployment with no complete client registers, and the secret goes on standard input", async () => { - const { hub } = withBase(); + const { hub } = withHub(); const inputs: (string | undefined)[] = []; const calls: string[] = []; const url = await run((args, input) => { diff --git a/test/State.test.ts b/test/State.test.ts index a3d15b1..2c66553 100644 --- a/test/State.test.ts +++ b/test/State.test.ts @@ -25,7 +25,6 @@ import { RUN_RECORD_KEY, type RunRecord } from "../src/RunRecord.ts"; import { makeConvexState, makeFunctionApi, - OutdatedBase, RUN_KEY_ACTIONS, state, STORE_PROJECT_NAME, @@ -56,7 +55,10 @@ beforeEach(() => { store = join(app, "convex"); mkdirSync(store, { recursive: true }); mkdirSync(join(app, "node_modules", "convex"), { recursive: true }); - writeFileSync(join(app, "package.json"), JSON.stringify({ name: "samebase", private: true })); + writeFileSync( + join(app, "package.json"), + JSON.stringify({ name: STORE_PROJECT_NAME, private: true }), + ); writeFileSync( join(app, "node_modules", "convex", "package.json"), JSON.stringify({ name: "convex", bin: { convex: FAKE_CLI } }), @@ -156,7 +158,7 @@ describe("Convex.state", () => { const [runKey, pushKey, pushKeyDelete] = keyCalls(fake); expect(Schema.decodeUnknownSync(KeyBody)(runKey?.body)).toEqual({ - name: "samebase", + name: STORE_PROJECT_NAME, allowedActions: [...RUN_KEY_ACTIONS], }); expect(Schema.decodeUnknownSync(KeyBody)(pushKey?.body).allowedActions).toEqual([ @@ -248,16 +250,11 @@ describe("Convex.state", () => { const deployment = fake.project(fake.addProject({ name: STORE_PROJECT_NAME, teamId: TEAM })) ?.prodDeploymentName ?? ""; - // 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. + // The deployment answers version 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`, [ - older, - noHost, - older, - noHost, + { status: 200, body: { ...stateFixture("function_version.json"), value: 0 } }, + { status: 200, body: { ...stateFixture("function_version.json"), value: 0 } }, ]); await run(open()); expect(await run(runs())).toHaveLength(1); @@ -269,34 +266,6 @@ 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 = diff --git a/test/fakeConvex.ts b/test/fakeConvex.ts index bdc875d..25488f4 100644 --- a/test/fakeConvex.ts +++ b/test/fakeConvex.ts @@ -55,14 +55,9 @@ export interface StoreRow { readonly value: string; } -/** The store on one deployment: its version, the app that serves it, its rows, and outputs. */ +/** The store of src/store/convex on one deployment: its version, rows, and outputs. */ export interface Store { version: number; - /** - * `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; } @@ -86,7 +81,6 @@ export class Hub { /** 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", @@ -173,8 +167,6 @@ 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 identity hub of each deployment that has one: POST /api/function answers its admin functions. */ readonly hubs = new Map(); /** The store functions that ran, as " " lines. */ @@ -227,9 +219,9 @@ export class FakeConvex { return id; } - /** Gives a deployment the store at `version` that `host` serves, with no rows, and returns it. */ - addStore(deployment: string, version: number, host?: Store["host"]) { - const store: Store = { version, host, rows: [], outputs: new Map() }; + /** 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() }; this.stores.set(deployment, store); return store; } @@ -509,16 +501,12 @@ export class FakeConvex { return json(200, { ...stateFixture("function_version.json"), value: hub.run(path, hubArgs) }); } const name = path.replace(/^state:/, ""); - // 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); + // 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. + if (this.pushedByCli(deployment)) { const pushed = this.stores.get(deployment); - if (pushed === undefined) this.addStore(deployment, STORE_VERSION, "seed"); - else { - pushed.version = STORE_VERSION; - pushed.host = "seed"; - } + if (pushed === undefined) this.addStore(deployment, STORE_VERSION); + else if (pushed.version < STORE_VERSION) pushed.version = STORE_VERSION; } const store = this.stores.get(deployment); const reply = (value: unknown) => @@ -528,8 +516,7 @@ export class FakeConvex { if ( store === undefined || !(STORE_QUERIES.includes(name) || STORE_MUTATIONS.includes(name)) || - store.version < (STORE_FUNCTION_SINCE[name] ?? 1) || - (name === "host" && store.host === undefined) + store.version < (STORE_FUNCTION_SINCE[name] ?? 1) ) { return error( `[Request ID: fake] Server Error\nCould not find function for '${path}'. Did you forget to run \`npx convex dev\`?\n`, @@ -551,8 +538,6 @@ export class FakeConvex { switch (name) { case "version": 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": @@ -630,18 +615,15 @@ export class FakeConvex { } /** True when the fake CLI log (fixtures/fake-convex-cli.ts) has a run with a key of `deployment`. */ - /** The push events of the fake CLI with a key of this deployment that are not applied yet. */ - private newCliPushes(deployment: string): string[] { + private pushedByCli(deployment: string) { const log = process.env["ALCHEMY_CONVEX_FAKE_CLI_LOG"]; - if (log === undefined || !existsSync(log)) return []; + if (log === undefined || !existsSync(log)) return false; const hashes = new Set(this.keysOf(this.createdSecrets, deployment).map(sha256)); return readFileSync(log, "utf8") .split("\n") - .filter( - (line) => - line !== "" && - !this.appliedPushes.has(line) && - hashes.has(Schema.decodeUnknownSync(Schema.fromJsonString(CliRun))(line).keySha256), + .filter((line) => line !== "") + .some((line) => + hashes.has(Schema.decodeUnknownSync(Schema.fromJsonString(CliRun))(line).keySha256), ); } diff --git a/test/reader.test.ts b/test/reader.test.ts index c17655b..fc809e9 100644 --- a/test/reader.test.ts +++ b/test/reader.test.ts @@ -14,15 +14,7 @@ const run: RunRecord = { source: "laptop", recordedAt: "2026-10-24T10:00:00.000Z", }; -/** 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, -}; +const identity = { subject: "reader", scope: "alchemy:read", repository: run.repository }; describe("repository reader", () => { it("denies anonymous, malformed, and wrong-scope identities", async () => { @@ -30,7 +22,6 @@ 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 }, diff --git a/test/store.test.ts b/test/store.test.ts index 88fa38e..a53fd7d 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -1,34 +1,16 @@ // The store functions of src/store/convex and its alchemyState component in convex-test. import { describe, expect, it } from "vitest"; -import { components, internal } from "../src/store/convex/_generated/api.js"; +import { 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 and its host", async () => { + it("answers its wire version", async () => { const t = storeTest(); 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 () => { - // 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, {})).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({ - 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 b1c4093..7a32c16 100644 --- a/test/storeTest.ts +++ b/test/storeTest.ts @@ -7,9 +7,8 @@ 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"); -/** 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 } }); +export const storeTest = () => { + const t = convexTest({ modules: app }); t.registerComponent("alchemyState", schema, component); return t; };