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 211 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_ROOT_REQUIRED

403 — Root admin token required

ADMIN_TOKEN_EXISTS

409 — Admin token name already in use

ADMIN_TOKEN_NOT_CONFIGURED

400 — Admin token not configured

AI_CHAT_FAILED

500 — Assistant turn failed

AI_UNKNOWN_IMAGE_MODEL

400 — Unknown image model

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 — createMigrationAuth({ ...options, database: env.DB }) (from @lunora/auth/migrate) — 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

BROWSER_TOO_MANY_REDIRECTS

502 — Too many redirects

CASCADE_LIMIT

400 — Cascade limit exceeded

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_JOBS_NOT_CONFIGURED

400 — Cron jobs not configured

CRON_JOB_IN_FLIGHT

409 — Cron job is already running

CRON_JOB_NOT_FOUND

404 — Cron job not found

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

DELIVERY_FENCED

409 — Delivery authorization changed

The operation may have committed. Retry with the same transport key; a committed result from an older authority epoch returns DELIVERY_RECEIPT_FENCED. Do not automatically retry with a new transport key.

DELIVERY_RECEIPT_FENCED

409 — Committed receipt authorization changed

The operation committed, but its stored result belongs to an older authority epoch. Do not automatically retry with a new transport ID. Recover through the application's durable operation key under current authorization.

DEPLOYMENT_PAUSED

503 — Deployment is paused

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_ROOM_NAME

500 — Duplicate room name

DUPLICATE_SOCKET_PATH

500 — Duplicate socket route path

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

FORBIDDEN_TOPIC

403 — Live topic publish forbidden

FUNCTIONS_NOT_CONFIGURED

400 — Functions registry not configured

FUNCTION_NOT_FOUND

404 — Function not found

GLOBALS_NOT_CONFIGURED

400 — Global-table introspector not configured

GLOBAL_DRY_RUN_UNSUPPORTED

400 — Global table dry run not supported

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

GLOBAL_TRANSACTION_UNSUPPORTED

500 — Global transaction unsupported by driver

A .global() write commits its row and companion rows in one transaction. Pass a postgres.js client that has begin(), or a mysql2 pool (createPool) rather than a single connection.

HTTP_STREAM_BAD_CHUNK

502 — Malformed HTTP stream chunk

HTTP_STREAM_CALLBACK

500 — HTTP stream callback threw

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

