Troubleshooting dynotable-orm: errors and common mistakes

What errors does dynotable-orm throw?

Four error classes, each thrown before any request is sent: ConfigError for a model definition that cannot work, ValidationError for an item the schema rejects, ExpressionError for a request DynamoDB would reject, and MarshallError for a value DynamoDB cannot store. Errors from DynamoDB itself are the SDK's, re-thrown unchanged.

errormeansthrown by
ConfigErrora model definition that cannot workmodel(), extend(), createTableInput(), extendZod(); a key or condition value whose field is async
ValidationErrorthe schema rejected an item or a valuecreate, batchPut, transaction.create; update with the Zod adapter
ExpressionErrora path, update, condition, key, query, batch or transaction DynamoDB would rejectevery call that builds a request
MarshallErrora value that cannot be stored or readevery call that writes or reads a value
the SDK's own errorsDynamoDB refused or failed the requestevery call that sends a request

All four classes are exported from dynotable-orm, so e instanceof ValidationError works. Each one's name is its class name. The sections below go through the messages one by one: search this page for the text of the message you see.

import {ConfigError, ExpressionError, MarshallError, ValidationError} from 'dynotable-orm';

export function describe(e: unknown): string {
  if (e instanceof ConfigError) return `fix the model definition: ${e.message}`;
  if (e instanceof ValidationError)
    return `bad input at ${e.path.map(String).join('.')}: ${e.message}`;
  if (e instanceof ExpressionError) return `bad request: ${e.message}`;
  if (e instanceof MarshallError) return `cannot store a value at ${e.path.join('.')}: ${e.reason}`;
  if (e instanceof Error) return `${e.name}: ${e.message}`; // from the AWS SDK
  return String(e);
}

Why does model() throw "the model has no hash key"?

ConfigError: the model has no hash key: set options.hashKey or mark a field. Every model must name its hash key: mark a field with .hashKey() or hashKey(...), or pass the hashKey option. dynamoose took the first attribute by default; dynotable-orm does not guess. See keys and indexes.

Related messages from model():

  • two hash keys: 'id' (options.hashKey) and 'userId' (field): a mark and an option name different keys. Keep one.
  • 'status' has {...} in its field metadata but {...} in options.indexes: a mark and an option give the same setting with different values.
  • the local index on 'placedAt' needs a table range key: a local index shares the table's hash key and sorts by its own field, so the table must have a range key.
  • 'meta.sk' is nested: only a top-level field can be a key, an index or a combine target: move the mark to a top-level field.
  • the combine target 'k' is also one of its sources.

Why does model() say a field "is not declared in the schema"?

ConfigError: 'email' (index byEmail) is not declared in the schema. An option names a key, an index or a combine field that the Zod schema does not declare. Zod strips undeclared keys, so the attribute would be missing from every item. Add the field to the schema, or fix the name in the option.

Why does a timestamp field throw "must accept absence"?

ConfigError: the timestamp field 'createdAt' must accept absence or a Date: the lib sets its value. With timestamps, the library sets createdAt and updatedAt itself. A required timestamp field must take the value the library sets: a Date, or a number for a number field. Declare it as z.date() or z.date().optional(), not as a string or with a check a Date fails.

the timestamp field 'createdAt' is a number, always stored in milliseconds: remove its storage setting: a number timestamp holds epoch milliseconds, so it takes no storage. See timestamps.

Why does createTableInput() say it "cannot tell whether" a key is a string or a number?

ConfigError: createTableInput() cannot tell whether 'id' is a string or a number from the schema. AttributeDefinitions needs S or N for each key attribute, and the schema does not give exactly one. This happens for every non-Date key of a schema that is not Zod, and for a Zod key such as a union of a string and a number. Write the CreateTable input yourself, or give the key one type.

What does "a key or condition value needs a sync one" mean?

ConfigError: 'id' has an async check or transform, or one that throws; a key or condition value needs a sync one. With the Zod adapter, a key or condition value goes through its field's transforms, and that parse is synchronous. A field with .refine(async …) or an async transform can be validated on a whole-item write, but it cannot be a key or a condition value. Make that field's checks synchronous, or pass {rawKey: true} to send a key as given.

Why does extend() say it "would replace" a method?

ConfigError: extend() on 'User' would replace get. extend() refuses a name that the model already has: get, query, create and the other built-in methods. Rename the method. A method that an earlier extend() added can be replaced. Methods of your own covers extend().

