DynamoDB update expressions and item writes with dynotable-orm

How do you write a DynamoDB update expression in TypeScript?

Pass the changes as an object: Model.update(key, {name: 'Ada', $ADD: {visits: 1}}). Plain fields are a $SET, next to the $ADD, $REMOVE and $DELETE operators. dynotable-orm types each path and value from the schema, checks the values with the Zod adapter, and builds the UpdateExpression with its name and value placeholders.

This page covers the four item calls, get, create, update and delete, then the update operators, and extend() for methods of your own. Each call resolves to plain objects, typed from the schema.

Read an item: get

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Profile = model(
  'Profile',
  z.object({
    id: z.string().hashKey(),
    name: z.string(),
    email: z.email(),
    visits: z.number().default(0)
  })
);

const profile = await Profile.get('u1'); // the item, or undefined
console.log(profile?.visits);

const slim = await Profile.get('u1', {attributes: ['name'], consistent: true});
console.log(slim?.name); // typed as {name: string} | undefined
optionwhat it does
attributesa projection: reads only these, and narrows the type
consistenta strongly consistent read
rawthe stored values, unconverted: no Date conversion, undeclared keys kept
rawKeysends the key without the field transforms and the set hook
return'request' returns the GetItem input instead of sending it
client, abortSignalthe client and the abort signal of this call

Write an item: create

create writes a whole item, validated by the schema, and resolves to the item as get would read it back, with the defaults filled in:

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

extendZod(z);

const Profile = model(
  'Profile',
  z.object({id: z.string().hashKey(), name: z.string(), visits: z.number().default(0)})
);

const created = await Profile.create({id: 'u1', name: 'Ada'});
console.log(created.visits); // 0, the default

try {
  await Profile.create({id: 'u1', name: 'Grace'}); // u1 is stored: the write is refused
} catch (e) {
  if (!isConditionalCheckFailed(e)) throw e;
}

await Profile.create({id: 'u1', name: 'Grace'}, {overwrite: true}); // replaces the whole item
  • create never replaces by accident. Without overwrite, it adds the condition attribute_not_exists on the hash key, so a stored key fails with ConditionalCheckFailedException.
  • overwrite: true replaces the whole item. Attributes the new item does not hold are gone.
  • condition adds a condition of your own. Without overwrite, it is joined with the key condition by AND. See conditions.
  • keepCreatedAt: true keeps the input's createdAt on a timestamped model, for an item you read and write back. See timestamps.

Items are plain objects. There is no item.save(): to write back an item you read, call Profile.create(item, {overwrite: true, keepCreatedAt: true}). The migration guide lists the other instance methods that are gone.

Change an item: update

An update is a key and a changes object. It resolves to the new item (ALL_NEW) by default:

