Skip to content
DocsconceptsDocumentation

Sharding

Default __root__ DO, opt-in .shardBy(), and .global() escape hatch.

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.