Client & deploy
Adapters
The three contracts, the implementations that ship, and how to write your own.
Adapters are the only layer that touches the outside world. Everything above them — operations,
hooks, access, validation — is the same whether you are on SQLite in a file, D1 at the edge, or a
Map in a test.
What ships
| Contract | Implementation | Package | Notes |
|---|---|---|---|
DatabaseAdapter |
InMemoryDatabaseAdapter |
@forge-cms/db |
Local dev and tests; resets on reload |
DatabaseAdapter |
LibSqlDatabaseAdapter |
@forge-cms/db |
SQLite / Turso, via drizzle |
DatabaseAdapter |
D1DatabaseAdapter |
@forge-cms/cloudflare |
Cloudflare D1, with index creation |
AuthAdapter |
UsersCollectionAuthAdapter |
@forge-cms/auth |
Real users in your database, PBKDF2 |
AuthAdapter |
SignedTokenAuthAdapter |
@forge-cms/auth |
Signed tokens, no user store |
AuthAdapter |
ExternalAuthAdapter |
@forge-cms/auth |
Delegates validation to another service |
AuthAdapter |
InMemoryAuthAdapter |
@forge-cms/auth |
Tests |
StorageAdapter |
InMemoryStorageAdapter |
@forge-cms/storage |
Local dev and tests only; not durable |
StorageAdapter |
R2StorageAdapter |
@forge-cms/cloudflare |
Cloudflare R2 (durable) |
StorageAdapter |
S3StorageAdapter |
@forge-cms/s3 |
S3-compatible storage (durable) |
There is no KV adapter, despite what older notes may suggest.
DatabaseAdapter
interface DatabaseAdapter<TRecord extends DatabaseRecord = DatabaseRecord> {
readonly name: string;
init(env?: unknown): this;
findById(collection: string, id: string): Promise<TRecord | null>;
findMany(options: FindManyOptions): Promise<TRecord[]>;
count(collection: string, where?: DatabaseWhere): Promise<number>;
create(collection: string, data: TRecord): Promise<TRecord>;
update(collection: string, id: string, data: Partial<TRecord>): Promise<TRecord>;
delete(collection: string, id: string): Promise<void>;
updateIf(
collection: string,
id: string,
data: Partial<TRecord>,
condition: WriteCondition
): Promise<ConditionalUpdateResult<TRecord>>;
deleteIf(
collection: string,
id: string,
condition: WriteCondition
): Promise<ConditionalDeleteResult>;
atomicWrite(
operations: readonly AtomicWriteOperation<TRecord>[]
): Promise<AtomicWriteResult<TRecord>[]>;
syncSchema(collections: CollectionDefinition[]): Promise<void>;
}
updateIf/deleteIf are conditional writes: the condition and the write are one atomic step, decided
by the database rather than by a read your code did earlier. A WriteCondition has two optional clauses —
targetMatches (the row must currently match; per-row compare-and-set) and
keepAtLeast: { where, others } (the row may leave the set matching where only while at least others
other rows of the collection stay in it — this is what keeps a users collection from losing its last
admin). A missing row or an unmet condition returns { applied: false }; it is not an error. A database
failure rejects — it is never reported as "not applied". LibSqlDatabaseAdapter and D1DatabaseAdapter
run each as one guarded SQL statement, so it holds across independent Workers or processes;
InMemoryDatabaseAdapter is atomic within one adapter instance only. This is a single-row primitive, not a
transaction. Writing your own adapter? Implement both methods and run
runDatabaseAdapterConditionalWriteContractTests alongside the other suites; UsersCollectionAuthAdapter
refuses to initialise over a database that lacks them.
atomicWrite is an atomic write batch: an ordered list of create/update/delete/updateIf/
deleteIf operations (the same names and semantics as the methods above) that all commit or none do.
It is declarative data — there is no callback, so no hook, HTTP call or other async work can run between
statements — and it is database-only: a database and an object store (D1 + R2, libSQL + S3) can never
commit together, so an upload lifecycle needs compensation, not a batch.
const [claim, user] = await database.atomicWrite([
{ type: 'create', collection: '_forge_bootstrap', data: { slot: 'users' } },
{ type: 'create', collection: 'users', data: { email, role: 'admin', passwordHash } }
]);
- Operations run in order; later ones see what earlier ones wrote. Results come back in the same order
and length, discriminated by
type(create/update→{ record };updateIf→{ applied, record? };deleteIf→{ applied };delete→{}). An empty batch returns[]. - Failure rolls everything back and rejects: a unique-index violation →
UniqueConstraintError(itscollectionsays which table conflicted); a plainupdateof a missing row, or anupdateIf/deleteIfwithrequireApplied: truethat does not apply →AtomicWriteConditionError. WithoutrequireApplieda conditional operation may reportapplied: falseand the rest of the batch still commits — userequireAppliedwhenever a later operation depends on it (e.g. a document compare-and-set followed by its snapshot insert).deleteof a missing row is a no-op. { type: 'assertCount', collection, where?, equals }writes nothing. It fails the batch withAtomicWriteConditionErrorunless exactlyequalsrows matchwhereat that point of the batch: earlier operations are visible, later ones are not. It is the cross-collection guard relation integrity uses. Examples: "no row still references X" isequals: 0over{ author: { in: [X] } }; "every target still exists" isequals: ids.lengthover{ id: { in: ids } }.equalsmust be a non-negative integer. Its result is{ type: 'assertCount' }.- Invalid input — more than
ATOMIC_WRITE_MAX_OPERATIONS(25) operations, a malformed operation, and on the SQL adapters an unknown column or unregistered collection — rejects before anything is written. - Retry:
UniqueConstraintErrorandAtomicWriteConditionErrormean "known rolled back". A network failure after the request left the process is outcome-unknown; supply your own unique keys (on the SQL adapters, ids too) so a retry is recognisable, and do not assume exactly-once. LibSqlDatabaseAdapterruns oneclient.batch(statements, 'write');D1DatabaseAdapterruns one D1batch();InMemoryDatabaseAdapterstages a copy of the touched collections and publishes it in one synchronous turn (atomic within one adapter instance only). Writing your own adapter? It must be genuinely atomic — a loop of independent writes is not an implementation. If you are SQLite-based, reuse the exportedassertValidAtomicWrite,toAtomicWriteError,atomicWriteMustApply(which operations must be followed by the guard statement),ATOMIC_WRITE_REQUIRE_APPLIED_SQLandATOMIC_WRITE_ASSERT_COUNT_SELECT(assertCount's statement, beforeFROM), and runrunDatabaseAdapterAtomicWriteContractTests.UsersCollectionAuthAdapterrefuses to initialise over a database that lacksatomicWrite, because first-admin provisioning depends on it.
FindManyOptions is { collection, limit?, offset?, where?, sort?, order? }. count must honour
the same where as findMany — otherwise pagination advertises pages that do not exist. The SQL
adapters share one where-clause builder between the two so they cannot drift.
DatabaseWhere supports nested and/or groups on top of the flat field operators, and sort
accepts a single field name or a { field, order }[] for a multi-field sort — see
Local API. InMemoryDatabaseAdapter evaluates them with a pure recursive
matchesWhere, the executable reference every adapter's generated SQL is proven against by a shared
cross-adapter contract suite (runDatabaseAdapterQueryContractTests,
@forge-cms/testing/contracts). Writing your own DatabaseAdapter? Run that suite too, alongside
runDatabaseAdapterContractTests.
AuthAdapter
interface AuthAdapter<TUser extends AuthUser = AuthUser> {
readonly name: string;
init(env?: unknown): this;
extractToken(request: Request): string | null;
validateSession(token: string): Promise<AuthSession<TUser> | null>;
requireAuth(request: Request): Promise<TUser>;
// optional, all additive: syncSchema?(), canHandleToken?(token), login?(…), signup?(…), and
managesCollection?(slug: string): boolean;
}
requireAuth throws ForgeAuthError with code unauthorized, forbidden or expired.
managesCollection?(slug) declares that the adapter owns the identity and lifecycle of a Forge
collection's documents. When it returns true, the runtime refuses generic create/update/delete
of that collection with AuthManagedCollectionError (403, AUTH_MANAGED_COLLECTION) — Local API with
overrideAccess true or false, and HTTP — because those writes would bypass the adapter's own
invariants. Reads and every other collection are unaffected. Omit it (absent means false) unless your
adapter stores users in a Forge collection and enforces rules on them; UsersCollectionAuthAdapter
answers true for exactly its configured collection, and CompositeAuthAdapter for whatever any child
claims.
UsersCollectionAuthAdapter
const database = new D1DatabaseAdapter(); // or LibSqlDatabaseAdapter; in-memory only in development
const auth = new UsersCollectionAuthAdapter().init({ ...env, userDatabase: database });
Users live in a normal collection (users by default) with PBKDF2-hashed passwords and an
admin/editor/viewer role. AUTH_SECRET from the env (at least 32 bytes) signs the tokens; without it,
init() throws unless you pass an explicit { devMode: true } for local development. Passwords are
bounded by passwordPolicy (8–1024 characters by default); see
Browser auth.
Wrap the collection with withAuthFields() so schema generation covers the columns the adapter
writes:
import { withAuthFields } from '@forge-cms/auth';
const collections = [posts, media, withAuthFields(users)];
Skipping it produces table users has no column named passwordHash the first time someone logs in
against a real database.
StorageAdapter
interface StorageAdapter {
readonly name: string;
init(env?: unknown): this;
put(options: PutObjectOptions): Promise<StorageObject>;
get(key: string): Promise<StorageObject | null>;
delete(key: string): Promise<void>;
getPublicUrl(key: string): Promise<string>;
list(prefix?: string): Promise<StorageObject[]>;
}
getPublicUrl must return something a browser can actually load. R2StorageAdapter defaults to the
path handleFile is meant to be mounted on; point it at a custom domain when the bucket is public:
new R2StorageAdapter({ binding: 'BUCKET', publicUrlBase: 'https://cdn.example.com' });
Selecting adapters at runtime
InMemoryDatabaseAdapter and InMemoryStorageAdapter are development and test adapters. They are not durability
evidence and must not be a production fallback: a selector like env.DB ? D1 : InMemory deploys a site that
silently forgets everything when a binding is missing or misnamed. Keep a development factory and a
durable-profile factory, and make the latter throw on an incomplete profile. The two official durable
profiles are D1 + R2 and libSQL + S3 — see Deployment.
Bindings only exist on the deployed Worker, so build the durable runtime per request environment:
export async function getServerRuntime(env: ServerEnv) {
if (!env.DB || !env.BUCKET)
throw new Error('Incomplete Cloudflare profile: DB and BUCKET are required');
const database = new D1DatabaseAdapter();
const storage = new R2StorageAdapter();
const auth = new UsersCollectionAuthAdapter().init({ ...env, userDatabase: database });
const runtime = new ForgeCmsRuntime({ collections, adapters: { database, auth, storage }, env });
runtime.init();
await runtime.syncSchema();
return runtime;
}
Both D1 and R2 adapters take a binding option, so a Worker with two databases can use both:
new D1DatabaseAdapter({ binding: 'CONTENT_DB' });
Build the runtime lazily on first request, not at module scope — Cloudflare Workers forbid async I/O at module load, and seeding at import time breaks the deploy.
Writing your own
Any new adapter must pass the shared contract suites:
import { describe } from 'vitest';
import { runDatabaseAdapterContractTests } from '@forge-cms/testing/contracts';
import { PostgresDatabaseAdapter } from './postgres.adapter.js';
describe('PostgresDatabaseAdapter', () => {
runDatabaseAdapterContractTests(() => new PostgresDatabaseAdapter(/* … */).init());
});
There are equivalent suites for auth and storage. @forge-cms/testing/contracts is the one official
deep import in the whole workspace.
Two behaviours the suites pin down because they were real bugs: contains is case-insensitive
(SQLite's LIKE semantics — the in-memory adapter had to be taught to match), and adapters stamp
created_at/updated_at themselves.