Last updated:
Every error Lunora throws carries a machine-readable code. The code keys into
a central catalog that supplies the transport status, a human title, and (for
the codes where it helps) an actionable hint. The same catalog drives the CLI
renderer, the Vite error overlay, the Studio, and the client SDK, so an error
reads the same wherever you meet it.
import { isLunoraError } from "@lunora/errors";
if (isLunoraError(error)) {
error.code; // "CONFLICT"
error.status; // 409
error.hint; // actionable Markdown, when the catalog has one
}isLunoraError matches structurally: an Error instance carrying the
type: "VisulimaError" brand, a string code, and a numeric status. All
three are required — the brand is what tells a real Lunora error apart from a
foreign one that happens to have a code/status pair, so
Object.assign(new Error(message), { code, status }) does not pass.
Matching on the shape rather than on instanceof is what lets it also
recognise errors rebuilt from the wire, where the prototype is lost.
Client helpers exist for the ones you branch on most: isUnauthorizedError,
isForbiddenError, isRateLimitedError (plus getRetryAfterMs), and
isConflictError, all from @lunora/client.
Redacted codes
A handful of codes are internal. Their messages may carry SQL fragments, file paths, or internal identifiers, so the transport emits a generic message and logs the real one server-side — which means there is nothing for a client to branch on, and they are deliberately absent from the reference below.
Throwing a LunoraError with any non-internal code is your vouch that its
message is safe to show a client. invariant(...) and unreachable(...) throw a
redacted code, so "should never happen" assertions are redacted automatically.
Build-time errors
Codegen rejects your source before anything is deployed, so these never cross the RPC wire. They are thrown as plain messages rather than codes, and Lunora matches them by message text to attach a solution. The ones with dedicated guidance:
| Situation | What to do |
|---|---|
| No Lunora schema found | Create lunora/schema.ts with a defineSchema default export, or run lunora init |
defineSchema() needs an inline object literal | Pass the object literal directly; codegen reads it statically and cannot follow a variable or spread |
| Table name is reserved | The name collides with a built-in ctx.db member; rename the table |
| Duplicate table name | Two tables resolve to the same name, usually a base table and a .extend(...) both defining it |
Invalid .jurisdiction(...) value | Only the string literals "eu", "us", or "fedramp" are accepted |
unique must be a literal | Write { unique: true }, not a computed value |
| Binding not exported by your worker entry | Add the generated re-export (e.g. export * from "./lunora/_generated/containers") to your worker entry |
Throwing your own
LunoraError takes a code and a message, and fills in status, title, and hint
from the catalog:
import { LunoraError } from "@lunora/errors";
throw new LunoraError("NOT_FOUND", `no message with id ${id}`);Explicit options override the catalog defaults. For errors you expect the client to branch on, see Error handling.
Error codes
All 161 codes Lunora publishes, generated from the catalog the runtime, CLI, overlay, Studio and client SDK all read.
Every heading is an anchor, so /docs/errors#conflict links straight to one.
ADMIN_FORBIDDEN
403 — Admin access forbidden
ADMIN_TOKEN_NOT_CONFIGURED
400 — Admin token not configured
ANALYTICS_SQL_QUERY_ERROR
502 — Analytics SQL query error
AUTH_AUDIT_NOT_CONFIGURED
400 — Auth audit reader not configured
AUTH_CIMD_FETCH_NOT_PUBLIC
500 — CIMD transport needs the global_fetch_strictly_public flag
Add "global_fetch_strictly_public" to "compatibility_flags" in wrangler.jsonc, so CIMD metadata fetches cannot reach private addresses. lunora doctor reports it as cimd-fetch-not-strictly-public.
AUTH_MCP_RESOURCE_AMBIGUOUS
500 — MCP resource cannot be told apart from the provider's other resources
Use mcp from "@lunora/auth/plugins" (not @better-auth/mcp) and pass the plugin object as is: it records its resource, which the worker needs to serve the protected-resource metadata when the provider names several resources.
AUTH_MCP_RESOURCE_INVALID
500 — MCP resource missing or not an absolute URL
Pass resource to requireMcpAuth (and mcp()) as the absolute URL of the MCP route, e.g. https://app.example.com/mcp: issued tokens are audience-bound to it.
AUTH_MIGRATOR_UNSUPPORTED
500 — Auth migrator cannot drive the configured database
better-auth migrates only through its Kysely adapter, so ensureMigrated / compileMigrationsSql need the raw D1 binding as database — a custom adapter (lunoraD1Adapter, lunoraAuthAdapter, lunoraDoAdapter) cannot be migrated through, and neither can an absent database.
Build a SECOND, migration-only instance over the raw binding — createAuth({ ...options, database: env.DB }) — and hand that one to ensureMigrated. Keep the adapter on the instance that serves requests: the adapter exists to dodge a dev-runner hang in $context, which the migration instance never resolves.
To compile the SQL off-platform (compileMigrationsSql), diff against an empty local database — new DatabaseSync(':memory:') from node:sqlite — rather than passing no database at all.
AUTH_MOVE_CONFLICT
409 — Auth copy conflict
A copied row has the same key or unique value (such as an email) as a row the pinned auth object holds on its own, or the pinned object changed a row the source changed too. Nothing from that page was written. Pass force: true to keep the pinned object's rows and count the collisions under conflicts.
AUTH_MOVE_FAILED
500 — Auth jurisdiction move failed
The message names the step, the table and the class of the SQLite error; the auth object's log has the full error. The copy resumes where it stopped once the cause is fixed.
AUTH_MOVE_INCOMPLETE
409 — Auth copy not finished
Run __lunora_admin__:copyAuthToJurisdiction until it answers done: true, check the per-table counts, then purge.
AUTH_MOVE_NOT_CONFIGURED
400 — Auth jurisdiction move not configured
The copy needs DO-backed auth (.auth({ namespace })) pinned with .jurisdiction(…, { pinAuth: true }), and the auth object's internalSecret set.
AUTH_MOVE_SOURCE_CHANGED
409 — Un-pinned auth object changed since the copy
The un-pinned auth object changed after the copy read it (an update, a delete, or a new row), for example during a gradual rollout or a rollback. Run the copy again: it reconciles the changed tables. Then purge.
AUTH_MOVE_SOURCE_PURGED
409 — Un-pinned auth object already purged
The un-pinned auth object was already purged. Its tables may exist again (a rollback served requests there), but they hold only rows written since, so the copy refuses rather than read them as deletions and remove the pinned object's users. Nothing is left to copy.
AUTH_MOVE_TARGET_NOT_EMPTY
409 — Pinned auth object is not empty
The pinned auth object holds users the copy did not write (someone signed up there), so copying into it would merge two user bases. Pass force: true to copy anyway: a copied row that collides with one of the pinned object's rows is left out and counted under conflicts.
AUTH_NOT_CONFIGURED
400 — Auth admin not configured
AUTH_OP_NOT_SUPPORTED
400 — Auth admin operation not supported
BACKUP_NOT_CONFIGURED
500 — Scheduled backup not configured
BACKUP_RETENTION_NOT_CONFIGURED
400 — Backup retention window not configured
lunora backup prune removes snapshots past the retention window, and this worker has no window: set backupRetain (how many to keep) and backupCron (which decides whose snapshots retention owns) on createWorker.
Nothing was deleted. A default is deliberately not invented here — retention deleting on its own is exactly what this command exists to replace.
BACKUP_TOO_LARGE
507 — Backup too large to assemble in a Worker
The scheduled backup is assembled inside the Worker isolate, so it caps the snapshot it will build. Nothing was written.
The cap is on the NDJSON, not on peak memory: the export fan-out resolves every shard's rows before the first row is encoded, so a snapshot under the cap can still exhaust the isolate. It is set well below the isolate's limit for that reason.
Narrow the snapshot with backupTables, or take this backup off-platform with lunora backup create --bucket, which runs on a machine rather than in an isolate. Backing up more often does not help — every run is a full snapshot.
BAD_REQUEST
400 — Bad request
BAD_ROW
400 — Malformed import row
BAD_SUBSCRIPTION_ARGS
400 — Invalid subscription arguments
BATCH_LIMIT_EXCEEDED
400 — Batch limit exceeded
BROWSER_RUN_ERROR
502 — Browser Run API error
BROWSER_TIMEOUT
504 — Browser operation timed out
CDC_LOG_TRIMMED
409 — CDC log trimmed
CDC_PAYLOAD_COMPACTED
409 — CDC payloads compacted
CDC_TIMELINE_FORKED
409 — CDC timeline forked
CLIENT_CLOSED
400 — Client is closed
CODEGEN_DIAGNOSTIC
500 — Codegen diagnostic
CONFLICT
409 — Conflict
Another write changed this row while your mutation was running (optimistic concurrency conflict).
Re-read the row and retry the mutation with the fresh value. Lunora serializes a DO's mutations, so a persistent conflict usually means the handler conflicts with itself (e.g. a trigger or cascade touching the same row) — split that work rather than adding a retry loop.
COUNT_RLS_UNSUPPORTED
422 — count() is unsupported under an RLS policy
CRON_EXPR_INVALID
500 — Invalid cron expression
CRON_EXPR_NOT_STATIC
500 — Cron expression is not statically analyzable
CRON_JOB_NOT_FOUND
404 — Cron job not found
CRON_JOBS_NOT_CONFIGURED
400 — Cron jobs not configured
CRON_NAME_NOT_STATIC
500 — Cron job name is not statically analyzable
CRON_NON_STATIC_FN
500 — Cron function reference is not statically analyzable
CRON_NON_STATIC_VALUE
500 — Cron value is not statically analyzable
CRON_SCHEDULE_INVALID
500 — Invalid cron schedule
CRON_SCHEDULE_NOT_STATIC
500 — Cron schedule is not statically analyzable
CROSS_SHARD_RANK_UNSUPPORTED
400 — Cross-shard rank() is unsupported
CURRENCY_MISMATCH
400 — Currency mismatch
DISPATCH_IN_PROGRESS
409 — Dispatch already in progress
A dispatch with this idempotency id is still running on the shard. Retry it; once the first attempt settles the same id is served from the replay cache.
DISPATCH_UNAUTHENTICATED
403 — Dispatch caller not authenticated
The scheduler could not authenticate to the worker. Check that LUNORA_SCHEDULER_SECRET matches on both sides, or that LUNORA_ADMIN_TOKEN is set and current.
DUPLICATE_AGENT_CLASS
500 — Duplicate agent generated class name
DUPLICATE_AGENT_NAME
500 — Duplicate agent name
DUPLICATE_CRON_NAME
500 — Duplicate cron job name
DUPLICATE_MIGRATION_ID
500 — Duplicate migration id
DUPLICATE_QUEUE_BINDING
500 — Duplicate queue binding
DUPLICATE_QUEUE_NAME
500 — Duplicate queue name
DUPLICATE_WORKFLOW_CLASS
500 — Duplicate workflow generated class name
EMAIL_DOMAIN_BLOCKED
400 — Email domain not allowed
This address's domain is on the disposable/throwaway blocklist (or your configured deny-list).
Sign up with a permanent mailbox. To tune the policy, pass blockDisposable / allowDomains / denyDomains in the EmailGateConfig you hand assertEmailAllowed / classifyEmail / emailGateMiddleware (@lunora/auth/email-guard).
EMAIL_UNDELIVERABLE
400 — Email domain cannot receive mail
The address's domain publishes no MX (or fallback A/AAAA) records, so it can't receive mail.
Check for a typo in the domain. MX verification is opt-in (mx: true) and needs DNS — leave it off on the edge path if DNS is unavailable.
EXPIRED
404 — Session expired
EXPORT_SHARD_FAILED
502 — Export failed on one or more shards
One or more shards failed to export, so the snapshot would have been short. Nothing is written when this fires — a partial export must never be mistaken for a complete one.
The message names each failed shard key and its error. Re-run the export once those shards are reachable; a shard that fails repeatedly is usually over the per-request memory budget, which backupTables narrows.
EXPORT_TAP_NOT_CONFIGURED
400 — Export tap not configured
FORBIDDEN
403 — Forbidden
FORBIDDEN_FANOUT
403 — Fan-out forbidden
FORBIDDEN_ORIGIN
403 — Origin forbidden
FORBIDDEN_SHARD
403 — Shard access forbidden
FUNCTION_NOT_FOUND
404 — Function not found
FUNCTIONS_NOT_CONFIGURED
400 — Functions registry not configured
GLOBAL_NOT_CONFIGURED
400 — Global table import not configured
GLOBAL_SEARCH_SCORES_UNSUPPORTED
400 — collectWithScores() is unsupported on a global table
GLOBAL_TABLE_NOT_EDITABLE
400 — Global table is not editable
GLOBALS_NOT_CONFIGURED
400 — Global-table introspector not configured
HTTP_STREAM_BAD_CHUNK
502 — Malformed HTTP stream chunk
HTTP_STREAM_INTERRUPTED
502 — HTTP stream interrupted
HTTP_STREAM_MISSING_PARAM
400 — HTTP stream missing path parameter
HTTP_STREAM_NO_BODY
502 — HTTP stream response has no body
HTTP_STREAM_STATUS
502 — HTTP stream request failed
HTTP_STREAM_TRANSPORT
502 — HTTP stream transport error
ID_COLLISION
409 — Document id already belongs to another table
The imported row carries an _id that is already held by a DIFFERENT table in this shard. Ids are per-table, so inserting it would leave two tables claiming one id and make a later lookup resolve to whichever one it reached first.
This is reported per row rather than aborting the import: the remaining rows still apply. Re-mint the id on the source side, or import that table into a shard that does not already hold it.
IDENTITY_MISMATCH
409 — Session changed since the write was queued
A write queued offline was replayed on a session that now belongs to someone else (a sign-out, or another user's sign-in, while it waited), so the worker refused it rather than commit it as them.
@lunora/client re-resolves the session on this refusal and settles the write itself. If every replay is refused while the same user stays signed in, your worker's resolveIdentity returns a different userId than better-auth's user.id, or the RPC request carries no session cookie.
INVALID_INPUT
400 — Invalid input
INVALID_SCHEDULE_ID
400 — Invalid schedule id
JURISDICTION_MOVE
422 — Unacknowledged jurisdiction move
The schema declares .jurisdiction(...) and the project has DO-backed auth (.auth({ namespace })). Pinning the auth object to the jurisdiction resolves it to a new, empty one: every user, account and session stays in the unpinned object until copied across. Voice sessions are pinned without an acknowledgement: they store nothing, and their transcripts are rows in the already-pinned shards.
Acknowledge with .jurisdiction("eu", { pinAuth: true }) and deploy, then copy the auth tables across with the __lunora_admin__:copyAuthToJurisdiction admin op and, once the counts check out, purge the unpinned copy with __lunora_admin__:purgeUnpinnedAuth (see Pinning auth and voice). For D1-mode auth the acknowledgement changes nothing.
KV_NOT_CONFIGURED
400 — KV introspector not configured
LOCAL_DEPENDENCY_MISSING
500 — Required local tool not found
The command Lunora tried to run is not on your PATH, so nothing ran.
Install it (or put it on PATH) and retry — wrangler ships as a dependency of a Lunora app, so pnpm install usually fixes that one; git and docker are installed separately.
LUNORA_RUNTIME_UNAVAILABLE
500 — Lunora runtime unavailable
MASK_UNSUPPORTED
422 — Aggregation over a masked column is unsupported
MCP_STEP_UP_MISCONFIGURED
500 — MCP step-up misconfigured
Give createAuthedMcpFetchHandler's stepUp a non-empty scope (such as lunora:write) and a challenge (createInsufficientScopeError from @lunora/auth/plugins), or leave stepUp out.
METHOD_NOT_ALLOWED
405 — Method not allowed
MIGRATION_DRIFT
409 — Migration drift
An applied SQL migration is never re-run, so the runner stopped before applying anything.
- Its text changed after it ran: revert the edit and put the change in a new migration. If the edit is inert (a comment, whitespace, line endings), record the new hash with the
UPDATEthe message prints. - Tracking rows predate versions and match no migration: set each row's
versionwith theUPDATEthe message prints, so the runner can tell what already ran.
MIGRATION_ID_NOT_STATIC
500 — Migration id is not statically analyzable
MIGRATION_ID_REQUIRED
400 — Migration id required
MIGRATION_NOT_FOUND
404 — Data migration not found
NAMESPACE_COLLISION
500 — Function namespace collision
NOT_FOUND
404 — Not found
NOT_IMPLEMENTED
501 — Not implemented
NOT_UNIQUE
400 — Query matched more than one document
.unique() matched more than one document — it expects the query to identify at most one row.
- If several matches are legitimate, use
.first()(take one) or.collect()(take all) instead. - Otherwise tighten the query (e.g. filter on a unique/indexed field) so it can only match one row.
OFFLINE_IDENTITY_CHANGED
409 — Offline identity changed
OUT_OF_ORDER
409 — Out-of-order mutation
PAYLOAD_TOO_LARGE
413 — Payload too large
PITR_UNAVAILABLE
409 — Point-in-time recovery unavailable
PROVIDER_ERROR
502 — Payment provider error
R2_SQL_ERROR
502 — R2 SQL API error
RAG_DIMENSION_MISMATCH
409 — Embedding dimension mismatch
A stored vector and the query embedding have different widths, so they cannot be compared.
This is what changing a RAG index's embeddingModel (or a provider's dimensions option) without reindexing looks like. Either put the previous model back, or reindex the namespace under the new one — bump embeddingModelVersion so the index rebuilds instead of mixing widths.
RATE_LIMITED
429 — Rate limited
RELATION_PREDICATE_UNSUPPORTED
422 — Relation predicate is unsupported in a write policy
RELAY_CANNOT_SEED
500 — Relay cannot seed
RELAY_MISCONFIGURED
500 — Relay misconfigured
RELAY_SEED_FAILED
502 — Relay seed failed
RELAY_SHAPE_UNROUTABLE
500 — Relay shape unroutable
REPLICA_NOT_READY
421 — Replica not caught up
REPLICA_READ_ONLY
421 — Replica is read-only
RLS_REQUIRED
403 — RLS policy required
This table is secure-by-default: it has no .public() marker and no RLS policy resolved for the caller, so the read fails closed.
Add a read policy with .rls(...), or mark the table .public() if it is intentionally world-readable.
RUN_KIND_FORBIDDEN
500 — Function kind may not be composed from a query
SCHEDULER_NOT_CONFIGURED
400 — Scheduler not configured
SCHEMA_SNAPSHOT_PARSE
500 — Schema snapshot parse error
SEARCH_INDEX_BUILDING
503 — Search index is still building
SERVICE_UNAVAILABLE
503 — Service unavailable
SHAPE_CROSS_SHARD_JOIN
400 — Shape cross-shard join is unsupported
SHAPE_GLOBAL_TOO_LARGE
413 — Global shape too large
SHAPE_MEMORY_TABLE
400 — Shape over a memory table is unsupported
SHAPE_NOT_FOUND
404 — Shape not found
SHAPE_REQUIRES_CDC
409 — Shape requires change-data-capture
SHARD_ERROR
503 — Shard error
SHARD_HTTP_ERROR
502 — Shard HTTP error
SHARD_TIMEOUT
504 — Shard timeout
SHARD_UNAVAILABLE
503 — Shard unavailable
SOCKET_TAG_BUDGET_EXCEEDED
400 — Socket tag budget exceeded
STORAGE_CHECKSUM_MISMATCH
400 — Storage checksum mismatch
The upload body did not match the declared expectedSize or expectedSha256, so nothing was written — this check fails closed.
Re-read the bytes from the source export and retry the transfer. A persistent mismatch means the source blob is corrupt or truncated; fix the export rather than bypassing the check.
STORAGE_DELETE_NOT_CONFIGURED
400 — Storage delete not configured
STORAGE_DOWNLOAD_NOT_CONFIGURED
400 — Storage download not configured
GET /_lunora/admin/storage/object needs a storageDownload function on the worker. The generated app worker wires it up; a hand-written createWorker({ ... }) has to pass (key, opts) => pick(opts?.bucket).download(key) — forwarding opts.bucket to the right bucket, and wrapping rather than passing createStorage(...).download itself, whose second parameter is a byte range.
Without it a bucket-backed lunora backup restore --bucket cannot read the snapshot. The object is still readable out of band with wrangler r2 object get.
STORAGE_NOT_CONFIGURED
400 — Storage not configured
STORAGE_OBJECT_NOT_FOUND
404 — Storage object not found
STORAGE_UPLOAD_NOT_CONFIGURED
400 — Storage upload not configured
STORAGE_URL_NOT_CONFIGURED
400 — Storage signed URL not configured
STREAM_BACKPRESSURE
429 — Stream backpressure
STREAM_DISCONNECTED
503 — Stream disconnected
STREAM_ID_IN_USE
409 — Stream id already in use
STREAM_INTERRUPTED
503 — Durable stream interrupted
STREAM_QUEUE_OVERFLOW
429 — Stream queue overflow
STREAM_TOO_LONG
507 — Durable stream exceeded its chunk ceiling
SUBSCRIPTION_PERSIST_FAILED
500 — Subscription persist failed
TOKEN_EXPIRED
401 — Authentication token expired
The caller's credential verified but its expiry has passed. The worker refuses an HTTP call with this code before it runs; a shard drops a socket with close code 4001 and the same code.
It is a refusal of the credential, not of the call: @lunora/client holds a queued write, fires onTokenExpired, and re-sends it after setAuthToken. The held write survives the refresh only if you pass the same subject (the user id) with the new token, setAuthToken(freshToken, user.id); without one a refresh reads as a user switch and the write is rejected OFFLINE_IDENTITY_CHANGED. Under a cookie session there is no token to replace: renew the cookie and the write is re-sent on a backoff. The worker can only tell expired from anonymous if your resolveIdentity returns the identity with its past exp (or expiresAtMs), or throws a LunoraError with this code. Returning null runs the call anonymous.
TOO_MANY_REQUESTS
429 — Too many requests
TOO_MANY_STREAMS
429 — Too many streams
TOO_MANY_SUBSCRIPTIONS
429 — Too many subscriptions
TOO_MANY_WHISPER_TOPICS
429 — Too many whisper topics
Reuse a stable set of whisper topic names, or reconnect — the per-socket verdict memo is in-memory, so a new connection starts empty. Leaving a topic does not free its entry.
TRANSACTION_LIMIT_EXCEEDED
413 — Transaction limit exceeded
A single mutation may only read and write a bounded amount before it is stopped.
Narrow the read with an index (.withIndex(...)) instead of scanning the table, or split the write across several mutations — for a large backfill use defineMigration + lunora migrate up, which batches and checkpoints for you.
The ceilings are deliberately conservative — they exist to stop one request taking down the whole shard. A deployment that genuinely needs bigger transactions can raise them by overriding the transactionLimits() seam on its generated shard class.
UNAUTHENTICATED
401 — Unauthenticated
UNAUTHORIZED
401 — Unauthorized
UNKNOWN_ADMIN_OP
404 — Unknown admin operation
UNKNOWN_COLUMN
404 — Unknown column
UNKNOWN_MUTATION_FN
404 — Unknown mutation function
UNKNOWN_TABLE
404 — Unknown table
UNPROCESSABLE
422 — Unprocessable
VALIDATION_ERROR
400 — Validation failed
VECTOR_QUERY_UNSUPPORTED
400 — Vector index querying not enabled
VECTORS_NOT_CONFIGURED
400 — Vector index introspector not configured
WEBHOOK_EVENT_ID_MISSING
400 — Webhook event id missing
WEBHOOK_SIGNATURE_INVALID
400 — Webhook signature invalid
WEBHOOK_TIMESTAMP_INVALID
400 — Webhook timestamp outside tolerance
WIRE_DECODE_FAILED
502 — Could not decode a server frame
WIRE_ENCODE_FAILED
500 — Could not encode a return value
WORKFLOWS_NOT_CONFIGURED
501 — Workflows not configured
WORKFLOWS_REST_ERROR
502 — Cloudflare Workflows REST API error