Stores (Durable Object & D1)
pramen runs the same schema, ACL, and read/write engine over two stores. The default is the Durable Object (the DO is the database); the D1 store is a first-class alternate for read-heavy / globally-distributed reads.
| Durable Object (default) | D1 (x-pramen-store: d1) |
|
|---|---|---|
| Where it runs | per-tenant DO (in-process SQLite) | the Worker, over a D1 binding (RPC) |
| Single-writer | yes (free β one request at a time) | no (D1 has no interactive transactions) |
Live queries (/live) |
β | β DO-only |
| Read replicas | β | β Sessions API (read-your-writes) |
| Atomic multi-statement mutation | β (rolls back on throw) | β (each statement auto-commits) |
The DO remains the write / atomic / live database; D1 is the read-replica / no-DO path. Both share the R2 file store, KV, and the deferred-task outbox.
Selecting the store
- Per request β the reliable way. Send the header
x-pramen-store: d1to run a call on D1, orx-pramen-store: doto force the Durable Object. - App-wide default. Set the
PRAMEN_STORE=d1var to make D1 the default when no header is sent (the header still overrides per request).
Prefer the header to pin the store. A
PRAMEN_STOREvar can be dropped by some adapters'cloudflare:workersenv proxies (e.g. Astro's), so the Worker may not see it in-process. Thex-pramen-store: d1header is always honored.
/live always uses the Durable Object regardless of the header or default (live needs a
single writer + a socket host). If a request routes to the DO but no DO is bound, you
get a clear 400 ("no Durable Object (PRAMEN) is bound β pin the D1 storeβ¦") instead of a
crash.
Read replicas + read-your-writes (D1 Sessions API)
Each D1-store request opens one D1 session (db.withSession(...)) and runs all its
SQL through it. The Worker picks where the session's first read may start by handler
kind:
- a mutation anchors
first-primaryβ its reads see current data; writes go to the primary regardless; - a query anchors
first-unconstrainedβ the first read may begin at the nearest replica.
The response carries the session's bookmark as the x-pramen-d1-bookmark header.
@pramen/client captures it and replays it on the next request, so a client
transparently reads its own writes even off a lagging replica (a supplied bookmark
wins over the kind default). Bare fetch callers can thread the header themselves; the
bookmark is monotonic, so once you hold one your reads never go backwards.
// @pramen/client does this for you β shown here for a raw fetch caller.
const res = await fetch(`${url}/rpc/createNote`, { method: "POST", headers, body });
const bookmark = res.headers.get("x-pramen-d1-bookmark"); // carry it forward
// next request: headers["x-pramen-d1-bookmark"] = bookmark -> reads your own write
Limits (intentional)
- Live queries are DO-only β
/liveerrors on the D1 path. - No interactive / atomic transactions on D1. pramen mutations interleave reads +
writes +
RETURNING+ trigger-into-outbox in onetransaction(), which D1 can't do atomically (no interactive txns;batch()can't read mid-batch). So on D1transaction(fn) = fn(): each statement auto-commits, and a multi-statement mutation does not roll back on throw the way it does on a DO. Single-statement mutations are atomic. Use the DO store when you need atomic mutations. - The reference setup uses one shared D1 database across tenants β a real product would add a tenant column or a per-tenant database. The ACL still scopes every row, so a shared bookmark only picks a consistency point, never widens access.
Deferred tasks on D1
The DO store self-drains its task outbox via an alarm. The D1 store has no
alarm, so drain it with a Cron Trigger β createPramen(app).scheduled, wired to a
triggers.crons entry in oblaka.ts β or on demand via POST /admin/tasks/drain with
the x-pramen-store: d1 header.