ConfigError: extendZod: ZodType already has a different 'storage' method: another Zod extension already added a method with that name. Use the function form of the marks instead (storage(z.date(), 'seconds')), which changes no prototype.

What does a ValidationError tell you?

The schema rejected an item, or one value of an update. The message is the schema's message for the first issue, plus its path: Too small: expected string to have >=1 characters at title. A value outside an enum and a value of the wrong type get dynamoose's wording instead: status must equal ["A","B"], but is set to C and Expected n to be of type number, instead found type string.

  • e.issues: every issue the schema reported, each with a message and a path.
  • e.path: the path of the first issue, such as ['title'] or ['lines', 0, 'sku'].

Common causes:

  • Old data written again. Every whole-item write runs the full schema, refinements and formats included. An item that dynamoose stored can fail when you read it and write it back. Fix the data, or loosen the schema: for example z.guid() for ids that are not RFC UUIDs.
  • An array for a Set. A z.set() field takes only a Set on create and $SET. Pass new Set(array). $ADD and $DELETE still take an array.
  • A $REMOVE of a required field. With the Zod adapter, removing a field that the schema requires fails, and so does setting it to undefined.
  • An undeclared key on a strict object. z.strictObject() rejects unknown keys: Unrecognized key: "x".
  • A required field missing on create. The write type is a Partial of the schema's input, so the compiler lets a missing field through and the runtime check catches it.

Schema validation lists what is checked on each call.

Why does an update throw "the update has nothing to write"?

ExpressionError: the update has nothing to write. The changes object holds no change that DynamoDB could apply. dynamoose sends such an update, and DynamoDB rejects it. Two common causes:

  • The changes are empty: update(key, {}) on a model without timestamps.
  • Every field in the changes is undeclared. A top-level $SET of an attribute the schema does not declare is dropped, so nothing is left. Check the field names, or declare the field. To write an undeclared attribute on purpose, pass {allowUndeclared: true} to update.

Nothing to update: the schema does not declare meta.x. is the same case for a nested path.

Why does an update throw "a path that overlaps it"?

ExpressionError: the update writes 'tags' and a path that overlaps it: DynamoDB rejects that. One update writes a path twice, or a path and a path inside it: $ADD and $DELETE on the same Set, address and address.city. Split it into two updates, or merge the changes. An updatedAt in the changes of a timestamped model does not throw: the library ignores it and writes its own stamp.

Why does an update say a field "cannot be updated alone"?

ExpressionError: period.from cannot be updated alone: period has its own checks or transform, so write the whole period. The parent object has a refinement or a transform, which can only judge a whole value. A write of one field below it would skip that rule. Update the whole parent: update(key, {period: {from, to}}).

Related: You must update all or none of the combine attributes when running Model.update, for an update that writes only some sources of a combined field, and You cannot update the combine attributes of sk because it is part of the key, for sources of a combined key.

Why does a query throw "no index fits the query"?

ExpressionError: no index fits the query: it needs an equality on a hash key. A DynamoDB query must name one hash key value, of the table or of an index. Add an eq() on a hash key at the top level of the chain, not under an or(). To read every item, use scan(). See query and scan.

Related messages:

  • the query needs an equality on the hash key 'author': using() names an index whose hash key has no equality in the conditions.
  • 'byOwner' is not an index of the model: using() names an undeclared index. It does not compile either, unless the types were bypassed.
  • a scan has no sort order, only a scan runs in parallel segments, and startAt() takes one key.

Why does a call throw "the key of … has no …"?

ExpressionError: the key of 'Message' has no 'sentAt'. The key object lacks a key attribute, or holds undefined or null for one. A model with a range key needs both: {roomId, sentAt}. A bare value is a key only for a model with a hash key alone.

the set hook of 'User' removed the key attribute 'id': the model's set option dropped a key attribute from the key it got.

Why does a condition throw "was passed undefined" or "has no comparison"?

  • eq() on 'status' was passed undefined: a comparison got undefined, often a variable that was never set. DynamoDB would reject the expression. To test for a missing attribute, use attributeNotExists().
  • the condition on 'status' has no comparison: a where() with nothing after it.
  • beginsWith() cannot follow not(): beginsWith, in and between cannot be negated.
  • in() on 'status' takes an array of values, and an "in" condition needs at least one value.
  • {gt, lt} on 'points' names 2 comparisons: give one, or chain them: the object form takes one comparison per attribute.
  • 'nope' is not an attribute of the model: an untyped new Condition('nope') names an undeclared attribute.
  • a group needs at least one condition: an empty group().