The fetch implementation resolved with a response that exposes neither a body stream nor a text() read — a broken fetch substitute, not a runtime limitation. A response with no body stream that can still be read as text (React Native's whatwg-fetch polyfill) is buffered through text() instead and never raises this code. Check the fetch override passed to the client or the httpStream call.

HTTP_STREAM_STATUS

502 — HTTP stream request failed

HTTP_STREAM_TRANSPORT

502 — HTTP stream transport error

IDENTITY_CHANGED

409 — Socket identity changed

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.

IDENTITY_STALE

409 — Socket identity stale

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.

Only shard tables raise it. A .global() table names its table on every by-id call, so the same id in two global tables is two separate rows and the import accepts it.

INDEX_FIELD_NESTED_PATH

500 — Index field is a nested path

Indexes cover top-level columns only. Denormalise the nested value into its own column, index that, and keep it in sync with a table .triggers() beforeInsert/beforeUpdate.

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

LIVE_CONNECTION_LIMIT

503 — Live connection limit reached

LIVE_CONNECTION_NOT_FOUND

404 — Live connection not found

LIVE_CONTROL_SUPERSEDED

409 — A later live control call already applied

LIVE_STREAMS_UNSUPPORTED

501 — Live streams are not served by this worker

LIVE_STREAM_DUPLICATE

500 — Duplicate live stream id or export name

LIVE_STREAM_NOT_EXPORTED

500 — Live stream is not a named export

LIVE_STREAM_NOT_LITERAL

500 — Live stream declaration is not a literal

LIVE_TOPIC_INVALID

500 — Live topic name or key field is not allowed

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 UPDATE the message prints.
  • Tracking rows predate versions and match no migration: set each row's version with the UPDATE the 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

OFFLINE_NOT_QUEUED

503 — Offline, write not queued

A mutation sent with onlineOnly: true found its connection down or reconnecting (the shard had connected before), or, on a shard that has never connected, its request failed on the wire. It was refused instead of queued. Nothing was persisted or replayed; issue the call again once the connection is back. See keeping a write off the queue.

OUT_OF_ORDER

409 — Out-of-order mutation

PAUSE_INSTANCES_FAILED

502 — Pause did not reach every scheduler instance

PAUSE_NOT_CONFIGURED

400 — Deployment pause not configured

PAYLOAD_TOO_LARGE

413 — Payload too large

PITR_UNAVAILABLE

409 — Point-in-time recovery unavailable

PRIVACY_COVERAGE

500 — Privacy coverage check failed

PRIVACY_REQUEST_OPEN

409 — A privacy request is already open

PRIVACY_SESSION_NOT_FRESH

403 — Recent sign-in required

PROCEDURE_META_UNLOADED

500 — Procedure meta is not loaded

PROVIDER_ERROR

502 — Payment provider error

PUBLISHED_FUNCTION_UNSUPPORTED

500 — Published function cannot be registered from its declaration

PUBLISHED_FUNCTION_VISIBILITY_UNKNOWN

500 — Published function does not declare its visibility

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.

ROOM_NAME_NOT_LITERAL

500 — Room name is not a string literal

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_RELATION_PREDICATE

400 — Shape filtered by a relation is unsupported

SHAPE_REQUIRES_CDC

409 — Shape requires change-data-capture

SHARD_DRY_RUN_UNSUPPORTED

501 — Shard cannot dry-run an import

SHARD_ERROR

503 — Shard error

SHARD_HTTP_ERROR

502 — Shard HTTP error

SHARD_TIMEOUT

504 — Shard timeout

SHARD_UNAVAILABLE

503 — Shard unavailable

SOCKET_AUTH_NOT_LITERAL

500 — Socket route auth is not a literal boolean

SOCKET_PATH_INVALID

500 — Socket route path is not a plain URL path

SOCKET_PATH_NOT_LITERAL

500 — Socket route path is not a string literal

SOCKET_TAG_BUDGET_EXCEEDED

400 — Socket tag budget exceeded

STALE_CREDENTIAL

409 — Stale credential

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_IDLE

504 — Stream went idle

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_FUNCTION_REQUIRED

400 — Subscription needs a function path

SUBSCRIPTION_PERSIST_FAILED

500 — Subscription persist failed

SUBSCRIPTION_RESULT_TOO_LARGE

413 — Subscription result too large

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_LIVE_TOPICS

429 — Too many live topics

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.

A transaction that reaches 80% of a ceiling without crossing it logs one warn line (event lunora.transaction.near_limit) naming the limit, so a function that only gets close on development data shows up before production data pushes it over. A single read or write that jumps past the ceiling from below 80% fails without that warning.

UNAUTHENTICATED

401 — Unauthenticated

UNAUTHORIZED

401 — Unauthorized

UNIQUE_INDEX_DUPLICATES

503 — Unique index blocked by duplicate rows

The index was not built, so requests to its store are refused until it is.

Make one row keep each key. For a shard table, write a defineMigration in lunora/migrations.ts that deletes or merges the duplicates, deploy it, then run lunora migrate up <id> with that migration's id; the lunora-migration-helper skill covers the pattern. A .global() table lives in your SQL database, not a shard, so defineMigration does not reach it: delete or merge the duplicate rows directly in the database. Either way the index build is retried on its own, so no second deploy is needed.

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

VECTORS_NOT_CONFIGURED

400 — Vector index introspector not configured

VECTOR_QUERY_UNSUPPORTED

400 — Vector index querying not enabled

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

WORKFLOW_EVENT_DUPLICATE

500 — Duplicate workflow event type

WORKFLOW_EVENT_NOT_LITERAL

500 — Workflow event type is not a string literal

On this page

Redacted codesBuild-time errorsThrowing your ownError codesADMIN_FORBIDDENADMIN_ROOT_REQUIREDADMIN_TOKEN_EXISTSADMIN_TOKEN_NOT_CONFIGUREDAI_CHAT_FAILEDAI_UNKNOWN_IMAGE_MODELANALYTICS_SQL_QUERY_ERRORAUTH_AUDIT_NOT_CONFIGUREDAUTH_CIMD_FETCH_NOT_PUBLICAUTH_MCP_RESOURCE_AMBIGUOUSAUTH_MCP_RESOURCE_INVALIDAUTH_MIGRATOR_UNSUPPORTEDAUTH_MOVE_CONFLICTAUTH_MOVE_FAILEDAUTH_MOVE_INCOMPLETEAUTH_MOVE_NOT_CONFIGUREDAUTH_MOVE_SOURCE_CHANGEDAUTH_MOVE_SOURCE_PURGEDAUTH_MOVE_TARGET_NOT_EMPTYAUTH_NOT_CONFIGUREDAUTH_OP_NOT_SUPPORTEDBACKUP_NOT_CONFIGUREDBACKUP_RETENTION_NOT_CONFIGUREDBACKUP_TOO_LARGEBAD_REQUESTBAD_ROWBAD_SUBSCRIPTION_ARGSBATCH_LIMIT_EXCEEDEDBROWSER_RUN_ERRORBROWSER_TIMEOUTBROWSER_TOO_MANY_REDIRECTSCASCADE_LIMITCDC_LOG_TRIMMEDCDC_PAYLOAD_COMPACTEDCDC_TIMELINE_FORKEDCLIENT_CLOSEDCODEGEN_DIAGNOSTICCONFLICTCOUNT_RLS_UNSUPPORTEDCRON_EXPR_INVALIDCRON_EXPR_NOT_STATICCRON_JOBS_NOT_CONFIGUREDCRON_JOB_IN_FLIGHTCRON_JOB_NOT_FOUNDCRON_NAME_NOT_STATICCRON_NON_STATIC_FNCRON_NON_STATIC_VALUECRON_SCHEDULE_INVALIDCRON_SCHEDULE_NOT_STATICCROSS_SHARD_RANK_UNSUPPORTEDCURRENCY_MISMATCHDELIVERY_FENCEDDELIVERY_RECEIPT_FENCEDDEPLOYMENT_PAUSEDDISPATCH_IN_PROGRESSDISPATCH_UNAUTHENTICATEDDUPLICATE_AGENT_CLASSDUPLICATE_AGENT_NAMEDUPLICATE_CRON_NAMEDUPLICATE_MIGRATION_IDDUPLICATE_QUEUE_BINDINGDUPLICATE_QUEUE_NAMEDUPLICATE_ROOM_NAMEDUPLICATE_SOCKET_PATHDUPLICATE_WORKFLOW_CLASSEMAIL_DOMAIN_BLOCKEDEMAIL_UNDELIVERABLEEXPIREDEXPORT_SHARD_FAILEDEXPORT_TAP_NOT_CONFIGUREDFORBIDDENFORBIDDEN_FANOUTFORBIDDEN_ORIGINFORBIDDEN_SHARDFORBIDDEN_TOPICFUNCTIONS_NOT_CONFIGUREDFUNCTION_NOT_FOUNDGLOBALS_NOT_CONFIGUREDGLOBAL_DRY_RUN_UNSUPPORTEDGLOBAL_NOT_CONFIGUREDGLOBAL_SEARCH_SCORES_UNSUPPORTEDGLOBAL_TABLE_NOT_EDITABLEGLOBAL_TRANSACTION_UNSUPPORTEDHTTP_STREAM_BAD_CHUNKHTTP_STREAM_CALLBACKHTTP_STREAM_INTERRUPTEDHTTP_STREAM_MISSING_PARAMHTTP_STREAM_NO_BODYHTTP_STREAM_STATUSHTTP_STREAM_TRANSPORTIDENTITY_CHANGEDIDENTITY_MISMATCHIDENTITY_STALEID_COLLISIONINDEX_FIELD_NESTED_PATHINVALID_INPUTINVALID_SCHEDULE_IDJURISDICTION_MOVEKV_NOT_CONFIGUREDLIVE_CONNECTION_LIMITLIVE_CONNECTION_NOT_FOUNDLIVE_CONTROL_SUPERSEDEDLIVE_STREAMS_UNSUPPORTEDLIVE_STREAM_DUPLICATELIVE_STREAM_NOT_EXPORTEDLIVE_STREAM_NOT_LITERALLIVE_TOPIC_INVALIDLOCAL_DEPENDENCY_MISSINGLUNORA_RUNTIME_UNAVAILABLEMASK_UNSUPPORTEDMCP_STEP_UP_MISCONFIGUREDMETHOD_NOT_ALLOWEDMIGRATION_DRIFTMIGRATION_ID_NOT_STATICMIGRATION_ID_REQUIREDMIGRATION_NOT_FOUNDNAMESPACE_COLLISIONNOT_FOUNDNOT_IMPLEMENTEDNOT_UNIQUEOFFLINE_IDENTITY_CHANGEDOFFLINE_NOT_QUEUEDOUT_OF_ORDERPAUSE_INSTANCES_FAILEDPAUSE_NOT_CONFIGUREDPAYLOAD_TOO_LARGEPITR_UNAVAILABLEPRIVACY_COVERAGEPRIVACY_REQUEST_OPENPRIVACY_SESSION_NOT_FRESHPROCEDURE_META_UNLOADEDPROVIDER_ERRORPUBLISHED_FUNCTION_UNSUPPORTEDPUBLISHED_FUNCTION_VISIBILITY_UNKNOWNR2_SQL_ERRORRAG_DIMENSION_MISMATCHRATE_LIMITEDRELATION_PREDICATE_UNSUPPORTEDRELAY_CANNOT_SEEDRELAY_MISCONFIGUREDRELAY_SEED_FAILEDRELAY_SHAPE_UNROUTABLEREPLICA_NOT_READYREPLICA_READ_ONLYRLS_REQUIREDROOM_NAME_NOT_LITERALRUN_KIND_FORBIDDENSCHEDULER_NOT_CONFIGUREDSCHEMA_SNAPSHOT_PARSESEARCH_INDEX_BUILDINGSERVICE_UNAVAILABLESHAPE_CROSS_SHARD_JOINSHAPE_GLOBAL_TOO_LARGESHAPE_MEMORY_TABLESHAPE_NOT_FOUNDSHAPE_RELATION_PREDICATESHAPE_REQUIRES_CDCSHARD_DRY_RUN_UNSUPPORTEDSHARD_ERRORSHARD_HTTP_ERRORSHARD_TIMEOUTSHARD_UNAVAILABLESOCKET_AUTH_NOT_LITERALSOCKET_PATH_INVALIDSOCKET_PATH_NOT_LITERALSOCKET_TAG_BUDGET_EXCEEDEDSTALE_CREDENTIALSTORAGE_CHECKSUM_MISMATCHSTORAGE_DELETE_NOT_CONFIGUREDSTORAGE_DOWNLOAD_NOT_CONFIGUREDSTORAGE_NOT_CONFIGUREDSTORAGE_OBJECT_NOT_FOUNDSTORAGE_UPLOAD_NOT_CONFIGUREDSTORAGE_URL_NOT_CONFIGUREDSTREAM_BACKPRESSURESTREAM_DISCONNECTEDSTREAM_IDLESTREAM_ID_IN_USESTREAM_INTERRUPTEDSTREAM_QUEUE_OVERFLOWSTREAM_TOO_LONGSUBSCRIPTION_FUNCTION_REQUIREDSUBSCRIPTION_PERSIST_FAILEDSUBSCRIPTION_RESULT_TOO_LARGETOKEN_EXPIREDTOO_MANY_LIVE_TOPICSTOO_MANY_REQUESTSTOO_MANY_STREAMSTOO_MANY_SUBSCRIPTIONSTOO_MANY_WHISPER_TOPICSTRANSACTION_LIMIT_EXCEEDEDUNAUTHENTICATEDUNAUTHORIZEDUNIQUE_INDEX_DUPLICATESUNKNOWN_ADMIN_OPUNKNOWN_COLUMNUNKNOWN_MUTATION_FNUNKNOWN_TABLEUNPROCESSABLEVALIDATION_ERRORVECTORS_NOT_CONFIGUREDVECTOR_QUERY_UNSUPPORTEDWEBHOOK_EVENT_ID_MISSINGWEBHOOK_SIGNATURE_INVALIDWEBHOOK_TIMESTAMP_INVALIDWIRE_DECODE_FAILEDWIRE_ENCODE_FAILEDWORKFLOWS_NOT_CONFIGUREDWORKFLOWS_REST_ERRORWORKFLOW_EVENT_DUPLICATEWORKFLOW_EVENT_NOT_LITERAL