-
Notifications
You must be signed in to change notification settings - Fork 0
Adapter: Hooks
Hooks are the primary extension point. The Adapter ships sane defaults and lets you override them via options.hooks or by subclassing.
See also (AWS JS SDK v3): DocumentClient marshalling · any of the CRUD / batch pages this hook layer wraps. Vocabulary: Concepts.
interface AdapterHooks<TItem> {
prepare?: (item: TItem, isPatch?: boolean) => TItem;
prepareKey?: (key: Partial<TItem>, index?: string) => Partial<TItem>;
prepareListInput?: (example: Partial<TItem>, index?: string) => Record<string, unknown>;
updateInput?: (input: Record<string, unknown>, op: {name: 'post'|'put'|'patch'|'delete'; force?: boolean}) => Record<string, unknown>;
revive?: (rawItem: TItem, fields?: string[]) => TItem;
validateItem?: (item: TItem, isPatch?: boolean) => Promise<void>;
checkConsistency?: (batch: BatchDescriptor) => Promise<BatchDescriptor[] | null>;
}User overrides merge over the defaults. The merged result lives at adapter.hooks.
| Hook | Default behavior |
|---|---|
prepare |
identity |
prepareKey |
identity, then restricted to keyFields
|
prepareListInput |
() => ({}) |
updateInput |
identity |
revive |
subsetObject(rawItem, fields) if fields is set, else identity |
validateItem |
async () => {} |
checkConsistency |
async () => null |
When the Adapter is constructed with declarative options — technicalPrefix, structuralKey, searchable, typeField, versionField — the toolkit installs built-in versions of prepare, revive, and prepareKey that handle the mechanical work those declarations imply. Your user-supplied hooks compose around the built-in: the built-in runs first, your hook sees its output.
(descriptorKey does its work at the list-op layer — auto-filtering the reserved descriptor row out of getList / getListByParams results — not at the hook layer.)
write path: item → built-in prepare → your prepare → DynamoDB
read path: DynamoDB → built-in revive → your revive → caller
key path: key → built-in prepareKey → your prepareKey → DynamoDB
Declaring searchable: {name: 1} means you no longer write the -search-name mirror in your prepare — the built-in does it for every write (including patches that touch the field). Likewise structuralKey auto-fills the joined key, typeField auto-stamps adapter.typeOf(item), versionField auto-inits to 1, and technicalPrefix rejects incoming user fields that intrude into the managed namespace.
Gated on what's declared. If none of the triggers are set, the built-in is a no-op and behaviour is byte-identical to pre-3.2.0 Adapters.
| Trigger | Behaviour |
|---|---|
technicalPrefix |
Rejects every incoming user field that starts with the prefix, except versionField and createdAtField (round-tripped through reads). Throws Error with the offending field name. |
structuralKey (composite keyFields) |
On full writes only: joins contiguous-from-start keyFields values via separator (default |), zero-pads {type: 'number'} components per their width, stores in structuralKey.name. Patches skip — DynamoDB rejects primary-key mutation via UpdateExpression anyway. |
searchable: {field: 1} |
Writes searchablePrefix + field = lowercase(value) on every write whose body carries field (full writes and patches). |
typeField: 'kind' |
On full writes only, when the item doesn't already carry the field: stamps typeOf(item). Patches skip (the value persists from the original put). User-written values win. |
versionField: '-v' |
On post: initialises to 1. On put / patch / edit: read + condition + increment logic lives in the CRUD methods themselves, not the built-in prepare. |
Gated on technicalPrefix. When declared: strips every field whose name starts with the prefix, except versionField and createdAtField — those pass through so callers can round-trip version counters through read-modify-write cycles. Your revive sees the cleaned item; the default revive then applies subsetObject(item, fields) when fields is set.
Without technicalPrefix, the built-in revive is a no-op; your revive hook (or the default) sees the raw DynamoDB row.
Gated on structuralKey. On base-table ops (index argument is undefined), joins the contiguous-from-start keyFields values into the structural-key attribute so DynamoDB gets the right primary key. On index-targeted ops (index set), passes through — GSI/LSI key composition is your prepareKey hook's responsibility until declarative GSI-key composition ships.
Per-item: adapter.put(raw(item)) skips both the built-in prepare and your prepare. The caller is fully responsible for the stored shape — including the structural key, mirrors, typeField, and a non-clashing technicalPrefix namespace. Pair with {reviveItems: false} on reads to get the raw bytes back unwrapped. See Adapter: Raw marker.
Pre-write transform — run on every non-Raw write. isPatch is true when called from patch / makePatch, so you can skip fields that aren't allowed to move on a partial update.
Check the built-in section above first. When you declare searchable, structuralKey, typeField, or technicalPrefix, the toolkit writes mirror columns, joins the composite key, stamps the type label, and validates the managed namespace for you. Your prepare is the place for everything the declarative options can't infer — domain-level derived fields, cross-field coercions, transient-field stripping, conditional rewrites.
hooks: {
prepare(item, isPatch) {
const out = {...item};
// Normalise the domain — something the declarative options can't express.
if (out.email) out.email = out.email.trim().toLowerCase();
// Derived field: full name for search / display. Built-in `searchable`
// writes the mirror column; we compute the source.
if (!isPatch && out.firstName && out.lastName) {
out.fullName = `${out.firstName} ${out.lastName}`;
}
// Strip transient UI-only fields before hitting DynamoDB.
delete out._dirty;
delete out._editing;
return out;
}
}Hook functions are bound to the Adapter instance via this, so you can read this.searchable, this.keyFields, this.searchablePrefix, etc. — useful for generic hooks shared across Adapter instances.
When in doubt about the boundary: your prepare is the last thing that runs before the item hits DynamoDB (modulo updateInput). The built-in runs first, so your hook sees item with mirrors + structural key + typeField already filled in. Read the built-in's output, don't re-derive it.
Any write path that bypasses prepare (raw(item), bare writeItems without a mapFn, direct SDK calls) skips both the built-in and your hook — see Adapter: Raw marker. If your stored shape depends on fields the built-in writes, a Raw caller has to reproduce them by hand.
Shape the key for a single-item operation. Default: identity, then restricted to keyFields — only the declared key fields survive.
With structuralKey declared, the built-in runs first and composes the joined key attribute from the caller's {state, rentalId, carVin}-shaped input — so in most cases your prepareKey has nothing to add. Override when key construction depends on the index (GSI/LSI-targeted reads), or when you want to accept external ID shapes and translate them into keyFields.
Provides extra DynamoDB params for getList. Typically returns an index-specific KeyConditionExpression so a scan becomes a query. The example is the partial item getList was called with; index is the GSI name argument.
hooks: {
prepareListInput(_example, index) {
const idx = index || '-t-name-index';
return {
IndexName: idx,
KeyConditionExpression: '#t = :t',
ExpressionAttributeNames: {'#t': '-t'},
ExpressionAttributeValues: {':t': 1}
};
}
}The KeyConditionExpression is hand-written here. For adapters with structuralKey declared, adapter.buildKey(values, options?, params?) composes the prefix from the declared keyFields automatically — see Concepts → KeyConditionExpression for the three ways to build one. '-t-name-index' and '-t' are author convention (a "all v3 items sorted by name" pattern), not toolkit conventions.
Last-chance hook to mutate a Command's input before the toolkit hands it to the SDK. Called on every write:
op.name |
Called from | Typical use |
|---|---|---|
'post' |
post / makePost
|
Add cross-cutting ConditionExpressions (e.g., tenant isolation). |
'put' |
put / makePut
|
Same; op.force tells you whether the existence check is on. |
'patch' |
patch / makePatch
|
Post-buildUpdate adjustments — e.g. add an audit UpdateExpression SET. |
'delete' |
delete / makeDelete
|
Add a final guard condition, emit telemetry. |
The hook receives input — the fully-built params object about to be sent — and must return an input object (typically the same input, mutated in place, or a rebuilt copy).
hooks: {
updateInput(input, op) {
// Add a tenant guard to every write
const names = input.ExpressionAttributeNames || {};
const values = input.ExpressionAttributeValues || {};
names['#tenant'] = 'tenant_id';
values[':tenant'] = this.tenantId;
const guard = '#tenant = :tenant';
input.ConditionExpression = input.ConditionExpression ? `(${input.ConditionExpression}) AND ${guard}` : guard;
input.ExpressionAttributeNames = names;
input.ExpressionAttributeValues = values;
return input;
}
}updateInput runs after buildUpdate / buildCondition and before cleanParams, so any name/value aliases you add are validated. If you only need a simple condition, consider using the options.conditions parameter on put / patch / delete instead — it reaches the same code path via buildCondition with less ceremony.
Post-read transform. Inverse of prepare. Default keeps everything when fields is null, applies subsetObject otherwise.
The built-in already strips technicalPrefix-namespaced fields when technicalPrefix is declared (preserving versionField and createdAtField so callers round-trip them). Your revive sees the cleaned item — use it for domain-level decoding (unmarshalling a stored ISO string back to Date, re-hydrating a Map from a stored plain object, etc.), not for stripping managed fields.
import {dateISO, unmarshallMap} from 'dynamodb-toolkit/marshalling';
hooks: {
revive(item, fields) {
const decoded = {
...item,
createdAt: dateISO.unmarshall(item.createdAt),
tags: unmarshallMap(item.tags)
};
return fields ? subsetObject(decoded, fields) : decoded;
}
}Without technicalPrefix, you're on your own for stripping — the pre-3.2 manual pattern (loop over keys, skip startsWith('-')) still works and is what the Concepts page covers under technicalPrefix and managed fields. Declaring technicalPrefix: '-' folds that loop into the built-in step.
Async validator. Throw to abort the write. Skipped for Raw<T> items (see Adapter: Raw marker).
hooks: {
async validateItem(item) {
if (!item.name || item.name.length > 100) throw new Error('Invalid name');
}
}Returns BatchDescriptor[] | null. If non-null, the write auto-upgrades to TransactWriteItems covering the main op + the returned checks. Returning an empty array still triggers the upgrade. See Adapter: Transaction auto-upgrade.
Why this is the right place for invariant enforcement. When a partial write (typically a patch) only touches a subset of the item's fields, the natural worry is: "did I just break an invariant this item was supposed to hold — referential integrity, a quota, an optimistic-lock revision?" The tempting approach is to read the full item back to the client, check the invariant in JavaScript, then write if it passes. That's two round-trips and a race window.
checkConsistency avoids both: each descriptor you return is a ConditionExpression DynamoDB evaluates server-side, atomically with the write. All checks pass → the write commits. Any check fails → TransactionCanceledException, database unchanged, caller sees one clear error. No invariant data is ever transferred to the client and back. This is the canonical place to enforce "parent must exist", version / revision locks, tenant scoping, quota limits, and anything else whose answer depends on rows other than the one being written.
hooks: {
async checkConsistency(batch) {
if (batch.action === 'put' && batch.params.Item.parent) {
// Require the parent to exist for any child write
return [{
action: 'check',
params: {
TableName: this.table,
Key: {name: batch.params.Item.parent},
ConditionExpression: 'attribute_exists(#k0)',
ExpressionAttributeNames: {'#k0': 'name'}
}
}];
}
return null;
}
}The toolkit ships prepare-hook factories for common boilerplate patterns. Import from the root.
Stamp fieldName with the current timestamp on first insert only. Items that already carry the field (round-tripped from a prior read) are left alone; patches are left alone.
import {Adapter, stampCreatedAtISO} from 'dynamodb-toolkit';
const adapter = new Adapter({
client, table: 'events', keyFields: ['id'],
technicalPrefix: '-',
createdAtField: '-createdAt',
hooks: {
prepare: stampCreatedAtISO('-createdAt') // first-insert timestamp
}
});stampCreatedAtISO writes new Date().toISOString(); stampCreatedAtEpoch writes Date.now() (epoch milliseconds). The default field name is '_createdAt' for both — override to match your createdAtField / technicalPrefix convention.
Why these exist. Every asOf-using declaration copied the same 4-line prepare:
prepare(item, isPatch) {
if (isPatch || item['-createdAt'] !== undefined) return item;
return {...item, '-createdAt': new Date().toISOString()};
}The factories return exactly that hook. One line instead of four, and you can't typo the "leave patches alone / leave round-tripped items alone" guards — those are the two bugs the 3.6.1 patch round fixed in hand-written prepare hooks.
The factories return a plain function. When you need additional prepare logic, compose via functional wrap:
import {stampCreatedAtISO} from 'dynamodb-toolkit';
const stamp = stampCreatedAtISO('-createdAt');
new Adapter({
client, table, keyFields: ['id'],
technicalPrefix: '-',
createdAtField: '-createdAt',
hooks: {
prepare(item, isPatch) {
const stamped = stamp(item, isPatch);
// your domain-level prepare, seeing the stamp's output
if (stamped.email) stamped.email = stamped.email.trim().toLowerCase();
return stamped;
}
}
});The full ordering is then built-in → your prepare (which calls stamp then your domain logic). The built-in wraps your prepare, not the factory directly — so the factory sees an item that already has mirrors, structural key, and typeField filled in. That's harmless for the stamp (which only reads the timestamp field) but worth knowing if you chain multiple factory-like helpers.
The declarative option gates options.asOf on mass ops (AND-merges <createdAtField> <= :asOf into the FilterExpression) but deliberately does not auto-write the field. The stored format (Date → ISO string vs epoch ms vs a timezone-qualified custom format) is a domain decision, and the toolkit doesn't pick. Wire the factory that matches your stored format. See Adapter: Constructor options — Concurrency and scope-freeze for the declaration details.
class PlanetAdapter extends Adapter {
async checkConsistency(batch) {
// ...
}
}When subclassing, override the corresponding instance method on this.hooks. Both options.hooks and subclass overrides are supported; options.hooks takes precedence.
Start here
- Getting started
- Concepts
- Key and field design
- Compatibility
- Migration: v2 to v3
- SDK v2 to v3 cheat sheet
Guides
- Hierarchical data walkthrough
- Key expression patterns
- Multi-type tables
- Pagination
- Mass operation semantics
- URL schema design
Adapter
- Adapter
- Constructor options
- CRUD methods
- Mass methods
- Batch builders
- Hooks
- Raw marker
- Indirect indices
- Transaction auto-upgrade
Expression builders
Batch / transactions / mass / paths
REST surface
Framework adapters
Recipes
- Recipes index
- List records of a tier
- Per-tier sparse GSI markers
- Tier within a partition
- Reservation with auto-release
- Keys-only GSI, runtime projection
- Cascade subtree operations
- Querying subtrees with buildKey
- Filter URL grammar
- Text search
- Provisioning workflow
- Resumable mass operations
History