Storage backends
The contract
One interface, in core/storage/contract.ts. Eight backends implement it today: local filesystem, any Postgres, Supabase, Neon + Vercel Blob, Firebase, Cloudflare D1+R2, PocketBase and Appwrite. Convex is planned — it needs an account rather than an emulator, so it is blocked on signup, not code.
Surfaces:
- Snapshots —
readSnapshot/writeSnapshot(channel, state, expectedRevision?), plusreadSnapshotMeta. Two channels,draftandpublished. WithexpectedRevisionthe write only lands if the stored revision still matches and throwsRevisionConflictErrorotherwise; without it the write is unconditional, which is right for publishing and wrong for autosave. media—put/resolveUrl/remove/list.resolveUrlreturns a URL, never a Blob. An earlier design returned bytes, which forced the client to download every asset at boot to build object URLs.messages— the contact inbox. Its own surface because strangers append to it concurrently.subscribers— the mailing list. Same reason, plus one more: the content document is serialised into every public page and copied whole by the backup button, and a list of email addresses may be in neither.kv— sessions, one-time codes, rate limits, stored service credentials. Namespaced, with TTL. Deliberately not content, so authentication state can never travel inside an export.readOwner/writeOwner,provision(),health(), and acapabilitiesobject.
Capabilities
An adapter declares what it can do — durability, whether it has its own auth, how file storage works, and worksOnEphemeralHosts. The registry reads that last one and refuses to select the filesystem backend in production on a host that discards the disk.
The conformance suite
core/storage/conformance.ts is the specification, written framework-agnostically so it takes describe/it/expect as arguments.
No green run, no ship. This is the single thing that makes supporting many backends survivable — without it every adapter is eighty per cent finished in a slightly different way, and the bugs only appear on somebody else's deployment.
What it insists on, and each of these caught a real divergence:
- Two racing conditional writes: exactly one wins.
- Fifty concurrent appends to the inbox lose nothing. A read-modify-write on a shared blob keeps one.
- A patch merges rather than replaces, and two patches to different fields on the same record both survive.
- Clearing a field works. Spreading a patch treats
undefinedas "remove the key"; merging it as jsonb drops it before it reaches the database. One backend cleared a spent token and the other silently did not. - A subscriber never appears inside a content snapshot.
- Removing something already gone is not an error.
The file adapter passed every revision test while real Postgres failed four. One implementation is not a contract.
Adding one
- Implement
StorageAdapterincore/storage/adapters/<name>.ts. If it is SQL, look at_shared/postgres.tsfirst — three adapters share that engine. - Register it, with its capabilities and the environment variables that select it.
- Run the conformance suite against it, against a real instance or an official emulator. Not a mock.
- Add its variables to
.env.exampleand a row to configuration.
Five are planned and unbuilt: Firebase, Convex, Cloudflare D1 + R2, PocketBase and Appwrite. Each has an official local emulator, and none should ship without a green run against it.