Skip to content
DocsDocumentation

Errors & warnings reference

Every well-known Lunora error code — its HTTP status, what it means, and what to do about it.

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:

SituationWhat to do
No Lunora schema foundCreate lunora/schema.ts with a defineSchema default export, or run lunora init
defineSchema() needs an inline object literalPass the object literal directly; codegen reads it statically and cannot follow a variable or spread
Table name is reservedThe name collides with a built-in ctx.db member; rename the table
Duplicate table nameTwo tables resolve to the same name, usually a base table and a .extend(...) both defining it
Invalid .jurisdiction(...) valueOnly the string literals "eu", "us", or "fedramp" are accepted
unique must be a literalWrite { unique: true }, not a computed value
Binding not exported by your worker entryAdd 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 145 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_ERROR

502 — Analytics Engine SQL API error

AUTH_AUDIT_NOT_CONFIGURED

400 — Auth audit reader not configured

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_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_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_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_BINDING

500 — Duplicate agent binding

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.

INVALID_INPUT

400 — Invalid input

INVALID_SCHEDULE_ID

400 — Invalid schedule id

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

METHOD_NOT_ALLOWED

405 — Method not allowed

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

TOO_MANY_REQUESTS

429 — Too many requests

TOO_MANY_STREAMS

429 — Too many streams

TOO_MANY_SUBSCRIPTIONS

429 — Too many subscriptions

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

On this page

Redacted codesBuild-time errorsThrowing your ownError codesADMIN_FORBIDDENADMIN_TOKEN_NOT_CONFIGUREDANALYTICS_SQL_ERRORAUTH_AUDIT_NOT_CONFIGUREDAUTH_MIGRATOR_UNSUPPORTEDAUTH_NOT_CONFIGUREDAUTH_OP_NOT_SUPPORTEDBACKUP_NOT_CONFIGUREDBACKUP_RETENTION_NOT_CONFIGUREDBACKUP_TOO_LARGEBAD_REQUESTBAD_ROWBAD_SUBSCRIPTION_ARGSBATCH_LIMIT_EXCEEDEDBROWSER_TIMEOUTCDC_LOG_TRIMMEDCDC_PAYLOAD_COMPACTEDCDC_TIMELINE_FORKEDCLIENT_CLOSEDCODEGEN_DIAGNOSTICCONFLICTCOUNT_RLS_UNSUPPORTEDCRON_EXPR_INVALIDCRON_EXPR_NOT_STATICCRON_JOB_NOT_FOUNDCRON_JOBS_NOT_CONFIGUREDCRON_NAME_NOT_STATICCRON_NON_STATIC_FNCRON_NON_STATIC_VALUECRON_SCHEDULE_INVALIDCRON_SCHEDULE_NOT_STATICCROSS_SHARD_RANK_UNSUPPORTEDCURRENCY_MISMATCHDISPATCH_UNAUTHENTICATEDDUPLICATE_AGENT_BINDINGDUPLICATE_AGENT_CLASSDUPLICATE_AGENT_NAMEDUPLICATE_CRON_NAMEDUPLICATE_MIGRATION_IDDUPLICATE_QUEUE_BINDINGDUPLICATE_QUEUE_NAMEDUPLICATE_WORKFLOW_CLASSEMAIL_DOMAIN_BLOCKEDEMAIL_UNDELIVERABLEEXPIREDEXPORT_SHARD_FAILEDEXPORT_TAP_NOT_CONFIGUREDFORBIDDENFORBIDDEN_FANOUTFORBIDDEN_ORIGINFORBIDDEN_SHARDFUNCTION_NOT_FOUNDFUNCTIONS_NOT_CONFIGUREDGLOBAL_NOT_CONFIGUREDGLOBAL_SEARCH_SCORES_UNSUPPORTEDGLOBAL_TABLE_NOT_EDITABLEGLOBALS_NOT_CONFIGUREDHTTP_STREAM_BAD_CHUNKHTTP_STREAM_INTERRUPTEDHTTP_STREAM_MISSING_PARAMHTTP_STREAM_NO_BODYHTTP_STREAM_STATUSHTTP_STREAM_TRANSPORTID_COLLISIONINVALID_INPUTINVALID_SCHEDULE_IDKV_NOT_CONFIGUREDLOCAL_DEPENDENCY_MISSINGLUNORA_RUNTIME_UNAVAILABLEMASK_UNSUPPORTEDMETHOD_NOT_ALLOWEDMIGRATION_ID_NOT_STATICMIGRATION_ID_REQUIREDMIGRATION_NOT_FOUNDNAMESPACE_COLLISIONNOT_FOUNDNOT_IMPLEMENTEDNOT_UNIQUEOFFLINE_IDENTITY_CHANGEDOUT_OF_ORDERPAYLOAD_TOO_LARGEPITR_UNAVAILABLEPROVIDER_ERRORR2_SQL_ERRORRAG_DIMENSION_MISMATCHRATE_LIMITEDRELATION_PREDICATE_UNSUPPORTEDRELAY_CANNOT_SEEDRELAY_MISCONFIGUREDRELAY_SEED_FAILEDRELAY_SHAPE_UNROUTABLEREPLICA_NOT_READYREPLICA_READ_ONLYRLS_REQUIREDRUN_KIND_FORBIDDENSCHEDULER_NOT_CONFIGUREDSCHEMA_SNAPSHOT_PARSESEARCH_INDEX_BUILDINGSERVICE_UNAVAILABLESHAPE_CROSS_SHARD_JOINSHAPE_GLOBAL_TOO_LARGESHAPE_MEMORY_TABLESHAPE_NOT_FOUNDSHAPE_REQUIRES_CDCSHARD_ERRORSHARD_HTTP_ERRORSHARD_TIMEOUTSHARD_UNAVAILABLESOCKET_TAG_BUDGET_EXCEEDEDSTORAGE_CHECKSUM_MISMATCHSTORAGE_DELETE_NOT_CONFIGUREDSTORAGE_DOWNLOAD_NOT_CONFIGUREDSTORAGE_NOT_CONFIGUREDSTORAGE_OBJECT_NOT_FOUNDSTORAGE_UPLOAD_NOT_CONFIGUREDSTORAGE_URL_NOT_CONFIGUREDSTREAM_BACKPRESSURESTREAM_DISCONNECTEDSTREAM_ID_IN_USESTREAM_INTERRUPTEDSTREAM_QUEUE_OVERFLOWSTREAM_TOO_LONGSUBSCRIPTION_PERSIST_FAILEDTOKEN_EXPIREDTOO_MANY_REQUESTSTOO_MANY_STREAMSTOO_MANY_SUBSCRIPTIONSTRANSACTION_LIMIT_EXCEEDEDUNAUTHENTICATEDUNAUTHORIZEDUNKNOWN_ADMIN_OPUNKNOWN_COLUMNUNKNOWN_MUTATION_FNUNKNOWN_TABLEUNPROCESSABLEVALIDATION_ERRORVECTOR_QUERY_UNSUPPORTEDVECTORS_NOT_CONFIGUREDWEBHOOK_EVENT_ID_MISSINGWEBHOOK_SIGNATURE_INVALIDWEBHOOK_TIMESTAMP_INVALIDWIRE_DECODE_FAILEDWIRE_ENCODE_FAILEDWORKFLOWS_NOT_CONFIGUREDWORKFLOWS_REST_ERROR