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| option | what it does |
|---|---|
attributes | a projection: reads only these, and narrows the type |
consistent | a strongly consistent read |
raw | the stored values, unconverted: no Date conversion, undeclared keys kept |
rawKey | sends the key without the field transforms and the set hook |
return | 'request' returns the GetItem input instead of sending it |
client, abortSignal | the 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 itemcreatenever replaces by accident. Withoutoverwrite, it adds the conditionattribute_not_existson the hash key, so a stored key fails withConditionalCheckFailedException.overwrite: truereplaces the whole item. Attributes the new item does not hold are gone.conditionadds a condition of your own. Withoutoverwrite, it is joined with the key condition byAND. See conditions.keepCreatedAt: truekeeps the input'screatedAton 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:
| operator | takes | builds |
|---|---|---|
plain field, $SET | a value of the field's type, or ifNotExists(value) | SET p = v, or SET p = if_not_exists(p, v) |
$ADD on a number | a number to add, negative to subtract | ADD p v: an atomic counter |
$ADD on a Set | a Set or an array of elements | ADD p v |
$ADD on a list | an array of elements to append | SET p = list_append(p, v) |
$DELETE on a Set | a Set or an array of elements to take out | DELETE p v |
$REMOVE | an array of paths, or an object of nulls | REMOVE p |
- Paths and values are typed. A path the schema does not have, a value of the wrong type, or an
$ADDon a string does not compile. With the Zod adapter, each$SETvalue 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'}writesSET #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. undefinedremoves. A plain field or$SETvalue ofundefinedbecomes aREMOVE.- One path once. An update that writes a path twice, or a path and a path inside it (
$ADDand$DELETEon the same Set, oraddressandaddress.city), throws anExpressionErrorbefore 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 setsupdatedAt.
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': aPartialof the item, orundefined.'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
getorquerythrows aConfigError: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 ofobjas a plain object, typed as the item, for fixtures and mocks. Nothing is validated or sent.toJSON(item)is dynamoose'sitem.toJSON(): a JSON round trip, so aDatebecomes its ISO string.Profile.fromStored(raw)reads an item in DynamoDB's attribute-value form, from a stream record for example, asgetwould.
To look at the items these calls store, open the table in DynoTable, a desktop DynamoDB GUI.
Next steps
- Conditions: conditional writes and
isConditionalCheckFailed. - Transactions and batches for several items in one call.
- The
update(),UpdateInputandifNotExists()reference.