Best practices
The opinionated idioms that keep a Lunora app fast, safe, and reactive.
Last updated:
Lunora has a grain. Go with it and most performance, correctness, and security problems never appear; the Advisors catch the ones that do. This is the short list.
Schema first
- Define your data in
schema.tsbefore writing functions. Codegen derivesapi,Id<"table">,Doc<"table">, and the typedctx.dbfrom it, so the schema is what makes the whole app type-safe end to end. See Generated code. - Let the schema, not your handlers, enforce shape. Optionality, relations, and constraints belong in the table definition.
Reads
- Index every filtered read. A
query(...).filter(...)with no leading.withIndex()loads every row and filters in memory: fine on ten rows, a full-scan on a million. Declare the index and lead with it. Thefilter_without_indexadvisor flags this for you. - Index foreign keys you read by. A FK column with no index leading with it
full-scans on every join (
unindexed_foreign_key). See Indexes. - Prefer reactive queries over actions for reads. A
queryauto-subscribes clients and pushes deltas; anactionreturns a one-shot value with no reactivity. If the UI should update when the data changes, it's a query.
Writes
- Keep mutations small and deterministic. A
mutationreads and writes the database and nothing else. NoDate.now(),Math.random(), orfetch(): nondeterminism breaks caching and replay, and thenondeterministic_query_mutationadvisor will warn you. For the current time, readctx.now(a stable per-execution timestamp); for randomness/network, compute it in anactionand pass the result in as an argument. - Push side effects into actions. External API calls, queues, sending mail,
anything non-deterministic or that touches the outside world: that's what an
actionis for. See Actions. Have the action call back into amutationto persist the result.
Inputs and safety
- Validate every input with
v.*. A typed.input({ … })rejects malformed args with a400before your handler runs. Never acceptv.any()on a public function (public_arg_uses_anyflags it), and bound public strings with a length.checkso they can't be abused as a storage/DoS vector. - Throw
LunoraErrorfor expected failures. It carries acodeand the right status to the client; plainErrors are hidden behind a generic500. See Error handling. - Guard public mutations. Rate-limit and (where relevant) CAPTCHA-protect abuse-sensitive endpoints; the security advisors raise the urgency on login/signup/reset names.
Scaling
- Start with the default single Durable Object. It's the easiest topology to reason about and is enough for most apps. Don't shard preemptively.
- Shard by tenant or room when you actually need to.
.shardBy(key)partitions state per user/tenant/room so load spreads across many DOs. Pick a key with even traffic; thehot_shardruntime advisor flags a key where one shard dominates. See Sharding.
Lean on Advisors
You don't have to remember all of the above. The Advisors
run the static set on every lunora dev save and on deploy, and the Studio
computes the runtime set from live shard metrics, surfacing unindexed scans,
missing guards, hot shards, and dead schema before they bite.
See also
- Queries & mutations — picking the right function kind.
- Actions — where side effects live.
- Indexes — making reads fast.
- Sharding — scaling state across Durable Objects.
- Error handling — expected vs. unexpected failures.
- Generated code — the schema-derived types.
- Advisors — the lints that enforce these idioms.