import {ifNotExists, model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

const Profile = model(
  'Profile',
  z.object({
    id: z.string().hashKey(),
    name: z.string(),
    nickname: z.string().optional(),
    visits: z.number().default(0),
    tags: z.set(z.string()).optional(),
    history: z.array(z.string()).optional(),
    firstSeen: z.date().optional()
  })
);

const request = await Profile.update(
  {id: 'u1'},
  {
    name: 'Ada', // a plain field is a $SET
    $SET: {firstSeen: ifNotExists(new Date())}, // only when it is not stored yet
    $ADD: {visits: 1, tags: ['beta'], history: ['login']}, // a number, Set elements, a list append
    $REMOVE: ['nickname']
  },
  {return: 'request'}
);

console.log(request.UpdateExpression);
// 'REMOVE #a5 ADD #a2 :v2, #a3 :v3 SET #a0 = :v0, #a1 = if_not_exists(#a1, :v1), #a4 = list_append(#a4, :v4)'
console.log(request.ExpressionAttributeNames);
// {'#a0': 'name', '#a1': 'firstSeen', '#a2': 'visits', '#a3': 'tags', '#a4': 'history', '#a5': 'nickname'}

{return: 'request'} returns the UpdateItem input instead of sending it, which shows the expression the operators build. Each one maps to a clause of a DynamoDB update expression:

operatortakesbuilds
plain field, $SETa value of the field's type, or ifNotExists(value)SET p = v, or SET p = if_not_exists(p, v)
$ADD on a numbera number to add, negative to subtractADD p v: an atomic counter
$ADD on a Seta Set or an array of elementsADD p v
$ADD on a listan array of elements to appendSET p = list_append(p, v)
$DELETE on a Seta Set or an array of elements to take outDELETE p v
$REMOVEan array of paths, or an object of nullsREMOVE p
  • Paths and values are typed. A path the schema does not have, a value of the wrong type, or an $ADD on a string does not compile. With the Zod adapter, each $SET value and each appended element is also checked at runtime: see updates are checked path by path.
  • A nested path is a string key. {'address.city': 'Paris'} writes SET #a0.#a1 = :v0, and {'history[0]': 'first'} writes one list element. The parent must already be stored: DynamoDB does not create the missing levels of a nested path.
  • undefined removes. A plain field or $SET value of undefined becomes a REMOVE.
  • One path once. An update that writes a path twice, or a path and a path inside it ($ADD and $DELETE on the same Set, or address and address.city), throws an ExpressionError before sending. DynamoDB rejects it too.
  • Nothing to write throws. An update with no change throws an ExpressionError. A top-level field the schema does not declare is dropped first, so an update of undeclared fields alone throws too, unless you pass {allowUndeclared: true}. On a timestamped model, update(key, {}) still sets updatedAt.

An update can create the item

DynamoDB's UpdateItem creates the item when the key is not stored yet, with only the attributes the update writes. Nothing in the schema stops it, because an update never runs the whole-item check. To change only an item that exists, add a condition on its hash key:

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

extendZod(z);

const Profile = model(
  'Profile',
  z.object({id: z.string().hashKey(), name: z.string(), visits: z.number().default(0)})
);

try {
  await Profile.update(
    {id: 'u404'},
    {$ADD: {visits: 1}},
    {condition: Profile.condition().where('id').attributeExists()}
  );
} catch (e) {
  if (!isConditionalCheckFailed(e)) throw e;
  console.log('no such profile');
}

What update resolves to

returnValues takes the five DynamoDB ReturnValues settings, and the result type follows it:

  • 'ALL_NEW' (the default): the whole item after the update.
  • 'ALL_OLD', 'UPDATED_OLD' and 'UPDATED_NEW': a Partial of the item, or undefined.
  • 'NONE': undefined.

The one-argument form

update(item) takes the key and the changes in one object, as dynamoose does: Profile.update({id: 'u1', name: 'Ada', $ADD: {visits: 1}}). The key attributes are taken out and not written. It takes no options; use the two-argument form for a condition.

Delete an item: delete

Profile.delete(key) resolves to undefined, whether or not the key was stored. It takes condition, rawKey, return, client and abortSignal. To delete only when a rule holds, pass a condition, such as {condition: Profile.condition().where('visits').eq(0)}.

Methods of your own: extend()

extend() adds methods to a model and returns the same model, typed with them. In each method, this is the model:

import {model} from 'dynotable-orm';
import {extendZod} from 'dynotable-orm/zod';
import {z} from 'zod';

extendZod(z);

export const Profile = model(
  'Profile',
  z.object({id: z.string().hashKey(), name: z.string(), visits: z.number().default(0)})
).extend({
  async mustGet(id: string) {
    const profile = await this.get(id);
    if (!profile) throw new Error(`${this.name} ${id} not found`);
    return profile;
  },
  async visit(id: string) {
    return this.update({id}, {$ADD: {visits: 1}});
  }
});

await Profile.batchPut([
  {id: 'u1', name: 'Ada'},
  {id: 'u2', name: 'Grace'}
]);

const ids = ['u1', 'u2'];
const profiles = await Promise.all(ids.map(Profile.mustGet)); // bound: safe to pass as a callback
console.log(profiles.length, (await Profile.visit('u1')).visits);
  • Each method is bound to the model, so it can be passed as a callback.
  • A later extend() replaces a method that an earlier one added.
  • A built-in name such as get or query throws a ConfigError: extend() on 'Profile' would replace get.
  • A method runs as written: it gets no callback argument, and a synchronous method stays synchronous.

extend() replaces dynamoose's M.methods.set(). The codemod converts most of those calls: see extend() in the migration guide.

Helpers for plain items

  • new Profile(obj) returns a deep copy of obj as a plain object, typed as the item, for fixtures and mocks. Nothing is validated or sent.
  • toJSON(item) is dynamoose's item.toJSON(): a JSON round trip, so a Date becomes its ISO string.
  • Profile.fromStored(raw) reads an item in DynamoDB's attribute-value form, from a stream record for example, as get would.

To look at the items these calls store, open the table in DynoTable, a desktop DynamoDB GUI.

Next steps

Updated