Last updated:
Lunora scales out, but starts on a single shard. New apps get one Durable
Object, addressable as __root__, that holds every table without an explicit
tier modifier. This is the Zeroback shape, and the right answer for the first
80% of an app's life.
When to shard
The per-DO ceiling is 10 GB of SQLite and roughly 1 000 sustained req/s.
When the __root__ DO crosses 1 GB, the runtime emits a console.warn once
per DO lifetime so you can plan the migration before you hit the wall. The
threshold (1 073 741 824 bytes) is 10% of the per-DO ceiling, far enough
below it that a .shardBy() migration has runway to land.
[@lunora/do] __root__ Durable Object SQLite size is 1075000000 bytes
(>= 1 GiB, 10% of the 10 GiB per-DO ceiling). Plan a `.shardBy()` migration
before you hit the wall. See https://lunora.sh/docs/concepts/sharding for
guidance.The warning fires from ShardDO after every RPC that touches storage and
deduplicates itself with a static flag, so it shows up at most once per DO
instance. Non-__root__ DOs are never affected, even when they exceed
1 GB on their own.
Sharding is a single edit:
messages: defineTable({ channelId: v.id("channels"), text: v.string() }).shardBy("channelId");After codegen, every call to ctx.db.messages.* routes by channelId. A
chat with 5 000 active channels now spreads across 5 000 DOs, each with its
own SQLite, CPU budget, and hibernation timer.
What stays in the root DO
Anything without .shardBy() or .global() stays in __root__. That keeps
the common case (app config, feature flags, small per-app state) cheap and
strongly consistent, with no routing overhead.
Going global
For cross-tenant data (identities, billing, account-wide audit logs) use
.global(). The table lives in Cloudflare D1 instead of a DO. Reads can
hit a regional replica; writes go through the primary. Pass the D1Session
bookmark via the x-d1-bookmark header for read-your-writes consistency.
users: defineTable({ email: v.string(), name: v.string() }).global().index("by_email", ["email"], { unique: true });Cross-shard reads
ctx.db.query("messages").collect() against a .shardBy() table that
doesn't pin a shard is a fan-out: codegen routes it through the Query
Coordinator Worker, which dispatches to every shard and merges the results.
Avoid this in hot paths.
Securing shard access
Once you shard, the shard key is a client-supplied address, so one tenant could
ask for another tenant's DO. Lunora default-denies cross-shard access: a
request that names a shard other than the default (shardKey !== defaultShard,
default __root__), or a fan-out, is rejected with 403
(FORBIDDEN_SHARD / FORBIDDEN_FANOUT) unless the worker opts in. A
single-DO app never names a non-default shard, so it is unaffected.
Configure authorizeShard(identity, shardKey) on createWorker to gate it:
return true to allow, false to reject. Derive the decision from the
verified identity, so a caller can only reach shards it owns:
createWorker({
shardDO: env.SHARD,
// Gate every non-default shard on the caller owning it.
authorizeShard: (identity, shardKey) => identity?.userId === shardKey,
// Fan-out is a separate, more privileged grant when `authorizeShard` is set.
authorizeFanOut: (identity) => identity?.role === "admin",
});The alternative is allowUnauthenticatedShardAccess: true, which opts into
open shard/fan-out access. Only reach for it when every table is protected
by per-row RLS. The DO address is then no longer a
security boundary; each row authorizes itself. The runtime logs a one-time
security warning while it is on and no authorizeShard/authorizeFanOut is
configured.