Conditions covers the chain.

Why do a transaction or a batch throw before sending?

  • a transaction holds 1 to 100 items, not 0: DynamoDB takes 1 to 100 actions per transaction. Split a larger set into several transactions, or use a batch when the writes need not happen together.
  • the transaction writes the key [...] twice: two entries on one item. DynamoDB rejects that too: merge them into one entry.
  • transaction.condition() on 'Stock' needs a condition: a ConditionCheck entry without a condition.
  • the batch holds the key [...] twice: a batch names one key twice. Remove the duplicate.

See transactions and batches.

What does a MarshallError mean?

A value has no DynamoDB form. The message ends with the path of the value, and e.path and e.reason hold the two parts:

  • an empty Set cannot be stored at tags: DynamoDB has no empty Set. Leave the attribute out, or remove it.
  • a Set cannot mix types and a Set can hold only strings, numbers or bytes.
  • NaN is not a finite number within the safe integer range at score: NaN, Infinity and integers beyond Number.MAX_SAFE_INTEGER cannot be stored as a number without losing precision. Use a bigint for a large integer: it is written as a number, and a large stored integer reads back as a bigint.
  • a Map instance cannot be stored: a Map or another class instance. Convert it to a plain object first. A function gives a value of type function cannot be stored.
  • an invalid Date cannot be stored: a new Date('not a date').

With the Zod adapter, the schema usually rejects these values first, with a ValidationError. A MarshallError then means a value that no schema checks, such as an $ADD delta.

How do you catch ConditionalCheckFailedException?

With isConditionalCheckFailed(e). A create on a stored key, or a write whose condition fails, makes DynamoDB refuse the write, and the SDK throws a ConditionalCheckFailedException:

import {isConditionalCheckFailed, model} from 'dynotable-orm';
import {hashKey} from 'dynotable-orm/zod';
import {z} from 'zod';

const User = model('User', z.object({id: hashKey(z.string()), name: z.string()}));

try {
  await User.create({id: 'u1', name: 'Ada'});
} catch (e) {
  if (!isConditionalCheckFailed(e)) throw e;
  console.log('u1 already exists');
}

It matches the error by its name, so it works with any copy of the SDK. A test of e.code === 'ConditionalCheckFailedException' from dynamoose code never matches: SDK v3 errors have no code.

Why does a transaction throw TransactionCanceledException?

DynamoDB cancelled the whole transaction: a condition failed, or another request was writing one of the items. isConditionalCheckFailed is false for it. Read the reasons array that transaction() adds to the error: one code per entry, in order, such as ['ConditionalCheckFailed', 'None']. A 'TransactionConflict' reason can be retried. See when a transaction fails.

Why does DynamoDB throw ResourceNotFoundException?

The table does not exist under the name the model uses. A model's table name is ${prefix}${name}${suffix}, so check Model.tableName, the prefix and suffix from setDefaults(), and the region and endpoint of the client. dynotable-orm never creates a table: create it from createTableInput() or in your infrastructure code. See creating the table.

Mistakes that throw nothing

  • Dates read back as strings. A model on a table that holds ISO strings, without defaultDateStorage: 'iso', writes epoch milliseconds next to the old strings, and old items read back with a string where the type says Date. Read the Date storage trap.
  • An update created an item. DynamoDB's UpdateItem creates a missing item with only the updated attributes. Add {condition: M.condition().where('id').attributeExists()}.
  • A field disappears. z.object() drops undeclared keys on write and on read. Declare the field, or use z.looseObject(). See unknown keys.
  • A query reads another index than you meant. Without using(), the library picks one from the conditions. Name it with using().
  • A page holds fewer items than limit(), or none. DynamoDB applies Limit before the filter. Loop on lastKey. See pagination.
  • A not() has no effect. not() negates the next comparison, and where() starts a new one, so write where('status').not().eq('done'), not not().where('status').eq('done').
  • A transaction ignores the model's client. transaction() uses its own client option, else setClient(). Pass {client} to transaction().
  • A cancelled transaction slips past isConditionalCheckFailed. Read reasons instead.

Next steps

Updated