Error codes
Every error locel can raise, where it comes from, and what to do about it.
Every refusal in locel has a code. Codes are part of the public API: a code never changes meaning, and a code is never reused for a different condition.
Status: Building. Every code here is declared, and the declaration, boot,
write-refusal and attach codes are raised from @locel/core, @locel/server
and @locel/client. The configuration codes belong to packages that do not
exist yet.
Shape
import { errors } from '@locel/core'
interface LocelError extends Error {
code: string
status?: number // HTTP status, when raised across the wire
help?: string // what to do about it
collection?: string
key?: string
field?: string
transaction?: string
}help is populated for every code in this page. An error a developer has to
search for is an error that was half-reported.
How codes are named
The grammar is the AdonisJS framework's, so a locel code reads like the ones already in your stack trace. New codes follow one of these, in this order of preference:
| Form | Use | Examples |
|---|---|---|
E_MISSING_<thing> | Something required is absent | E_MISSING_PARTITION_COLUMN, E_MISSING_SUBSET_INDEX |
E_UNKNOWN_<thing> | Named, but not recognised | E_UNKNOWN_COLLECTION, E_UNKNOWN_IDENTITY |
E_INVALID_<thing> / E_UNSUPPORTED_<thing> | Present but unusable | E_INVALID_VALUE, E_UNSUPPORTED_DIALECT |
E_<subject>_NOT_<state> | A subject is not in a required state | E_ROW_NOT_FOUND, E_FIELD_NOT_WRITABLE |
E_<subject>_<state> | A subject is in a bad state | E_ROW_DELETED, E_STORE_TORN |
E_<subject>_ALREADY_<state> | Done twice | E_COLLECTION_ALREADY_DEFINED |
E_CANNOT_<verb>_<noun> | An action is structurally impossible | E_CANNOT_SEAL_PARTITION_COLUMN |
E_<subject>_MISMATCH | Two things that must agree do not | E_SCHEMA_MISMATCH, E_FINGERPRINT_MISMATCH |
A subject goes in front when the bare phrase would be ambiguous outside locel —
E_ROW_OUTSIDE_PARTITION rather than E_OUTSIDE_PARTITION, the same reason the
framework says E_OAUTH_STATE_MISMATCH rather than E_STATE_MISMATCH.
Declaration errors
Raised by defineCollection and defineSchema, at module evaluation — so they
fail the process at startup rather than on the first write.
| Code | Condition | What to do |
|---|---|---|
E_RESERVED_FIELD_NAME | A field name starts with $ | Rename it. $ is reserved for magic columns. |
E_UNKNOWN_PRIMARY_KEY_FIELD | primaryKey names a field that does not exist | |
E_INVALID_PRIMARY_KEY_TYPE | primaryKey names a field that is not field.string() or field.number() | A row is addressed by its key, and only those two address anything. |
E_UNKNOWN_WRITABLE_FIELD | writable names a field that does not exist | Usually a rename that missed the allowlist. |
E_SERVER_OWNED_FIELD_NOT_WRITABLE | A serverOwned() field appears in writable | Remove it from writable. The two are contradictory. |
E_CANNOT_RESOLVE_SEALED_FIELD | A resolve entry for an encrypted() field | The server cannot read the plaintext. Sealed fields are always server-wins. |
E_UNKNOWN_INDEX_FIELD | An index names a field that does not exist | |
E_CANNOT_INDEX_SEALED_FIELD | An index names an encrypted() field | The server holds an opaque envelope, so it cannot index, order or filter on it. |
E_COLLECTION_ALREADY_DEFINED | Two collections in one schema share a name | The wire name must be unique. |
Boot errors
Raised by createAuthority, before the server accepts a request.
| Code | Condition | What to do |
|---|---|---|
E_MISSING_PARTITION_COLUMN | A collection does not carry every partition column | Add the column, or leave the collection out of the schema. |
E_CANNOT_SEAL_PARTITION_COLUMN | A partition column is encrypted() | The server must be able to read it to enforce the boundary. |
E_STORE_CANNOT_QUERY | A store cannot serve an operation the configuration needs — most often query for subset mode | Use a store that implements it. Never returns an empty result instead: an empty subset and a broken one look identical to a client. |
E_UNSUPPORTED_DIALECT | A knex or kysely store on a dialect the adapter has no profile for | Raised at boot. The profile decides how sequence number assignment is serialised, and guessing would produce duplicate sequence numbers under load rather than an error. Open an issue naming the dialect. |
Write refusals
Returned from push and rejected on tx.confirmed. Each rejects the whole
transaction; the client rolls it back and removes it from the outbox.
| Code | Status | Condition | What to do |
|---|---|---|---|
E_UNKNOWN_COLLECTION | 400 | The collection is not in the server's schema | Almost always a client running ahead of a deploy. Deploy the server first. |
E_ROW_OUTSIDE_PARTITION | 403 | The row's partition columns do not match the session's scope | A bug or an attack. Do not retry. |
E_FIELD_NOT_WRITABLE | 403 | The patch touches a field absent from writable, or one marked serverOwned() | Fix the caller. The field is not trimmed and applied — a silent trim hides the bug until someone reads the wrong value. |
E_POLICY_REFUSED | 403 | A write policy returned false | Show the user why, if you can. |
E_ROW_NOT_FOUND | 404 | Update or delete against a row that does not exist | |
E_ROW_EXISTS | 409 | Insert against a key that already exists | Two devices minted the same primary key, or the code inserted twice. Never silently upserted — that would put two users on one row. |
E_ROW_DELETED | 409 | Update whose base version predates a concurrent delete, with onDeleteConflict: 'reject' | Tell the user. This is the case where quiet resolution loses someone's paragraph. |
E_INVALID_VALUE | 422 | A value fails its declared type — a non-finite number, null in a non-nullable field | |
E_SCHEMA_MISMATCH | 409 | The transaction was authored against a schema the server no longer has | The client rebuilds and replays; entries that no longer apply are reported through $conflict. |
None of these is a routine outcome. The routine one — somebody else edited the same field first — is a merge, and it does not appear here because it is not a refusal. See Conflicts.
Do not retry any of them automatically. Every one will fail again the same
way, and a retry loop against E_ROW_OUTSIDE_PARTITION is a request storm.
Attach errors
Raised by replica.attach(), before anything is read or written.
| Code | Condition | What to do |
|---|---|---|
E_FINGERPRINT_MISMATCH | The store found for this key holds a different schema hash, protocol version or storage format | replica.rebuild(). Set rebuildOnMismatch in development; leave it off in production, where a mismatch means something you did not expect. |
E_STORE_TORN | Some $version or outbox base version exceeds the checkpoint | replica.rebuild(). Should be unreachable — one transaction per write means there is nothing to be halfway through. If you see it, something outside locel wrote to the store, or the storage layer lied about a commit. |
E_PROTOCOL_MISMATCH | The server speaks a different protocol version | Deploy order. A client on the old protocol against a new server is supported for the length of a rollout; the reverse is not. |
E_BROKER_UNAVAILABLE | The requested broker is not available in this environment | One process must own the replica. broker: 'none' is correct only where this process is the only one. |
E_UNKNOWN_IDENTITY | A first-ever start with no network, nothing cached from a previous hello, and no scope option — so neither the scope nor the principal is known, and there is no way to tell which store to open | Render the signed-out or first-run state. Pass scope at construction if the application already knows it. Only reachable once per install — after one successful hello the scope is cached. |
Subset errors
| Code | Condition | What to do |
|---|---|---|
E_MISSING_SUBSET_INDEX | No declared index can serve the subset's filters and ordering | Add the index to the collection. Refused at open rather than run as an unbounded scan on every commit. |
E_SUBSET_MODE_REQUIRED | openSubset on a replica in partition mode | Mode is fixed at construction and is part of the fingerprint. |
Configuration errors
| Code | Condition | What to do |
|---|---|---|
E_COLLECTION_ALREADY_PERSISTED | locelCollectionOptions wrapped in persistedCollectionOptions | Remove the wrapper. The replica is the durable store; a second one is a second opinion about what your user wrote. |
E_UNKNOWN_STORE | locel.use(name) for a store not in the config | Normally caught at compile time by the declare module block. |
E_COLLECTION_NOT_IN_SCHEMA | A collection passed to locelCollectionOptions is not in the replica's schema |
Store failures
Not thrown — reported through replica.onStoreFailure, because they are
conditions rather than events, and because throwing from a write that is already
on screen would be too late.
reason | Condition | Recovery |
|---|---|---|
quota | Origin storage is full | Free space, then replica.resume(). |
unavailable | Storage cannot be opened at all — a disabled API, a private-mode restriction | Degrade explicitly. Do not fall back to memory silently. |
corrupt | The store cannot be read | replica.rebuild(). |
While a storage failure is outstanding the replica stops accepting writes. It does not drop outbox entries, prune confirmed rows, or continue in memory. See Offline and durability.
Handling them
A Rejection is a value, not a thrown error, so it is matched on code. It
arrives twice, once as an event and once as durable state, and the two are for
different jobs:
// Once, when the server answers. For telemetry and anything time-sensitive.
replica.onRejection((rejection) => {
telemetry.count('locel.rejected', { code: rejection.code })
})
// Durable, until acknowledged. For telling the user.
for (const rejection of replica.rejections) {
render(rejection.code === 'E_POLICY_REFUSED'
? 'You do not have permission to do that.'
: 'Some changes could not be saved.')
}Two rules, everywhere:
Never retry a refusal automatically. They are bugs and races, not transient failures.
Never discard one silently. A user who ticked three todos on a train is owed
the information that one did not stick, even if the other two did — which is why
the account is a list you clear with replica.acknowledge(rejection), and not a
toast that fires once while the tab is closing.
The grammar comes from ADR 0014.