@patronage/alchemy-d1-state
v0.1.1
Published
Alchemy v2 custom state store backed by one shared Cloudflare D1 database, one table per project
Readme
@patronage/alchemy-d1-state
An Alchemy v2 custom state store backed by one shared Cloudflare D1 database, one table per project.
It replaces Alchemy's built-in Cloudflare state-store worker. Instead of each project running its own state worker behind a rotatable shared credential, every project writes to its own table in a single D1 database that is provisioned once by hand and never managed by an Alchemy stack.
pnpm add @patronage/alchemy-d1-statealchemy and effect are peer dependencies — your stack supplies both.
Usage
import { d1State } from "@patronage/alchemy-d1-state";
import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
export default Alchemy.Stack(
"MyApp",
{
providers: Cloudflare.providers(),
state: d1State({
databaseId: process.env.ALCHEMY_STATE_DB_ID!,
table: "my_app", // this project's table
}),
},
Effect.gen(function* () {
// ... resources ...
})
);d1State options:
| Option | Required | Notes |
| --- | --- | --- |
| databaseId | yes | The shared state database's D1 id. |
| table | yes | A bare SQL identifier ([A-Za-z_][A-Za-z0-9_]*) interpolated into DDL/DML — this project's table. |
| accountId | no | Defaults to CLOUDFLARE_ACCOUNT_ID. |
| apiToken | no | Defaults to CLOUDFLARE_API_TOKEN; needs D1 edit on the account. |
| baseUrl | no | Overrides the Cloudflare API root. |
| client | no | An injected D1Client — used by tests to run the real store code against local SQLite with no network. |
The service is built once per stack run and memoised with Effect.cached, mirroring Alchemy's own inMemoryState/localState layers, so the D1 backend is only exercised on real state access — never at alchemy login or layer construction.
Storage model
Each project's table is keyed (stack, stage, fqn) with upsert semantics: a write to an existing key replaces the row rather than erroring or appending. A stack's resolved output (getOutput/setOutput) lives in the same table under a reserved sentinel FQN, excluded from every resource-facing query.
Provisioning
The store creates and writes rows; it never creates the database. Provision that once, by hand, with wrangler, and do not let an Alchemy stack manage it — an Alchemy-managed store database would let one project's destroy delete every project's state.
1. Create the state database
wrangler d1 create my-alchemy-stateKeep the printed database id; that is what each stack passes as databaseId (Patronage projects read it from ALCHEMY_STATE_DB_ID). One database serves every project — adding a project adds a table, not a database.
2. Create this project's table
The table shape is part of the package's contract, so the migrations ship in the tarball:
node_modules/@patronage/alchemy-d1-state/provisioning/migrations/
0001_create_project_table.sql template — replace `project_table` with your table name
0002_create_hq_table.sql a worked example of that templateCopy 0001_create_project_table.sql into your own migrations/ directory, rename project_table to the name you pass to d1State({ table }), and apply it:
wrangler d1 migrations apply my-alchemy-state --remoteAdding a project is purely additive — a new table, no other project's data or credentials touched. If the migration is skipped the store will CREATE TABLE IF NOT EXISTS its own table on first write; running the migration is still preferable, because it makes the schema reviewable and diffable before any state depends on it.
3. Enable durability
The state database is the only record of what your stacks own, so back it up independently of D1:
- Time Travel is on by default (30-day point-in-time restore). Confirm with
wrangler d1 time-travel info my-alchemy-state. - Add a scheduled export to R2 — e.g.
wrangler d1 export my-alchemy-state --remote --output=state-$(date +%F).sqlon a cron.
Schema
CREATE TABLE IF NOT EXISTS "<table>" (
stack TEXT NOT NULL,
stage TEXT NOT NULL,
fqn TEXT NOT NULL,
data TEXT NOT NULL,
PRIMARY KEY (stack, stage, fqn)
);FQNs are stored via Alchemy's encodeFqn (/ → __).
Fail-closed secret guard
This store ships with no encryption key by design. Built-in Alchemy stores rely on at-rest encryption to make persisting an Effect Redacted<T> marker safe; this store has nothing to rely on, so it refuses instead.
set() and setOutput() scan the encoded value before writing and fail with a StateStoreError if a Redacted marker appears anywhere in it. The message names the JSON path of the leak plus the resource FQN (set) or the stack/stage the output belongs to (setOutput). It is an Effect failure, not a thrown exception — catch it with Effect.catchTag, not try/catch. Nothing is written when the guard fires: the scan runs before the upsert.
Keep secrets out of persisted props/attr entirely. Store a reference or id and read the secret from Secrets Store or the environment at runtime.
Trust boundary
A Cloudflare API token with D1 access is account-wide: any token that can write one project's table can read and write every project's table in the shared database. Table-per-project is operational isolation, not a security boundary. What this store removes is a shared rotatable credential and secrets at rest — not cross-project inaccessibility. Treat every project's CLOUDFLARE_API_TOKEN as able to reach all state in the shared database, and scope and rotate tokens accordingly.
Exports
| Export | Kind | Notes |
| --- | --- | --- |
| d1State | function | The state: layer factory for Alchemy.Stack. Takes D1StateProps. |
| buildService | function | The underlying StateService builder; d1State composes it with a lazily-resolved client. |
| STATE_STORE_ID | constant | Telemetry slug ("cloudflare-d1") reported as alchemy.state_store.id. |
| D1StateProps | type | { databaseId, table, accountId?, apiToken?, fetch?, baseUrl?, client? }. |
| createHttpD1Client | function | Builds a D1Client that calls the real Cloudflare D1 HTTP query API. |
| lazyHttpD1Client | function | Defers client construction (and credential reads) until first use. |
| D1Client | type | Minimal client interface: run one parameterised SQL statement, get rows back. |
| D1QueryResult | type | Row set returned by a single D1Client statement. |
| HttpD1ClientProps | type | Props for createHttpD1Client. |
| findRedactedPath | function | Scans an encoded value for an Effect Redacted marker and returns its JSON path; backs the secret guard. |
License
MIT
