-
Notifications
You must be signed in to change notification settings - Fork 2
NORM Errors
How @tundralibs/norm reports failures: the NormError hierarchy and
the stable NormErrorCode strings its surfaces raise. Every code is a
frozen string you can branch on, so error handling never depends on
parsing a message.
- The hierarchy
- Error classes
- Reading a code
- Query surface codes
- Crypto codes
- Instance and configuration codes
- Definition and registry codes
- Migration codes
- Handling errors
- Guarantees and caveats
- Related documentation
Every error norm throws extends NormError, which extends BaseError
from @tundralibs/utils. That shared base supplies the typed
context, the cause chain, and JSON serialization; NormError adds
the code getter that reads context.code.
BaseError (@tundralibs/utils — context, cause, toJSON)
└─ NormError (adds .code; catch to match any norm error)
├─ NormQueryError (caller-side misuse of the query surface)
├─ NormCryptoError (encrypt/decrypt failures, missing secret)
├─ NormDefinitionError (invalid entity/schema/registry definitions)
├─ NormMigrationError (migration refusals and drift)
├─ NormAdvisoryLockError (server-side advisory lock not acquired)
├─ NormValidationError (write payload failed the Guardian)
├─ NormHookError (a lifecycle hook threw)
└─ NormUnsupportedError (engine lacks the requested capability)
The class tells you the category and the code tells you the exact
failure. Catch on the class when the whole category gets one reaction
(a NormValidationError is a 400), and read the code when one call can
fail several ways that deserve different handling.
Classes and codes come from the errors sub-path:
import { NormMigrationError, NormQueryError } from '@tundralibs/norm/errors';
import type { NormErrorCode } from '@tundralibs/norm/errors';
const isQueryFailure = (e: unknown): e is NormQueryError =>
e instanceof NormQueryError;
const isMigrationFailure = (e: unknown): e is NormMigrationError =>
e instanceof NormMigrationError;
const RETRYABLE: ReadonlySet<NormErrorCode> = new Set(['LOCK_TIMEOUT']);The root barrel (@tundralibs/norm, and @tundralibs/norm/core)
re-exports most of them for convenience, but not NormMigrationError;
import from @tundralibs/norm/errors when you handle migration
failures.
| Class | Thrown by | Carries a code |
|---|---|---|
NormError |
The instance and configuration surface: new Norm({...}), dialect resolution, runtimeOf(). Also the base class. |
Yes, the four instance codes. |
NormQueryError |
The read/write surface, before any engine call: filters, projections, aggregates, upserts, scopes. An unknown local column is OQL's TypeError, not this; see caveats. |
Yes, all nine query-surface codes. |
NormCryptoError |
The crypto path: a cell that would not decrypt or decode, or a request needing a secret that was never configured. |
Only for MISSING_SECRET; read-path failures do not. |
NormDefinitionError |
Entity() / Schema() / use() and the compile pass. Aggregates every finding on context.issues. |
Yes, the five definition codes. |
NormMigrationError |
The Migrator: drift, blocked drops, plan mismatches, lock contention, refused rewrites. |
Yes, the migration codes, plus MISSING_SECRET. |
NormAdvisoryLockError |
The executor seam when pg_advisory_lock / GET_LOCK times out. The migrator remaps it to a NormMigrationError. |
Always LOCK_TIMEOUT (set by the constructor). |
NormValidationError |
The write path when an insert, update, or upsert payload fails the column-derived Guardian. Detail on context.issues. |
No; branch on the class. |
NormHookError |
The accessor pipeline when a model's beforeInsert / beforeUpdate / beforeDelete / afterRead hook throws. |
No; context.model and context.hook identify it. |
NormUnsupportedError |
Eagerly, when the configured engine lacks a capability (db.transaction() on MongoDB or a fetch-only dialect), or the entity's own shape forbids the call (update()/upsert()/delete()/truncate() on a temporal entity, which is insert-only on every engine). context.feature names it. |
No; branch on the class. |
code is a getter on NormError, so it is available on every subclass:
import { NormError } from '@tundralibs/norm/errors';
function describe(err: unknown): string {
if (!(err instanceof NormError)) return 'not a norm error';
// `code` is `NormErrorCode | undefined` — narrow before using it.
return err.code ?? 'no code on this throw site';
}Every error that leaves a NormDb also says which instance raised it:
norm is a getter on NormError (it reads context.norm) and the
message is prefixed with the name. new Norm({ name: 'billing', … })
raises [billing] Column 'ssn' on entity 'Users' is not filterable ….
The name is NormConfig.name (norm-<n> when omitted), so two Norms
in one process are told apart without parsing anything. Definition
errors from Entity() / Schema() are raised before any instance
exists and carry no norm.
There are 30 codes in the NormErrorCode union, grouped below by the
surface that raises them.
Nine codes, all carried by NormQueryError and all raised before the
engine is touched. They describe a shape the repository cannot execute,
never a database failure. Two of the nine (TEMPORAL_PAST,
TEMPORAL_OVERLAP) apply only to a temporal entity's insert().
| Code | Raised when | What to do |
|---|---|---|
UNKNOWN_ENTITY |
db.repo(key) or db.query({ entity }) names a registry key no composed schema provides. |
Compose the schema that declares it (norm.use(...)), or fix the key. Treat as a programming error, not user input. |
NON_FILTERABLE_COLUMN |
A filter, orderBy, or column reference targets a column marked unfilterable() (implied by encrypt()), locally or through a relation, or a hashed column got a non-plaintext value, a column-to-column compare, or a non-equality operator. |
Filter the plaintext .hash() sibling instead of the ciphertext, and keep hashed-column filters to $eq / $ne / $in / $nin / $null with plaintext (or null). |
AGGREGATE_MISUSE |
An aggregate request is malformed (unknown fn, non-local column, alias collision) or combined with something incompatible: total: true, relation projections, mask columns, or an encrypted column. |
Group over plain, physical, local columns; drop total (a grouped query's total is its row count) and read relations in a separate query. |
UPSERT_CONFLICT_KEY |
A conflictKeys / updateOnConflict / update entry is a virtual mask, an encrypted (nondeterministic) column, a primary-key, conflict-key or insert-only column, or a copy that the entity's beforeUpdate hook could not see. |
Point the conflict key at a plain column or the encrypted column's .hash() sibling; change keys with update(); pass update: {..} on an entity with beforeUpdate. |
UPSERT_BATCH_SHAPE |
An upsert batch passes different columns in different rows; one conflict clause cannot serve them all. | Pass the same columns in every row (null where you mean null), or upsert rows one at a time. |
SCOPE_VIOLATION |
A db.scope(...) spec is invalid (a key that is not a single @column, or a value that is not an equality primitive), a scoped write would move a row out of its scope, a scoped upsert would adopt an outside row, or truncate() is called on a scoped handle. |
Fix the scope spec, or drop the offending column from the payload and let the scope fill it. Use delete({}) instead of truncate() on a scoped handle. |
INVALID_PROJECTION |
A projection key does not start with @, sub-projects a non-relation, names an unknown target, selects only relations, or asks for total: true on a filter that cannot be counted. |
Correct the projection; include at least one local column when projecting relations. |
UNKNOWN_RELATION |
A relation alias in a filter, orderBy, or projection resolves to neither a foreign key nor a reverse relation. |
Use a declared FK alias or the reverse name (reverseAs, or the derived default) on that entity. |
TEMPORAL_PAST |
A temporal entity's insert({ EffectiveFrom }) supplied a value that does not parse, or is in the past beyond a small clock-skew tolerance. History is immutable. |
Omit EffectiveFrom (norm stamps "now") or pass a value at/after now. See Temporal. |
TEMPORAL_OVERLAP |
A temporal entity's insert({ EffectiveFrom }) falls at or before the currently active version's own EffectiveFrom. A new version must start strictly after the one it supersedes. |
Pass a later EffectiveFrom, or omit it to use "now". See Temporal. |
Two refusals reuse an existing code rather than getting one of their
own: ordering by, or supplying a filter value from, an unprojected
to-many relation raises INVALID_PROJECTION (an unprojected to-many
runs as an EXISTS subquery, so project it or move the condition to
key position); and a scoped upsert whose scope column is encrypted with
no .hash() sibling raises SCOPE_VIOLATION.
| Code | Carried by | Raised when | What to do |
|---|---|---|---|
MISSING_SECRET |
NormCryptoError, NormMigrationError
|
Encryption or decryption was requested but new Norm({ secret }) was never given one: on a repo read or write, on the instance-level helpers, or when a migration rebuild has to rewrite encrypted data. |
Supply secret. The migration variant (NormMigrationError) means a table rebuild would have silently dropped encrypted data. |
Read-path decrypt and decode failures use the same class but carry no
code: context.reason is 'decrypt' (bad auth tag, wrong key) or
'decode' (malformed plaintext), with the underlying error on cause.
They surface as a thrown NormCryptoError only under
onDecryptFailure: 'throw'; the default policy degrades the cell to
null and emits a decryptError event instead.
All four are thrown as a plain NormError.
| Code | Raised when | What to do |
|---|---|---|
INVALID_HANDLE |
A value passed where a NormDb handle was expected is not one: runtimeOf() and the migration seam. |
Pass the value norm.use(...) returned, not the Norm instance and not a repo. |
INVALID_ENGINE_CONFIG |
new Norm({...}) got no database config, or a database.dialect norm does not know. |
Pass a database config with a supported dialect. |
ENGINE_NOT_REGISTERED |
database.dialect names a known dialect whose engine module was never imported (context.dialect names it). |
Import @tundralibs/norm/engines/<dialect>, or the root @tundralibs/norm barrel, which registers every dialect except sqlite, before constructing. |
INVALID_CACHE_CONFIG |
new Norm({ cache }) names a non-MEMORY cache engine without an explicit name (the auto-generated one would collide across processes); or, at norm.use(...) time, an entity's cache TTL is not a whole number of minutes within 30 days, or an entity declares cache: <minutes> and also .encrypt() columns on a non-MEMORY engine, since an external cache must never hold decrypted plaintext at rest. |
Set name on the Norm; fix the TTL; cache encrypted entities only on the MEMORY cache engine, or drop cache from them. See Caching. |
ENGINE_NOT_REGISTERED is the one you meet most often on sqlite. The
root barrel deliberately does not register it eagerly, because a native
binding on every runtime would make the barrel unbundlable for everyone
else, so a bare @tundralibs/norm import still needs its own
import '@tundralibs/norm/engines/sqlite' before constructing. It is
also what you meet on an edge runtime if you import
@tundralibs/norm/core without registering the engine you asked for;
see Choosing an entry point.
All five are carried by NormDefinitionError, whose context.issues
array holds one { model, path, message } entry per finding. The code
identifies the first structural violation that stopped the pass;
issues is where the detail lives.
| Code | Raised when | What to do |
|---|---|---|
DUPLICATE_ENTITY |
Two registry keys map to the same qualified database object, or a key is provided by more than one composed schema. | Rename one entity (or its dbSchema), or stop composing the duplicate schema. |
UNRESOLVED_FK |
A foreign key references an entity key that is not registered. | Compose the schema that provides the target, or fix the model on the FK. |
INVALID_FK |
A foreign key's target column does not exist, or the join runs over an encrypted column. | Point at a real column; never join over encrypt(), since IV-randomized ciphertexts never compare equal. |
REVERSE_COLLISION |
A reverse-relation name (explicit reverseAs or the derived default) collides with a column, an FK alias, or another reverse. |
Set an explicit reverseAs on the offending foreign key. |
TERMINAL_JOIN |
A foreign key targets a QUERY entity, or a stored SELECT reads from / joins one. QUERY entities are terminal. |
Reference a TABLE or VIEW instead. |
Carried by NormMigrationError, thrown by the Migrator. Most are
refusals: the migrator stops rather than desync the database from the
snapshot chain.
| Code | Raised when | What to do |
|---|---|---|
DRIFT |
The applied head no longer matches its recorded snapshot hash: the snapshot file was edited or deleted after it was applied. | Restore the snapshot from version control. Never "fix" drift by editing an applied snapshot. |
BLOCKED_DROPS |
apply() would DROP a table or column and allowDrop is not set. context.dir is set; the blocked list is in the message. |
Pass allowDrop: true once you have reviewed the list, or add renamedFrom hints if these are renames. |
PLAN_HASH_MISMATCH |
A reviewed .sql plan artifact's hash no longer matches the plan apply() would execute. |
Re-run renderPlans(), get the new diff reviewed, then apply. Production must execute exactly what was reviewed. |
PLAN_CHANGED |
A version left a partial-apply checkpoint (engines without transactional DDL), but its plan hash has since changed. | Reconcile the schema by hand, then delete the checkpoint row from the progress table. Resuming would skip statements. |
MISSING_ARTIFACT |
Reserved for a missing reviewed .sql plan artifact. No throw site raises it today; see Guarantees and caveats. |
Nothing; do not write a handler that expects it. |
MISSING_SNAPSHOT |
A versioned snapshot file is missing or unreadable: the applied head is absent from the directory, or a rollback target's file is gone. | Restore the file. Pending diffs would otherwise baseline against the wrong version. |
LOCK_TIMEOUT |
A migration lock could not be taken in time: the on-disk migrator.lock, or the server-side advisory lock another deploy holds. |
Wait and retry; this is the one code that is genuinely transient. Check whether a deploy is running elsewhere. |
INVALID_ROLLBACK |
A rollback({ to }) target is not below the applied head. |
Pass a version lower than the current head (or omit to to step back one). |
DIGEST_IMMUTABLE |
A one-way digest column's algorithm changed. There is no plaintext to re-digest. | Add a new column and backfill it from source data; do not try to migrate the digest in place. |
REBUILD_COUNT_MISMATCH |
A table rebuild copied a different row count than the original held. context.subject is the entity key. |
Do not retry blindly: the original table is preserved as <name>__pre_migrate. Investigate before dropping it. |
UNSUPPORTED_RENAME |
An unsupported rename was requested: renamedFrom on a VIEW. |
Drop and recreate the view; there is no data at stake. |
NormAdvisoryLockError is what the executor throws when a server-side
lock times out; only the migrator wraps it into a LOCK_TIMEOUT
NormMigrationError. If you call executor.withAdvisoryLock yourself,
catch NormAdvisoryLockError. It carries context.key and
context.timeoutMs, and its constructor always sets
code: 'LOCK_TIMEOUT'.
Branch on the class when the whole category maps to one reaction, and on the code when one call fails several ways:
import {
Column,
Entity,
Norm,
NormCryptoError,
NormError,
NormQueryError,
NormValidationError,
Schema,
} from '@tundralibs/norm';
const App = Schema('App', {
Users: Entity('users', {
id: Column.integer(),
email: Column.varchar(255),
}, { pk: ['id'] }),
});
const norm = new Norm({ database: { dialect: 'sqlite', path: './data' } });
const db = norm.use(App);
async function createUser(email: string): Promise<number> {
try {
await db.repo('Users').insert([{ id: 1, email }]);
return 201;
} catch (err) {
// A whole category, one reaction: a bad payload is a 400.
if (err instanceof NormValidationError) return 400;
// One call, several failure modes: switch on the stable code.
if (err instanceof NormQueryError) {
switch (err.code) {
case 'SCOPE_VIOLATION':
return 403; // the write would leave the active scope
case 'UPSERT_CONFLICT_KEY':
case 'INVALID_PROJECTION':
case 'UNKNOWN_ENTITY':
throw err; // caller bug — fix the query, do not retry
}
}
if (err instanceof NormCryptoError && err.code === 'MISSING_SECRET') {
throw err; // configuration bug — `new Norm({ secret })` was skipped
}
// Anything else that is still ours: log the code and context.
if (err instanceof NormError) {
console.error(err.code ?? 'UNCODED', err.context);
}
throw err;
}
}Migration failures are worth a dedicated map, because most of them are operator-facing and only one is retryable:
import { NormMigrationError } from '@tundralibs/norm/errors';
/** Turn a migration failure into an exit code: 75 = retry later. */
export function migrationExitCode(err: unknown): number {
if (!(err instanceof NormMigrationError)) return 70;
switch (err.code) {
case 'LOCK_TIMEOUT':
return 75; // transient — another deploy holds the lock
case 'DRIFT':
case 'MISSING_SNAPSHOT':
case 'PLAN_HASH_MISMATCH':
case 'PLAN_CHANGED':
return 78; // the migration directory needs a human
case 'BLOCKED_DROPS':
return 77; // re-run with allowDrop once reviewed
default:
return 70;
}
}Because NormError extends BaseError, every instance also carries
the shared contract, which is useful for structured logging:
import { NormError } from '@tundralibs/norm/errors';
export function toLogPayload(err: NormError): Record<string, unknown> {
return {
code: err.code, // stable NormErrorCode, or undefined
name: err.name, // the concrete class name
context: err.context, // typed metadata for this class
cause: err.cause, // the wrapped upstream error, when chained
json: JSON.stringify(err), // BaseError.toJSON()
};
}Codes are additive. New codes may be appended to NormErrorCode;
existing ones are never renamed or repurposed once shipped. A switch
over codes should therefore always keep a default.
code is optional at every throw site. On every code-carrying meta
type (QueryErrorMeta, CryptoErrorMeta, DefinitionErrorMeta,
MigrationErrorMeta) the field is declared code?, and
NormError.code returns NormErrorCode | undefined. Every throw site
listed above does set one, but nothing in the type system enforces it,
and norm throws some errors with no code at all (crypto read-path
failures, NormValidationError, NormHookError,
NormUnsupportedError). Narrow before you branch, and always keep a
fallback path for undefined.
MigrationErrorMeta goes furthest: every field on it is optional
(dir?, version?, subject?, code?), and NormMigrationError's
constructor defaults the whole meta to {}. Treat everything on a
migration error's context as possibly absent.
MISSING_ARTIFACT is declared but never thrown. A missing .sql
plan artifact is not an error: SQL plans are opt-in (renderSql,
renderPlans()), and apply() executes the freshly computed plan when
no stored artifact exists. The review gate is active only once the
artifacts are on disk. A tampered artifact raises PLAN_HASH_MISMATCH
instead. Do not write a handler that waits for MISSING_ARTIFACT.
A code can be shared across classes. MISSING_SECRET is raised as
both a NormCryptoError (query surface) and a NormMigrationError
(rebuild path), and LOCK_TIMEOUT appears on both
NormAdvisoryLockError and NormMigrationError. Match on the class as
well as the code when the reaction differs.
An unknown column is a TypeError, not a NormQueryError. norm
validates what it owns (relation aliases as @Alias.@col, projection
targets, hashed, encrypted, and unfilterable() columns, aggregates,
scopes) and hands the assembled query to @tundralibs/oql, whose
asserts reject a filter, orderBy, or conflictKeys entry that names
a local column the entity does not have. That rejection still happens
before any SQL is sent, but it surfaces as OQL's plain TypeError
(Invalid SELECT query: 'where' is invalid - … ColumnIdentifier "@nope" is not in the provided column list), with no code and no norm
stamp. Treat it like UNKNOWN_ENTITY: a programming error, not user
input.
- Querying: the filters, projections, and aggregates that raise the query-surface codes.
-
Scoping: where
SCOPE_VIOLATIONcomes from. -
Security: encryption, digests, and the
onDecryptFailurepolicy behindNormCryptoError. -
Migrations: the
Migratorworkflow the migration codes police. -
Schema definition: the declarations validated
into
NormDefinitionError. -
Temporal: where
TEMPORAL_PASTandTEMPORAL_OVERLAPcome from. -
Caching: where
INVALID_CACHE_CONFIGcomes from.