API Reference
Module-by-module reference for all public exports of effect-dynamodb.
DynamoModel
Section titled “DynamoModel”Provides annotations, date schemas, storage modifiers, and model configuration for effect-dynamodb.
import { DynamoModel } from "effect-dynamodb"Annotations
Section titled “Annotations”| Export | Type | Description |
|---|---|---|
Hidden | <S>(schema: S) => S | Marks a field as hidden from asModel/asRecord decode. Still stored in DynamoDB, visible in asItem/asNative |
isHidden | (schema: Schema.Top) => boolean | Check if a schema field has the Hidden annotation |
identifier | <S>(schema: S) => S | Marks the primary business identifier on a model. Exactly one per entity. Required for entities referenced via ref |
isIdentifier | (schema: Schema.Top) => boolean | Check if a schema field has the identifier annotation |
getIdentifierField | (model: Schema.Top) => { name, schema } | undefined | Find the identifier field in a model’s .fields |
ref | <S>(schema: S) => S | Marks a field as a denormalized reference to another entity. Transforms types in Entity (Input: ID, Record: full object, Update: optional ID) |
isRef | (schema: Schema.Top) => boolean | Check if a schema field has the ref annotation |
getRefAnnotation | (schema: Schema.Top) => RefAnnotation | undefined | Get the full ref annotation metadata |
Date Schemas
Section titled “Date Schemas”All date schemas carry a DynamoEncoding annotation that controls how the field is stored in DynamoDB.
| Export | Wire Type | Domain Type | DynamoDB Storage |
|---|---|---|---|
DateString | ISO 8601 string | DateTime.Utc | String (S) |
DateEpochMs | epoch milliseconds | DateTime.Utc | Number (N) |
DateEpochSeconds | epoch seconds | DateTime.Utc | Number (N) |
DateEpoch(opts) | auto-detect ms/seconds | DateTime.Utc | configurable |
DateTimeZoned | ISO+offset+zone string | DateTime.Zoned | String (S) |
UnsafeDateString | ISO 8601 string | native Date | String (S) |
UnsafeDateEpochMs | epoch milliseconds | native Date | Number (N) |
UnsafeDateEpochSeconds | epoch seconds | native Date | Number (N) |
TTL | epoch seconds | DateTime.Utc | Number (N) — alias for DateEpochSeconds |
Storage Modifier
Section titled “Storage Modifier”| Export | Type | Description |
|---|---|---|
storedAs | <A>(storageSchema: Schema<A>) => (fieldSchema: Schema<A>) => Schema<A> | Override DynamoDB storage format. Type-safe: domain types must match |
Model Configuration
Section titled “Model Configuration”| Export | Type | Description |
|---|---|---|
configure | (model, attributes) => ConfiguredModel<M> | Wrap a model with per-field DynamoDB overrides (field renaming, storage encoding, immutable) |
isConfiguredModel | (value) => boolean | Check if a value is a ConfiguredModel |
Storage Layouts
Section titled “Storage Layouts”Values for a field’s storedAs in configure that change how a structured field is laid out on the item.
| Export | Type | Description |
|---|---|---|
SparseMap | (options?: { prefix? }) => SparseMapConfig | Store a Schema.Record as one top-level attribute per entry — see Sparse Maps |
Packed | (options: { format: "json", compression?: { codec: "gzip" | "brotli" | "zstd", threshold? } }) => PackedConfig | Store an object- or array-shaped field (or EventStore data / snapshot state) as one JSON string, compressed to binary at or above threshold (default 1024 bytes) when smaller. Keep the default: compressing small values (threshold: 0) saves read units but makes reads slower. Throws [EDD-9071] on invalid options. See Packed Storage |
isPackedConfig | (value) => value is PackedConfig | Check if a value is a Packed config |
getPackedFields | (model) => Record<string, PackedConfig> | Domain field name → config, for every field configured storedAs: Packed(...) |
Encoding Utilities
Section titled “Encoding Utilities”| Export | Type | Description |
|---|---|---|
DynamoEncodingKey | symbol | Annotation key for reading DynamoEncoding from schema AST |
getEncoding | (schema) => DynamoEncoding | undefined | Read the DynamoEncoding annotation from a schema |
| Type | Description |
|---|---|
DynamoEncoding | { storage: "string" | "epochMs" | "epochSeconds", domain: "DateTime.Utc" | "DateTime.Zoned" | "Date" } |
ConfiguredModel<M> | Wrapper carrying original model + per-field attribute overrides |
RefAnnotation | { _tag: "Ref", refSchemaId?: string } — annotation metadata for ref fields |
// Annotationsclass Employee extends Schema.Class<Employee>("Employee")({ employeeId: Schema.String, createdBy: Schema.String, internalId: Schema.String.pipe(DynamoModel.Hidden),}) {}
// configure — immutable fields + field overridesconst EmployeeModel = DynamoModel.configure(Employee, { createdBy: { immutable: true },})
// Date schemasclass Event extends Schema.Class<Event>("Event")({ eventId: Schema.String, startedAt: DynamoModel.DateString, // ISO string ↔ DateTime.Utc expiresAt: DynamoModel.DateEpochSeconds, // epoch seconds (for TTL) scheduledAt: DynamoModel.DateTimeZoned, // with timezone}) {}
// storedAs — override storage format on a SELF schema (not a transform).// `Schema.DateTimeUtc` is the self schema; the annotation drives storage.class Order extends Schema.Class<Order>("Order")({ orderId: Schema.String, // Domain: DateTime.Utc, stored as epoch seconds (e.g. for DynamoDB TTL). // Applying `storedAs` to a transform schema (e.g. DateString or // DateTimeUtcFromString) is rejected at Entity.make() time. expiresAt: Schema.DateTimeUtc.pipe(DynamoModel.storedAs(DynamoModel.DateEpochSeconds)),}) {}
// configure — field renaming + storage overrides (self-schema only for storedAs)const OrderModel = DynamoModel.configure(Order, { expiresAt: { field: "ttl", storedAs: DynamoModel.DateEpochSeconds },})
// identifier — marks primary business ID for ref resolutionclass Team extends Schema.Class<Team>("Team")({ id: Schema.String.pipe(DynamoModel.identifier), name: Schema.String,}) {}
// ref — denormalized reference to another entityclass Selection extends Schema.Class<Selection>("Selection")({ team: Team.pipe(DynamoModel.ref), // Input: teamId, Record: Team player: Player.pipe(DynamoModel.ref), // Input: playerId, Record: Player}) {}DynamoSchema
Section titled “DynamoSchema”Application namespace for key prefixing and versioning.
import { DynamoSchema } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
make | (config) => DynamoSchema | Create a schema with name, version, and optional casing |
prefix | (schema) => string | Build schema prefix: $name#vN |
applyCasing | (value, casing) => string | Apply a casing to a key part — labels and composite values alike |
composeKey | (schema, entityType, composites) => string | Compose entity key: $schema#vN#entity_type#composites |
composeCollectionKey | (schema, collection, composites) => string | Compose collection PK |
composeClusteredSortKey | (schema, collection, entity, composites) => string | Compose clustered SK |
composeIsolatedSortKey | (schema, entity, composites) => string | Compose isolated SK |
composeUniqueKey | (schema, entity, constraint, values) => { pk, sk } | Compose unique constraint sentinel keys |
composeVersionKey | (schema, entity, version) => string | Compose version snapshot SK |
composeDeletedKey | (schema, entity, timestamp) => string | Compose soft-deleted item SK |
composeVersionKeyPrefix | (schema, entity) => string | Prefix for version queries |
composeDeletedKeyPrefix | (schema, entity) => string | Prefix for deleted queries |
| Type | Description |
|---|---|
DynamoSchema | Interface: { name: string, version: number, casing: Casing } |
Casing | "lowercase" | "uppercase" | "preserve" |
const AppSchema = DynamoSchema.make({ name: "myapp", version: 1 })// Keys generated as: $myapp#v1#entity_type#compositesSee Modeling for details.
Minimal table definition with Layer-based name injection.
import { Table } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
make | (config: { schema, entities?, aggregates? }) => Table | Create a table definition with entities and aggregates |
definition | (table) => TableDefinition | Derive CreateTableCommandInput fields from entity index declarations |
Both make and definition check the members against each other. Every member declaring one physical secondary index (an entity index, an auto-generated _cascade_<field> index, an aggregate’s collection or list index) must agree on its key attributes and on whether it is a GSI or an LSI, and every entity must declare the same primary-key fields. A disagreement throws [EDD-9069], naming both declarers. Tables deployed with CDK or Terraform get the check from make alone.
| Type | Description |
|---|---|
Table | Interface with schema, entities, aggregates, layer(), layerConfig(), and Tag for DI |
TableConfig | { name: string } — runtime table configuration |
TableDefinition | { KeySchema, AttributeDefinitions, GlobalSecondaryIndexes? } |
const MainTable = Table.make({ schema: AppSchema, entities: { UserEntity, TaskEntity } })
// Runtime name injection via LayerMainTable.layer({ name: "MyTable" })
// Typed client auto-derives table schema for creationconst db = yield* DynamoClient.make({ entities: { UserEntity, TaskEntity }, tables: { MainTable },})yield* db.tables.MainTable.create()See Modeling for details.
Entity
Section titled “Entity”The core module — binds models to tables, provides CRUD operations, query accessors, lifecycle management, and 7 derived types. This is the largest module.
import { Entity } from "effect-dynamodb"Construction
Section titled “Construction”| Export | Type | Description |
|---|---|---|
make | (config) => Entity | Create an entity with model, table, indexes, and optional system fields |
make config:
| Property | Type | Required | Description |
|---|---|---|---|
model | Schema.Class | Schema.Struct | ConfiguredModel | Yes | Domain model schema (or configured model with field overrides) |
entityType | string | Yes | Discriminator stored as __edd_e__ |
primaryKey | { pk: KeyDef, sk: KeyDef, casing? } | Yes | Primary key composition rules. Optional casing overrides the schema’s for the primary key |
indexes | Record<string, GsiIndexDef> | No | GSI index definitions with name, pk, sk, optional collection, type, casing (overrides the schema’s for that index — collection members must agree, EDD-9055) and indexPolicy |
timestamps | boolean | { created?, updated? } | No | Auto-managed createdAt/updatedAt. Each slot takes a field name, a DynamoModel date schema (storage only), or { field?, schema? }. A schema without a DynamoEncoding annotation throws EDD-9044 at make() time |
versioned | boolean | { retain?, field?, ttl? } | No | Auto-increment version with optional snapshot retention |
softDelete | boolean | { ttl?, preserveUnique? } | No | Soft delete with optional TTL |
unique | Record<string, string[]> | No | Unique constraint definitions |
refs | Record<string, Entity> | No | Map ref field names to their source entities for ref hydration |
Operations
Section titled “Operations”These are methods on the entity definition returned by Entity.make(). They return operation descriptors used by Transaction, Batch, and advanced pipeable workflows. For day-to-day CRUD, use DynamoClient.make(table) to get a typed client with executable methods (see below).
| Operation | Signature | Description |
|---|---|---|
entity.put(input) | EntityPut | Insert or overwrite an item (descriptor). On a versioned or unique-constrained entity it reads the item first: a put over an existing item continues its version, incarnation and createdAt, snapshots it (retain) and rotates changed sentinels, releasing only those it owns. A concurrent write in between is retried (last writer wins); a race lost on every attempt fails with OptimisticLockError / ConcurrentModification. A deleted retain item created again continues after its retained version history |
entity.create(input) | EntityPut | Insert only — fails with ConditionalCheckFailed if exists (descriptor). Never reads the item; a retain entity runs one Limit 1 query to continue after any version history retained at the key (with primary sort key composites, plus a keys-only Limit 1 query for history an earlier release wrote) |
entity.upsert(input) | EntityPut | Create or update the supplied fields — uses if_not_exists() for immutable fields, createdAt, version (descriptor). On an entity with unique constraints or retain (or an input omitting a defaulted index composite) it reads the item once: creates a missing item with its sentinels and snapshot, or updates an existing one, rotating changed sentinels and snapshotting it. Validates the whole input either way |
entity.get(key) | EntityGet | Get a single item by primary key (descriptor) |
entity.update(key) | EntityUpdate | Start an update operation (compose with set, add, etc.) (descriptor). On a missing item it fails with ItemNotFound and writes nothing. The exception is a plain .set() of a complete item (every required field and primary-key composite, with no other operations, expectedVersion, .condition(), cascade, withVector or old-image returnValues), which the library creates through create, exactly as put would write it |
entity.patch(key) | EntityUpdate | Update with attribute_exists — fails with ConditionalCheckFailed if not exists (descriptor) |
entity.delete(key) | EntityDelete | Delete an item (descriptor). Releases only the unique sentinels the item owns. On a retain entity it reads the item and deletes it in one transaction with a snapshot of its final state, so an item created again continues past it. A delete that reads first and has no .condition() is retried from a fresh read after a concurrent write |
entity.deleteIfExists(key) | EntityDelete | Delete with attribute_exists — fails with ConditionalCheckFailed if not exists (descriptor). On an entity whose delete reads first, retried after a concurrent write like an unconditioned delete |
entity.query.<indexName>(pk) | Query<Record> | Query a specific index by partition key (descriptor) |
entity.scan() | Query<Record> | Full table scan filtered by entity type (descriptor) |
entity.versions(key) | Query<Record> | Query version history for an item (requires versioned: { retain: true }) (descriptor). With sort key composites, only the named item’s history |
entity.deleted.list(key) | Query<Record> | List soft-deleted items for a key (requires softDelete) (descriptor) |
entity.batchGet(keys) | See Batch module | Batch get items |
entity.batchPut(items) | See Batch module | Batch put items |
entity.batchDelete(keys) | See Batch module | Batch delete items |
Client Gateway Pattern
Section titled “Client Gateway Pattern”DynamoClient.make({ entities, aggregates?, tables? }) resolves dependencies and returns a typed client with executable operations for all listed entities and aggregates, namespaced under entities, aggregates, collections, and tables:
const MainTable = Table.make({ schema: AppSchema, entities: { UserEntity } })const db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable },})const users = db.entities.UserEntityBound entity methods:
| Method | Description |
|---|---|
bound.get(key) | Get a single item |
bound.put(input, ...combinators) | Insert or overwrite. Optional combinators (e.g. condition(...)). A put over an existing item of a versioned or unique-constrained entity continues it (see entity.put) |
bound.create(input, ...combinators) | Insert only — fails with ConditionalCheckFailed if exists |
bound.upsert(input, ...combinators) | Create or update — if_not_exists() for immutable fields, createdAt, version. Reads first on an entity with unique constraints or retain (see entity.upsert) |
bound.update(key, ...combinators) | Update an item — compose with Entity.set(...), Entity.expectedVersion(...), etc. |
bound.patch(key, ...combinators) | Update with attribute_exists — fails if item does not exist |
bound.delete(key, ...combinators) | Delete an item. Optional combinators (e.g. condition(...)) |
bound.deleteIfExists(key, ...combinators) | Delete with attribute_exists — fails if item does not exist |
bound.paginate(query, ...combinators) | Execute a query and return a lazy Stream<A> of items, automatically paginating |
bound.collect(query, ...combinators) | Execute a query and collect all pages into Effect<Array<A>> |
Bound lifecycle methods (require matching entity config):
| Method | Requires | Returns | Description |
|---|---|---|---|
bound.getVersion(key, version) | versioned: { retain: true } | Effect | Get a specific version snapshot |
bound.versions(key) | versioned: { retain: true } | BoundQuery | List all version snapshots as a fluent BoundQuery — supports .collect(), .fetch(), .paginate(), .count(), .limit(), .reverse(), .startFrom(), .filter(), .select() |
bound.restore(key) | softDelete | Effect | Restore a soft-deleted item |
bound.purge(key) | Any | Effect | Permanently remove item + all versions and soft-deleted copies, and release the unique sentinels they own. With sort key composites, only the named item’s rows; without, every row of this entity in the partition. Never another entity’s rows. Deletes in BatchWriteItem chunks of 25, resending unprocessed deletes with Batch.write’s backoff; not atomic, so when retries run out the DynamoError says exactly how many rows it had deleted |
bound.deleted.get(key) | softDelete | Effect | Get a specific soft-deleted item |
bound.deleted.list(key) | softDelete | BoundQuery | List all soft-deleted tombstones in the partition as a fluent BoundQuery |
Entity definition lifecycle operations (require matching config — listed in Operations table above for query descriptors):
| Operation | Requires | Description |
|---|---|---|
entity.getVersion(key, version) | versioned: { retain: true } | Get a specific version snapshot (descriptor) |
entity.deleted.get(key) | softDelete | Get a soft-deleted item (descriptor) |
entity.restore(key) | softDelete | Restore a soft-deleted item (descriptor) |
entity.purge(key) | Any | Delete the item, its versions and its soft-deleted copies (descriptor): every row of this entity in the partition without sort key composites, the named item’s rows with them; never another entity’s rows |
Operation Combinators
Section titled “Operation Combinators”These functions transform entity operations via pipe:
| Export | Works On | Description |
|---|---|---|
set(updates) | EntityUpdate | Set fields to new values (dual API) |
expectedVersion(n) | EntityUpdate | Optimistic lock — fail if version doesn’t match. Requires a versioned entity: on any other it fails with a ValidationError before anything is sent |
consistentRead | EntityGet | Enable strongly consistent reads |
condition(input) | EntityPut, EntityUpdate, EntityDelete | Add a ConditionExpression. A later one replaces an earlier one. It is ANDed with the op’s own guard (create’s not-exists, patch’s and deleteIfExists’s exists), never in its place. An empty condition ({}, and()) is no condition. An empty part under or() or not(), or an or() with no parts, fails with a ValidationError before anything is sent. An isIn with no values is sent, and DynamoDB’s rejection of its IN () is a ValidationError naming it |
remove(fields) | EntityUpdate | REMOVE attributes from the item |
add(values) | EntityUpdate | Atomically ADD to numeric attributes |
subtract(values) | EntityUpdate | Subtract from numeric attributes (SET #f = #f - :v) |
append(values) | EntityUpdate | Append to list attributes (list_append) |
deleteFromSet(values) | EntityUpdate | DELETE elements from Set attributes |
returnValues(mode) | EntityUpdate, EntityDelete | Control DynamoDB ReturnValues. An update takes "none" | "allOld" | "allNew" | "updatedOld" | "updatedNew"; a delete takes "none" | "allOld" (any other mode fails with a ValidationError before sending). On an update the result type follows the mode (UpdateReturn<A, M>): "none" → undefined, "updatedOld" / "updatedNew" → a Partial of the top-level attributes the update wrote, "allOld" / "allNew" → the whole item. Honoured on every update path; with repeated calls the last one wins. On a delete, "allOld" returns the deleted item (undefined when there was none) on every delete path, and "none" nothing; a deleted item that won’t decode is DeleteAppliedButUnreadable (the delete was applied) |
cascade(config) | EntityUpdate | Propagate source entity changes to target entities that embed it via DynamoModel.ref. Config: { targets, filter?, mode? } (dual API) |
Decode Mode Selectors
Section titled “Decode Mode Selectors”Control what type an operation returns:
| Export | Returns | Description |
|---|---|---|
asModel | Entity.Model<E> | Pure domain object (default for yield*) |
asRecord | Entity.Record<E> | Domain + system fields (version, timestamps) |
asItem | Entity.Item<E> | Full DynamoDB item (all keys + __edd_e__) |
asNative | Entity.Marshalled<E> | Raw DynamoDB AttributeValue format |
Type Extractors
Section titled “Type Extractors”| Type | Description |
|---|---|
Entity.Model<E> | Pure domain object fields |
Entity.Record<E> | Model + system metadata (version, timestamps) |
Entity.Input<E> | Creation input (model fields, no system fields) |
Entity.Update<E> | Mutable fields only (keys and immutable excluded) |
Entity.Key<E> | Primary key attributes only |
Entity.Item<E> | Full DynamoDB item (model + system + keys + __edd_e__) |
Entity.Marshalled<E> | DynamoDB AttributeValue format |
Schema & Attribute Accessors
Section titled “Schema & Attribute Accessors”| Export | Description |
|---|---|
keyAttributes(entity) | List all key attribute names (primary + GSI) |
keyFieldNames(entity) | List physical field names for all keys |
compositeAttributes(entity) | List all composite attribute names across indexes |
itemSchema(entity) | Get the item-level decode schema |
decodeMarshalledItem(entity, item) | Decode a DynamoDB item (DynamoDB JSON with B as bytes or base64, or already unmarshalled) as a table read does: packed fields by stored form, sparse maps rebuilt, renames reversed. Fails with ValidationError (a missing compressor’s cause is CompressorNotRegistered) |
const UserEntity = Entity.make({ model: User, entityType: "User", primaryKey: { pk: { field: "pk", composite: ["userId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byEmail: { name: "gsi1", pk: { field: "gsi1pk", composite: ["email"] }, sk: { field: "gsi1sk", composite: [] }, }, }, timestamps: true, versioned: true, unique: { email: ["email"] },})
// Get typed client with executable operationsconst db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable },})const users = db.entities.UserEntity
// CRUDconst user = yield* users.put({ userId: "u-1", email: "a@b.com", ... })const found = yield* users.get({ userId: "u-1" })
// Update with combinatorsyield* users.update({ userId: "u-1" }, Entity.set({ email: "new@b.com" }))
// Update with multiple combinatorsyield* users.update( { userId: "u-1" }, Entity.set({ email: "new@b.com" }), Entity.expectedVersion(1),)
yield* users.delete({ userId: "u-1" })
// Query execution via bound entityconst allUsers = yield* users.collect(UserEntity.query.byEmail({ email: "a@b.com" }))const stream = users.paginate(UserEntity.scan(), Query.pageSize(100))
// Or with v2 entity-centric pattern (BoundQuery fluent API):// const db = yield* DynamoClient.make({ entities: { UserEntity } })// const allUsers = yield* db.entities.UserEntity.byEmail({ email: "a@b.com" }).collect()See Getting Started and Modeling for details.
Pipeable Query<A> data type — a lazy, immutable description of a DynamoDB query or scan.
import { Query } from "effect-dynamodb"Combinators
Section titled “Combinators”| Export | Type | Description |
|---|---|---|
where(conditions) | Dual | Add sort key conditions (KeyConditionExpression) |
limit(n) | Dual | Return at most n items (a contract on results — accumulates across requests, works under a filter) |
pageSize(n) | Dual | Fetch in batches of n rows — sets DynamoDB’s Limit (a contract on round trips) |
maxPages(n) | Dual | Limit total number of pages fetched |
reverse | Combinator | Reverse sort order (descending) |
consistentRead | Combinator | Enable strongly consistent reads (the table, or an LSI). DynamoDB rejects a consistent read of a GSI: the query fails with a ValidationError (operation: "query.consistentRead") naming the index, DynamoDB’s error as its cause |
ignoreOwnership | Combinator | Skip __edd_e__ entity type filter — for mixed-table scenarios |
startFrom(cursor) | Dual | Resume pagination from a previous cursor |
select(attrs) | Dual | Project specific attributes (returns Query<Record<string, unknown>>) |
filterExpr(expr) | Dual | Add an Expr ADT filter (from callback API) |
selectPaths(paths) | Dual | Project path segments (from callback API) |
Terminals
Section titled “Terminals”| Export | Returns | Description |
|---|---|---|
execute | Effect<Page<A>> | Execute and return a page: { items: Array<A>, cursor: string | null } |
collect | Effect<Array<A>> | Execute, fetch all pages, and flatten into a single array |
paginate | Effect<Stream<Array<A>>> | Execute and return a Stream of pages for lazy pagination |
count | Effect<number> | Execute with SELECT COUNT — returns total matching items (respects maxPages) |
asParams | Effect<Record<string, unknown>, ValidationError> | Return built DynamoDB command input without executing — useful for debugging. Fails with a ValidationError for a filter the query would refuse to send |
Filter Operators
Section titled “Filter Operators”Used in entity-level filter():
| Operator | Example | Description |
|---|---|---|
| Equality | { status: "active" } | Exact match |
ne | { status: { ne: "deleted" } } | Not equal |
gt | { price: { gt: 30 } } | Greater than |
gte | { price: { gte: 30 } } | Greater than or equal |
lt | { price: { lt: 100 } } | Less than |
lte | { price: { lte: 100 } } | Less than or equal |
between | { price: { between: [10, 50] } } | Inclusive range |
beginsWith | { name: { beginsWith: "A" } } | String prefix |
contains | { name: { contains: "widget" } } | Substring match |
exists | { email: { exists: true } } | Attribute exists |
notExists | { email: { notExists: true } } | Attribute does not exist |
Utilities
Section titled “Utilities”| Export | Description |
|---|---|
isQuery(value) | Type guard for Query<A> |
const db = yield* DynamoClient.make({ entities: { TaskEntity }, tables: { MainTable },})const tasks = db.entities.TaskEntity
const items = yield* tasks.collect( TaskEntity.query.byProject({ projectId: "p-1" }), Query.where({ beginsWith: activePrefix }), TaskEntity.filter({ priority: { gt: 3 } }), Query.reverse, Query.limit(25),)See Queries for details.
BoundQuery
Section titled “BoundQuery”The fluent query builder returned by every entity query accessor on a BoundEntity (e.g. db.entities.Tasks.byProject({...})) and by db.entities.Tasks.scan(). BoundQuery<Model, SkRemaining, A> wraps an internal Query<A> with pre-resolved services so all terminals return Effect<..., ..., never>.
import type { BoundQuery } from "effect-dynamodb"Combinators are immutable — each call returns a new BoundQuery. Terminals execute the query.
Combinators
Section titled “Combinators”| Method | Description |
|---|---|
.where((t, ops) => …) | Type-safe sort key condition on remaining SK composites. Only available when SK composites have not all been consumed. Consumes SkRemaining (cannot be called twice). Operators: eq, lt, lte, gt, gte, between, beginsWith. |
.filter((t, ops) => …) | Post-read filter expression via callback. Type-safe attribute paths via t, condition operators via ops. An empty filter (and()) is no filter. An empty part under or() or not(), or an or() with no parts, fails with a ValidationError before sending. An isIn with no values is sent, and DynamoDB’s rejection is a ValidationError (operation: "query.filter") naming it. |
.filter(shorthand) | Post-read filter via an equality shorthand object: { status: "active" } ANDs status = "active" for each key. For other comparisons use the callback form. |
.select((t) => […paths]) | Projection expression via callback. Returns BoundQuery<…, Record<string, unknown>>. |
.select(["field", …]) | Projection via attribute name array. |
.limit(n) | Return at most n items. A contract on results: the query accumulates across as many requests as it takes, which is what makes it work under .filter(). |
.pageSize(n) | Fetch in batches of n rows — sets DynamoDB’s Limit. A contract on round trips, not on what comes back. |
.maxPages(n) | Maximum number of DynamoDB pages to fetch. |
.reverse() | Reverse sort order (ScanIndexForward = false). |
.startFrom(cursor) | Resume pagination from an opaque cursor returned by .fetch(). |
.consistentRead() | Enable strongly consistent reads. On a GSI, which DynamoDB reads only eventually consistently, DynamoDB rejects the query and it fails with a ValidationError naming the index. An entity’s indexes are GSIs when db.tables.*.create() creates them. |
.ignoreOwnership() | Skip the __edd_e__ entity-type filter. Use only when querying a polymorphic GSI shared across entity types and you want every item back. |
Terminals
Section titled “Terminals”| Method | Returns |
|---|---|
.collect() | Effect<Array<A>, DynamoClientError | ValidationError, never> — drain all pages into a single array. |
.fetch() | Effect<Page<A>, DynamoClientError | ValidationError, never> — single page + opaque cursor. Page<A> is { items: Array<A>, cursor: string | undefined }. |
.paginate() | Stream<A, DynamoClientError | ValidationError, never> — lazy stream that paginates automatically. |
.count() | Effect<number, DynamoClientError, never> — count-only query (Select: COUNT); items are not returned. |
where typing
Section titled “where typing”.where() is conditionally available based on the SkRemaining type parameter. When the index’s SK composites have already been fully consumed (e.g., the entity has no SK composites, or all of them were supplied to the query accessor), .where() is not present on the type — calling it is a compile error. Once called, .where() consumes SkRemaining and the resulting BoundQuery no longer exposes .where().
const db = yield* DynamoClient.make({ entities: { Tasks } })
// Sort key condition + filter + limitconst recent = yield* db.entities.Tasks .byProject({ projectId: "p-1" }) .where((t, { beginsWith }) => beginsWith(t.createdAt, "2026")) .filter((t, { eq }) => eq(t.status, "active")) .limit(50) .collect()
// Single page + cursorconst page = yield* db.entities.Tasks .byProject({ projectId: "p-1" }) .fetch()
// Lazy streamyield* db.entities.Tasks .byProject({ projectId: "p-1" }) .paginate() .pipe(Stream.runForEach((task) => Console.log(task.title)))
// Count onlyconst total = yield* db.entities.Tasks.byProject({ projectId: "p-1" }).count()See Queries and Expressions for details.
Collections
Section titled “Collections”Multi-entity queries across a shared index. Collections are auto-discovered from entity indexes that share the same collection property.
Auto-Discovery
Section titled “Auto-Discovery”When multiple entities define the same collection name on the same GSI, they are automatically grouped into a collection accessible via db.collections.<name>():
// Define entities with shared collectionconst Employees = Entity.make({ // ... indexes: { byTenant: { collection: "tenantMembers", name: "gsi1", pk: { field: "gsi1pk", composite: ["tenantId"] }, sk: { field: "gsi1sk", composite: ["name"] }, }, },})
const Tasks = Entity.make({ // ... indexes: { byTenant: { collection: "tenantMembers", name: "gsi1", pk: { field: "gsi1pk", composite: ["tenantId"] }, sk: { field: "gsi1sk", composite: ["priority"] }, }, },})
// Auto-discovered on the typed clientconst db = yield* DynamoClient.make({ entities: { Employees, Tasks } })const result = yield* db.collections.tenantMembers({ tenantId: "t-1" }).collect()// result: { Employees: Employee[], Tasks: Task[] }A collection query takes .filter(), .select(), .limit(), .pageSize(), .reverse() and .startFrom(), and keeps its grouping through them. Its terminals:
| Terminal | Returns |
|---|---|
.collect() | The grouped result, { Employees: Employee[], Tasks: Task[] } |
.fetch() | One page, grouped the same way, with its cursor |
.paginate() | A Stream of every item, each tagged with its member: { member: "Employees", item: Employee } (CollectionStreamItem) |
.count() | The number of items, every member’s together |
A filter or select names each member’s domain fields: on a member that stores a field under another name (DynamoModel.configure(..., { field })) it reads that attribute. A member without the field reads it as absent — eq doesn’t match, not(eq(...)) and notExists(...) do — and an attribute it stores under that name never stands in for the field. .select() returns partial records grouped per member. The query also takes .maxPages(), .ignoreOwnership() and .consistentRead(). A collection over a GSI, which DynamoDB reads only eventually consistently, fails .consistentRead() with a ValidationError naming the index, from DynamoDB’s rejection. Its types (CollectionQuery, CollectionStreamItem, CollectionSelected, CollectionAccessors) are exported.
Explicit Collections
Section titled “Explicit Collections”For advanced use, Collection.make() creates collections explicitly:
import { Collection } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
make | (name, entities) => Collection | Create a collection from entities sharing an index |
See Indexes & Collections for details.
Transaction
Section titled “Transaction”Atomic multi-item operations.
import { Transaction } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
transactGet | (items) => Effect<Tuple> | Atomically get up to 100 items — returns a typed tuple. Accepts unbound Entity.get intermediates AND the BoundGet returned by db.entities.*.get(...) |
transactWrite | (ops) => Effect<void> | Atomically write up to 100 items (puts, updates, deletes, condition checks). A put of a versioned or unique-constrained entity reads the item first and creates or replaces it as the entity’s own put does; an update / patch, and a delete of a unique / retain / softDelete entity, write exactly what the entity’s own op writes (see below). Accepts unbound Entity.put/Entity.update/Entity.delete intermediates AND the bound builders returned by db.entities.*; conditions attached to either (including create()’s implicit attribute_not_exists) are compiled into the transact item. upsert is rejected with a ValidationError — see below |
check | (get, condition) => ConditionCheckOp | Create a condition-check operation for transactWrite (dual API). Takes either get spelling |
transactGet
Section titled “transactGet”const [user, order] = yield* Transaction.transactGet([ UserEntity.get({ userId: "u-1" }), db.entities.Orders.get({ orderId: "o-1" }),])db.entities.X.get(key) returns a BoundGet: it is an Effect<Model, …, never> (yield it, pipe it, Effect.catchTag it) and it is also a read descriptor, so the same value works in transactGet, Batch.get and check. That matters most for entities authored with the pure, AWS-free @effect-dynamodb/schema package — a pure definition carries no operations, so the bound client is the only surface its author holds. Both spellings unwrap through one protocol and can be mixed in a single array; anything else fails with a ValidationError carrying EDD-9052.
transactWrite
Section titled “transactWrite”yield* Transaction.transactWrite( UserEntity.put({ userId: "u-1", ... }), OrderEntity.delete({ orderId: "o-old" }), Transaction.check( UserEntity.get({ userId: "u-1" }), { attributeExists: "email" }, ),)Ops transactWrite will not compile
Section titled “Ops transactWrite will not compile”transactWrite compiles each op to a Put, Delete or ConditionCheck, plus the extra items of a guarded put, and plans updates and multi-item deletes from the entity’s own op (see below). An op whose entity contract needs a different verb, extra items it can’t build from the op, or a service the compile step does not hold is refused with a ValidationError rather than written with different semantics:
| Op / entity feature | Why it is refused |
|---|---|
upsert() | An UpdateItem using if_not_exists for createdAt, immutable fields and the version counter. A Put would reset all three |
put() / create() of an entity with refs | Write-time ref hydration reads the referenced entity; the put compile step cannot, so the ref attribute would be written empty. (An update() is planned from the entity’s own update, which hydrates refs as it does standalone.) |
generatedId | Id generation needs the Crypto service |
vectorIndexes | Computing the embedding needs the Embedder service; the item would drop out of the index |
update() with .cascade(...) | EDD-9059. A cascade is a follow-up write to other entities after the update commits, so it cannot share the transaction |
update() / delete() with a .returnValues(...) mode that returns an item | EDD-9060. A transaction returns no item attributes. (A delete with such a mode is not a transact op at the type level either.) |
update() of an entity with vectorIndexes | EDD-9061. Recomputing the embedding needs the Embedder service |
Guarded puts expand
Section titled “Guarded puts expand”A put through transactWrite (or EventStore.append({ additionalItems })) of a versioned or unique-constrained entity is written exactly as the entity’s own put writes it. It reads the item first, then emits more than one item, all atomic with the rest of the transaction: the row, guarded on what was read (missing: attribute_not_exists; present: its version and incarnation, or its unique values), one attribute_not_exists-guarded sentinel per new unique value, a release of each old sentinel the item owns, and with retain the snapshot.
Consequences worth knowing:
- An existing item is replaced, not refused. It continues its version, incarnation and
createdAt, its replaced state is snapshotted (retain), and changed unique values rotate their sentinels. A deleted retain item created again continues after its retained version history. This is what keeps a versioned read model in step withEventStore.append. - A race is retried. If another writer changes, creates or deletes the item between the read and the transaction, or a sentinel it releases changes hands, the transaction is cancelled and built again from a fresh read. A race lost on every attempt fails with
OptimisticLockError/ConcurrentModification. - Errors name what happened. A duplicate fails with
UniqueConstraintViolation(naming the constraint and the values). Only an op’s own condition (.condition(),create()) is reported as a condition:TransactionCancelledfromtransactWrite,AdditionalItemConditionFailedfromappend. - One op per item. DynamoDB allows one operation per item in a transaction, and rejects a transaction that touches one item twice; it fails with a
ValidationErrornaming the entity and both ops, and nothing is written. That includes two puts of one item, and two puts that swap unique values: each releases the sentinel the other reserves. Inappend, an additional item that repeats an event or the idempotency sentinel fails the same way. DynamoDB can report a repeat as a failed condition, so the repeat is recognised first: it is never read as a lost race and retried.transactGetreading one item twice fails with aDynamoErrornaming both gets. - Write limits. Limits and request rules DynamoDB enforces itself (100 items and 4 MB per transaction, 400 KB per item, 2048 bytes per partition key and 1024 per sort key, one operation per item, 4,096 characters / 300 operators per expression and 100 operands per
IN, noIN (), no empty key values) are not checked before sending. The write is sent; DynamoDB rejects it whole, so nothing is written; the library recognises the rejection, confirms it against the request it sent, and fails with an error naming the culprit, with DynamoDB’s error as itscause. Over 100 items isTransactionOverflow(entity writes),AppendTooLarge(append),AggregateTransactionOverflow(an aggregate group),CascadePartialFailure(a transactional cascade) or aDynamoError(transactWrite,transactGet); the others are aValidationErrornaming the item and its key (an over-long condition also names how much of it is the caller’s.condition()), or for 4 MB the largest items (by DynamoDB’s size accounting, which counts each Put’s item, each other action’s key, and every expression with its names and values; aretainput counts twice: its item and its snapshot), with the library’s measure beside the size DynamoDB reported. The same diagnosis covers single-item writes,Batch.writeandEventStoresnapshots. A rejection the library does not recognise, or cannot confirm by measuring, passes through as DynamoDB’sDynamoValidationError. - The reads cost capacity. Each guarded put makes one consistent
GetItemof its item, one more per sentinel it would release, and, for a missingretainitem, oneLimit 1query of its version history (plus, with primary sort key composites, a keys-onlyLimit 1query for older history). - The 100-item cap counts the expanded total. Ten
unique+retainputs occupy 30 slots or more. The error message reports both numbers. softDeletedoes not affect a put — only a delete — so puts of soft-deletable entities are unaffected.
Updates and multi-item deletes
Section titled “Updates and multi-item deletes”An update / patch, and a delete of an entity with unique, versioned: { retain: true } or softDelete, write exactly what the entity’s own op writes — the guarded update or put of the row, sentinel rotations and owned releases, the retain snapshot, the soft-delete tombstone. transactWrite runs the op itself with its write recorded instead of sent, and the recorded items join the transaction. So everything the standalone op guarantees holds inside the transaction too:
yield* Transaction.transactWrite([ // Rotates the email sentinel: releases the old value, reserves the new one. db.entities.Users.update({ userId: "u-1" }).set({ email: "new@example.com" }), // Releases every sentinel the stored row owns. db.entities.Vehicles.delete({ vehicleId: "v-9" }), // Commits or rolls back with both of the above. db.entities.AuditEvents.put({ ... }),])- It reads where the standalone op reads — the row for a read-then-write update or a multi-item delete, owned sentinels, refs, a
clearMap’s buckets. A plain update compiles to oneUpdateitem, and its row is read once: standalone, that update’s own condition finds a missing row and takes the op’s missing-row path (a complete.set()creates the item; otherwiseItemNotFound;patchfailsConditionalCheckFailed), which a transaction cannot do after the fact — so the planner checks first and takes that same path. - Read outcomes surface before anything is sent:
ItemNotFound,ConditionalCheckFailed(patchof a missing item),OptimisticLockError(a staleexpectedVersion),RefNotFound. - A race is retried, as for a guarded put. The recorded items are guarded on what was read; if the row changes in between, the transaction is cancelled and planned again from a fresh read — so a row deleted meanwhile takes the op’s missing-row path, and a pinned
expectedVersionthe row no longer has failsOptimisticLockError. Only the caller’s own.condition()rejecting a row that is unchanged since the read is reported asTransactionCancelled— the stored item the cancellation returns is compared with the row the plan read, as for a guarded put;patch’s anddeleteIfExists’ existence guards are not caller conditions. A race lost on every attempt isOptimisticLockError(versioned) orConcurrentModification, and a retain snapshot the history already holds fails with aValidationErrorwithout retrying. - A taken unique value is
UniqueConstraintViolation, naming the constraint and the values. - An update with nothing to write contributes no item; a transaction of only such updates sends nothing.
EventStore.append({ additionalItems })does not plan: it accepts noupdate(at the type level), and refuses these deletes (EDD-9048).
| Type | Description |
|---|---|
ConditionCheckOp | A condition-check operation for inclusion in transactWrite |
Batch get and write with auto-chunking and unprocessed item retry.
import { Batch } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
get | (...gets) => Effect<Tuple> | Batch-get up to 100 items with typed tuple return. Accepts unbound Entity.get intermediates AND the BoundGet returned by db.entities.*.get(...) |
write | (...ops) => Effect<void> | Batch-write any number of items (auto-chunks at 25). Accepts unbound Entity.put/Entity.delete intermediates AND the bound builders returned by db.entities.*; conditional ops and upsert/update/patch are rejected with a ValidationError — see below. Puts of a versioned entity go first, as create-only transactions of up to 100 items |
// Batch get — typed positional results; unbound and bound gets may be mixedconst [user1, user2] = yield* Batch.get([ UserEntity.get({ userId: "u-1" }), db.entities.Users.get({ userId: "u-2" }),])
// Batch write — mixed puts and deletesyield* Batch.write( UserEntity.put({ userId: "u-1", ... }), UserEntity.put({ userId: "u-2", ... }), OrderEntity.delete({ orderId: "o-old" }),)Auto-chunking: get chunks at 100 items, write chunks at 25 items. Both retry unprocessed items automatically.
BatchWriteItem has no ConditionExpression, so Batch.write rejects any op carrying a condition — .condition(...), create() (implicit attribute_not_exists) and deleteIfExists() (implicit attribute_exists) all fail with a ValidationError rather than silently writing unconditionally, whatever .condition() is added to them (even .condition({})). Use Transaction.transactWrite for conditional writes.
It also rejects (EDD-9049) any write of an entity configured with unique or versioned: { retain: true }, and any delete of a softDelete entity. BatchWriteItem has no ConditionExpression (which is the entirety of a uniqueness sentinel’s correctness), no UpdateRequest, and no atomicity across its 25-item chunks — a sentinel that lands without its row, or a row without its sentinel, is a corrupt partition. A put of a softDelete entity is unaffected, because softDelete only changes the delete path.
Puts of a plain versioned entity are accepted, but not as PutRequests, which would reset an existing item to version 1 under a new incarnation. Batch.write sends them first, as create-only TransactWriteItems, each conditioned on attribute_not_exists, chunked at exactly 100 items and 4,194,304 bytes by DynamoDB’s size accounting. A chunk DynamoDB rejects for size all the same fails with a ValidationError giving both sizes, and is not re-split. There is no read, so there is no window between a check and the write. A batch that touches such an item more than once is refused with a ValidationError before anything is written.
- A put that would replace an existing item cancels its whole chunk, so nothing in that chunk is written, and fails with a
ValidationError. Earlier chunks may already have been written, sinceBatch.writewas never atomic across chunks, and the message says how many; the batch’s other requests aren’t sent. Use the entity’s ownput()orTransaction.transactWriteto replace an item. - Each chunk costs twice the write capacity of a batch write.
- Contention cancellations (
TransactionConflict, throttling) are retried with the batch’smaxRetries/baseDelayMsbackoff. - Puts of other entities, and deletes, are still plain
BatchWriteItemrequests.
Expression
Section titled “Expression”Condition, filter, and update expression builders.
import { Expression } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
condition | (input: ConditionInput) => ExpressionResult | Build ConditionExpression |
filter | (input: ConditionInput) => ExpressionResult | Build FilterExpression |
update | (input: UpdateInput) => ExpressionResult | Build UpdateExpression |
ConditionInput Operators
Section titled “ConditionInput Operators”| Operator | Example | Description |
|---|---|---|
eq | { eq: { status: "active" } } | Equality |
ne | { ne: { status: "deleted" } } | Not equal |
gt, gte, lt, lte | { gt: { price: 0 } } | Comparisons |
between | { between: { price: [10, 50] } } | Inclusive range |
beginsWith | { beginsWith: { name: "A" } } | String prefix |
attributeExists | { attributeExists: "email" } | Attribute exists |
attributeNotExists | { attributeNotExists: "pk" } | Attribute does not exist |
UpdateInput
Section titled “UpdateInput”| Property | Example | Description |
|---|---|---|
set | { set: { name: "New" } } | SET attribute values |
remove | { remove: ["oldField"] } | REMOVE attributes |
add | { add: { count: 1 } } | ADD to numeric/set attributes |
delete | { delete: { tags: new Set(["old"]) } } | DELETE from set attributes |
| Type | Description |
|---|---|
ExpressionResult | { expression: string, names: Record, values: Record } |
ConditionInput | Declarative condition expression input |
UpdateInput | Declarative update expression input |
const cond = Expression.condition({ eq: { status: "active" }, gt: { stock: 0 },})// cond.expression: "#status = :v0 AND #stock > :v1"See Expressions Guide for comprehensive reference.
Expr ADT (Callback API)
Section titled “Expr ADT (Callback API)”Type-safe expression building with PathBuilder and ConditionOps.
import { compileExpr, createConditionOps, createPathBuilder, isExpr, parseShorthand, parseSimpleShorthand, type Expr, type ConditionOps, type Path, type PathBuilder,} from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
createPathBuilder<M>() | () => PathBuilder<M, M> | Create a path proxy for type-safe attribute access |
createConditionOps<M>() | () => ConditionOps<M> | Create comparison and logical operators |
compileExpr(expr, resolveDbName?) | (Expr) => CompileResult | Compile Expr to DynamoDB expression string. Throws on an expression that would widen what was written — an empty part under or() / not(), an or() with no parts; an empty part directly under and() is left out. An isIn with no values compiles to IN (), which DynamoDB rejects (diagnosed at the site that sends it) |
isExpr(u) | (unknown) => u is Expr | Type guard for Expr nodes |
parseShorthand(input) | (ConditionInput) => Expr | Convert ConditionInput to Expr |
parseSimpleShorthand(input) | (Record) => Expr | Convert { key: value } to Expr |
Entity-Level Combinators:
| Combinator | Description |
|---|---|
Entity.condition(cb | shorthand) | Callback or object → condition combinator for put/update/delete |
Entity.filter(cb | shorthand) | Callback or object → filter combinator for query/scan |
Entity.select(cb | attrs) | Callback or string array → projection combinator |
Path-Based Update Combinators:
| Combinator | Description |
|---|---|
Entity.pathSet(op) | SET nested path or attribute-to-attribute copy |
Entity.pathRemove(segments) | REMOVE nested path or array element |
Entity.pathAdd(op) | ADD to nested numeric/set |
Entity.pathSubtract(op) | Subtract from nested numeric |
Entity.pathAppend(op) | Append to nested list |
Entity.pathPrepend(op) | Prepend to nested list |
Entity.pathIfNotExists(op) | Set only if attribute doesn’t exist |
Entity.pathDelete(op) | DELETE from nested set |
Types:
| Type | Description |
|---|---|
Expr | Discriminated union of 16 expression node types |
ConditionOps<Model> | Typed comparison/logical operators for callbacks |
PathBuilder<Root, Model> | Recursive proxy type for attribute path access |
Path<Root, Value, Keys> | Resolved attribute path with phantom types |
SizeOperand<Root> | size() operand for path-based size comparisons |
CompileResult | { expression: string, names: Record, values: Record } |
DeepPick<T, Paths> | Type utility for projection return type narrowing |
Projection
Section titled “Projection”ProjectionExpression builder for selecting specific attributes.
import { Projection } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
projection | (attrs: string[]) => ProjectionResult | Build ProjectionExpression from attribute names |
| Type | Description |
|---|---|
ProjectionResult | { expression: string, names: Record<string, string> } |
const proj = Projection.projection(["name", "email", "status"])// proj.expression: "#proj_name, #proj_email, #proj_status"// proj.names: { "#proj_name": "name", "#proj_email": "email", "#proj_status": "status" }KeyComposer
Section titled “KeyComposer”Composite key composition from index definitions. Used internally by Entity, also available for advanced use cases.
import { KeyComposer } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
composePk | (schema, entity, index, record) => string | Compose partition key value |
composeSk | (schema, entity, index, record) => string | Compose sort key value |
composeIndexKeys | (schema, entity, index, record) => Record | Compose all key attributes for an index |
tryComposeIndexKeys | (schema, entity, index, record) => Record | undefined | Non-throwing variant for sparse GSIs |
composeAllKeys | (schema, entity, indexes, record) => Record | Compose keys for all indexes |
composeGsiKeysForUpdatePolicyAware | (schema, entity, indexes, updates, keyRecord, { removedSet? }) => { sets, removes } | Policy-aware GSI key composition for update/append. Implements v3 per-half structural composition + policy-aware hole detection (see indexPolicy guide). |
composeSortKeyPrefix | (schema, entity, index, composites) => string | Partial SK prefix for begins_with queries |
extractComposites | (keyPart, record) => string[] | Extract composite attribute values from a record |
tryExtractComposites | (keyPart, record) => string[] | undefined | Non-throwing variant |
serializeValue | (value) => string | Serialize a value for key composition |
effectiveCasing | (schema, index) => Casing | The casing an index composes with: its own casing, else the schema’s |
assertCollectionCasingAgreement | (collectionName, members) => void | Throw EDD-9055 when a collection’s members would compose its keys with different casings |
| Type | Description |
|---|---|
KeyPart | { field: string, composite: string[] } |
IndexDefinition | { pk: KeyPart, sk: KeyPart, index?, collection?, type?, casing?, indexPolicy? } |
IndexPolicyKey | "sparse" | "preserve" |
IndexPolicy | { pk?: IndexPolicyKey, sk?: IndexPolicyKey } (each key defaults to "preserve" when omitted) |
GsiUpdateResult | { sets: Record<string, string>, removes: ReadonlyArray<string> } |
Marshaller
Section titled “Marshaller”Thin wrapper around @aws-sdk/util-dynamodb.
import { Marshaller } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
toAttributeMap | (record) => Record<string, AttributeValue> | Marshall JS object to DynamoDB format |
fromAttributeMap | (item) => Record<string, unknown> | Unmarshall DynamoDB format to JS object |
toAttributeValue | (value) => AttributeValue | Marshall a single value |
fromAttributeValue | (av) => unknown | Unmarshall a single value |
DynamoClient
Section titled “DynamoClient”Effect service wrapping AWS SDK DynamoDBClient.
import { DynamoClient } from "effect-dynamodb"Service Construction
Section titled “Service Construction”| Export | Type | Description |
|---|---|---|
DynamoClient | Context.Service | Effect service class |
DynamoClient.layer(config) | Layer<DynamoClient> | Create live layer with region + optional endpoint/credentials |
DynamoClient.layerConfig(config) | Layer<DynamoClient, ConfigError> | Create live layer from Effect Config providers. Accepts { region, endpoint?, credentials? } as Config.Config<...> values |
compressors option | ReadonlyArray<Compressor> | On layer / layerConfig: also provide a Compression registry for packed storage (the layer’s output becomes DynamoClient | Compression); it does not survive a wrapper layer that consumes this layer through Layer.provide, so prefer Compression.layer([...]) merged into the application layer. On DynamoClient.make({ ..., compressors }): bind the registry into every operation of that client, ahead of any ambient Compression |
Service Methods
Section titled “Service Methods”| Method | Description |
|---|---|
createTable(input) | Create a DynamoDB table |
deleteTable(input) | Delete a DynamoDB table |
putItem(input) | Put a single item |
getItem(input) | Get a single item |
deleteItem(input) | Delete a single item |
updateItem(input) | Update an item with expression |
query(input) | Query a table or index |
scan(input) | Scan a table or index |
batchGetItem(input) | Batch-get up to 100 items |
batchWriteItem(input) | Batch-write up to 25 items |
transactGetItems(input) | Transact-get up to 100 items |
transactWriteItems(input) | Transact-write up to 100 items |
// Standard layerDynamoClient.layer({ region: "us-east-1" })
// DynamoDB LocalDynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" },})
// Config-based (reads from Effect Config — e.g., env vars)DynamoClient.layerConfig({ region: Config.String("AWS_REGION"), endpoint: Config.option(Config.String("DYNAMO_ENDPOINT")),})Aggregate
Section titled “Aggregate”Graph-based composite domain model for DynamoDB. Binds a Schema.Class hierarchy to a DAG of underlying entity types sharing a partition key.
import { Aggregate } from "effect-dynamodb"Construction
Section titled “Construction”| Export | Type | Description |
|---|---|---|
make | Overloaded | Create a sub-aggregate or top-level aggregate (see below) |
one | (name, { entityType }) => OneEdge | Create a one-to-one edge descriptor |
many | (name, config) => ManyEdge | Create a one-to-many edge descriptor |
ref | (entity) => RefEdge | Create a ref edge (inline hydration, no separate DynamoDB item) |
Sub-aggregate form — Aggregate.make(Schema, { root, edges }):
Returns a SubAggregate<TSchema> with a .with(config) method for discriminator binding.
const TeamSheetAggregate = Aggregate.make(TeamSheet, { root: { entityType: "MatchTeam" }, edges: { coach: Aggregate.one("coach", { entityType: "MatchCoach" }), players: Aggregate.many("players", { entityType: "MatchPlayer" }), },})Top-level form — Aggregate.make(Schema, { table, schema, pk, collection, root, refs?, edges }):
Returns an Aggregate<TSchema, TKey> with get, create, update, delete operations.
const MatchAggregate = Aggregate.make(Match, { table: MainTable, schema: CricketSchema, pk: { field: "pk", composite: ["id"] }, collection: { name: "match" }, root: { entityType: "MatchItem" }, refs: { Team: Teams, Player: Players, Coach: Coaches, Venue: Venues }, edges: { venue: Aggregate.one("venue", { entityType: "MatchVenue" }), team1: TeamSheetAggregate.with({ discriminator: { teamNumber: 1 } }), team2: TeamSheetAggregate.with({ discriminator: { teamNumber: 2 } }), },})Operations
Section titled “Operations”| Operation | Signature | Description |
|---|---|---|
aggregate.get(key) | Effect<Domain, AggregateAssemblyError | DynamoError | ValidationError> | Fetch and assemble by partition key |
aggregate.create(input) | Effect<Domain, AggregateWriteError | ConditionalCheckFailed> | Create from input (ref IDs hydrated, sub-aggregate transactions). Fails with ConditionalCheckFailed, writing nothing, when the aggregate’s root item already exists |
aggregate.update(key, fn) | Effect<Domain, AggregateWriteError> | Fetch → mutate → diff → write changed groups. fn receives UpdateContext with { state, cursor, optic, current } |
aggregate.delete(key) | Effect<void, AggregateAssemblyError | DynamoError> | Remove all items in the partition |
aggregate.list(key, options?) | Effect<ListResult<Domain>, AggregateAssemblyError | DynamoError | ValidationError> | Page aggregates off the list index. options: limit (at most n aggregates), pageSize (rows examined per request), filter (server-side FilterExpression, callback or shorthand; an empty part under or() / not() or an or() with no parts fails with a ValidationError before sending; an isIn with no values is sent, and DynamoDB’s rejection named), reverse, cursor. Returns { data, cursor }; cursor is null only when exhausted, and is always null on a sharded (cardinality) list, which rejects a passed cursor with EDD-9051 |
Edge Types
Section titled “Edge Types”| Type | Description |
|---|---|
OneEdge | One-to-one edge: { _tag: "OneEdge", name, entityType } |
ManyEdge | One-to-many edge: { _tag: "ManyEdge", name, entityType, edgeAttributes?, sk? } |
RefEdge | Inline ref edge: { _tag: "RefEdge", entity } — no separate DynamoDB item |
AggregateEdge | Union: OneEdge | ManyEdge | RefEdge |
Interfaces
Section titled “Interfaces”| Type | Description |
|---|---|
SubAggregate<TSchema> | Composable sub-aggregate with .with(config) for discriminator binding |
BoundSubAggregate<TSchema> | Discriminator-bound sub-aggregate, ready to embed in a parent |
Aggregate<TSchema, TKey> | Top-level aggregate with CRUD operations |
UpdateContext<TIso, TClass> | Context provided to update mutation: { state: TIso, cursor: Cursor<TIso>, optic: Optic.Iso<TIso, TIso>, current: TClass } |
Type Extractors
Section titled “Type Extractors”| Type | Description |
|---|---|
Aggregate.Type<A> | Assembled domain type (e.g., Match) |
Aggregate.Key<A> | Partition key type |
Type Guards
Section titled “Type Guards”| Export | Description |
|---|---|
isOneEdge(edge) | Check if edge is OneEdge |
isManyEdge(edge) | Check if edge is ManyEdge |
See Aggregates & Refs for details.
EventStore
Section titled “EventStore”Typed, Effect-native event sourcing on DynamoDB. Provides a Decider model for command-event-state aggregates, an EventStream repository per stream type, optimistic concurrency via stream versioning, and a commandHandler combinator for the read-decide-append cycle.
import { EventStore } from "effect-dynamodb"Construction
Section titled “Construction”| Export | Description |
|---|---|
EventStore.makeStream({ table, streamName, events, streamId, metadata?, snapshot?, casing?, indexes? }) | Create an EventStream<TEvent, TStreamIdFields, TMetadata, TState, TIndexName, TIndexProjections, TPartitionedIndexName> bound to a Table. events is an array of Schema.TaggedClass event types, streamId.composite lists the stream-id composite fields, optional metadata is a Schema. Optional snapshot is { schema, mode?, every? } — mode is "after-append" (default) or "inline" (see Inline snapshots). Optional casing sets the key casing for the stream (stream name, stream ids, command ids); omitted, the stream name is lower-cased and the rest follows the schema — see Stream key casing. Optional indexes declares stream indexes: { [name]: { type?: "lsi" | "gsi", index, sk, pk?, key: (event, version, streamId) => string | undefined, partition?: (event, version, streamId) => string | undefined, snapshot?: (state, asOfVersion, streamId) => { partition, key } | undefined, projection?: "all" | "withoutMetadata" | "keys" } } — see Stream indexes. partition and snapshot are GSI-only: partition derives the index partition (raw) so several streams share one partition of a GSI, and snapshot (requires partition and a snapshot config) puts the stream’s snapshot item in it — see Shared indexes. Within one append, the events’ and inline snapshot’s partitions must agree (ValidationError otherwise). projection (default "all") is the DynamoDB index projection, what the index copies from each event (unrelated to inline read-model projections) — see Index projection; it must match the deployed index. A malformed entry (including partition / snapshot on an LSI, or snapshot without partition), an unknown projection or a member an entry does not declare (a misspelled projection, also a type error) throws [EDD-9063]. Optional storedAs: { data: DynamoModel.Packed(...) } stores event data packed, and snapshot.storedAs the snapshot state — see Packed Storage; any other storedAs key throws [EDD-9079]. |
EventStore.bind(stream) | Resolve DynamoClient and TableConfig from context and return a BoundEventStream whose operations have R = never. Use inside Context.Service make effects. |
EventStore.indexDefinitions(...streams) | CreateTable fragments for the streams’ indexes: { AttributeDefinitions, LocalSecondaryIndexes?, GlobalSecondaryIndexes? }, to merge into your own CreateTable input (the table’s pk / sk are not included). Each index’s Projection follows its projection: "all" → ALL; "withoutMetadata" → INCLUDE of __edd_e__, data, eventType, streamId, timestamp, version; "keys" → INCLUDE ["__edd_e__"] (not KEYS_ONLY: every read filters on __edd_e__). Tables deployed with CDK or Terraform must copy this Projection exactly; the library migrates nothing. An LSI must be created with the table and caps every partition key value’s item collection at 10 GB. Throws [EDD-9067] for conflicting definitions of one physical index, including two streams declaring it with different projections. A derived partition or a snapshot entry is not part of the definition: streams declaring one GSI alike share it, emitted once; a "withoutMetadata" index any of them indexes snapshots in also projects asOfVersion and state. |
Stream index projection
Section titled “Stream index projection”projection on a stream index entry is the DynamoDB index projection (which event attributes the index stores), not an inline read-model projection. Default "all"; existing streams are unchanged and the library migrates nothing.
projection | Emitted Projection | Index write per event | Read through an LSI | Read through a GSI | metadata type |
|---|---|---|---|---|---|
"all" (default) | ALL | the full event | one Query | one Query | the stream’s metadata type |
"withoutMetadata" | INCLUDE of __edd_e__, data, eventType, streamId, timestamp, version (6 NonKeyAttributes) | the event without metadata | one Query | one Query | undefined |
"keys" | INCLUDE ["__edd_e__"], not KEYS_ONLY (1 NonKeyAttribute) | the keys and __edd_e__ | one Query with Select: ALL_ATTRIBUTES; DynamoDB fetches each event from the table | one Query, then BatchGetItem per page (100 keys per request, unprocessed keys retried, missing keys re-read once with ConsistentRead, index order kept) | the stream’s metadata type |
"keys"trades write and storage cost for read cost: each event read through it costs a table read, rounded up to 4 KB.Query.countnever fetches.__edd_e__is always projected: every read filters on it, and DynamoDB refuses a filter on an attribute an index does not project.- The declared projection must match the deployed index. An LSI’s projection is fixed at table creation, and a GSI’s cannot be updated (delete and re-create it). With CDK or Terraform, copy each
ProjectionfromindexDefinitionsexactly. A deployed index without__edd_e__fails every read with DynamoDB’s “does not project one or more filter attributes”DynamoValidationError; one projecting less than an"all"/"withoutMetadata"declaration fails with aValidationError(operation: "EventStore.index.decode") naming the index. - See Index projection in the tutorial.
Decider
Section titled “Decider”A Decider<State, Command, Event, E = never> encodes one aggregate’s command-event-state triad:
| Field | Type |
|---|---|
decide | (command: Command, state: State) => Effect<ReadonlyArray<Event>, E> |
evolve | (state: State, event: Event) => State — may mutate state in place; the handler’s state is always the evolve fold |
initialState | State |
Operations
Section titled “Operations”EventStream and BoundEventStream expose the same operations. The only difference is the R parameter — EventStream ops require DynamoClient | TableConfig, BoundEventStream ops have R = never.
| Method | Returns |
|---|---|
append(streamId, events, expectedVersion, options?) | Effect<AppendResult<TEvent>, AppendError, R> — atomically append events with optimistic concurrency. AppendError is VersionConflict | StreamSealed | DuplicateCommand | AdditionalItemConditionFailed | AppendTooLarge | DynamoClientError | ValidationError | TransactionCancelled | UniqueConstraintViolation | OptimisticLockError | ConcurrentModification; the last three come from guarded puts in additionalItems. Each event becomes a Put with attribute_not_exists(pk) inside a single TransactWriteItems; a request of one item, its one event (no contiguity check, sentinel, snapshot or additional item), is a conditional PutItem with the same item, condition and ALL_OLD instead, at half the write cost, failing with the same errors (TransactionCancelled with operation: "PutItem" and the reason a transaction gives its item for throttling, an item collection over its size limit or a conflicting transaction); its event carries a random write token (__edd_wt__), so an AWS SDK retry of a put that committed but lost its response succeeds on the writer’s own event, as a transaction’s idempotent retry does. Options: metadata (one value written onto every event, validated against the stream’s metadata schema) or eventMetadata (one entry per event, in order: entry i is written onto event i, and an undefined entry writes no metadata; its length must equal events.length) — mutually exclusive, a type error and a ValidationError when both are given — plus eventTimestamps (one DateTime or undefined per event, in order: entry i is written as event i’s timestamp in UTC ISO format, undefined keeps the append’s own time; any length other than events.length or a non-DateTime entry is a ValidationError; combines with either metadata option; the idempotency sentinel and inline snapshot keep the append’s time, and version, not timestamp, orders the stream), additionalItems, idempotency: { commandId, ttl? }, snapshot (an inline snapshot Put in the same transaction; needs at least one event) and headProof ({ additionalItem: n } or "snapshot": the item whose own condition proves the head is at expectedVersion, in place of the version-contiguity ConditionCheck — additionalItems[n], which must carry a condition of its own, or the inline snapshot conditioned on asOfVersion = expectedVersion, which needs snapshot and a mode: "inline" stream without every, [EDD-9085] otherwise; its failure is a VersionConflict; a malformed proof, an n out of range or an unconditioned item is a ValidationError; the caller’s record must be written by every writer of the stream — see Step 23. "none" writes no check and no proof: the caller guarantees expectedVersion is never ahead of the head (the version of an event read from the stream is; a snapshot’s asOfVersion is only as sound as its writeSnapshot callers) and refuses to append when the last event it read is sealed; a stale version is still a VersionConflict — see Appending without the check), and seal (true makes the append’s last event the stream’s final event: it carries the __edd_sealed__ marker, as does the append’s inline snapshot, and any later event append fails with StreamSealed; zero events or a non-boolean seal is a ValidationError (EventStore.append.seal), and so is a sealing append without snapshot on a mode: "inline" stream without every — see Sealing a stream). additionalItems takes AppendWriteOps: TransactWriteOps and other streams’ appendOps. An append over DynamoDB’s transaction limits is rejected whole, so nothing is written, and fails with AppendTooLarge (over 100 items) or a ValidationError naming the culprit item; it is never split. |
appendOp(streamId, events, expectedVersion, options?) | StreamAppendOp — this stream’s append as an item of ANOTHER append’s additionalItems (or a handler’s): it commits if and only if that append does. With seal: true it closes this stream in the transaction that opens its successor; without it, it is an atomic multi-stream append. A full append of this stream (its contiguity check, event puts, seal marker, optional inline snapshot), enforced independently; options is NestedAppendOptions (append’s options without additionalItems, idempotency and headProof, refused at runtime with ValidationError, EventStore.appendOp, as are zero events); nothing is built until the outer append runs. Its failures name this stream (StreamSealed, terminal, or VersionConflict), judged after the outer stream’s own and ahead of the other additional items; it cannot be a headProof target. In a changeover, its expectedVersion is the version the carried-over state was read at, never a fresh read of the head. All items go through the outer append’s DynamoClient; a bound stream’s op resolves its own TableConfig. Not accepted by Transaction.transactWrite (type error, and ValidationError at runtime) — see A generation changeover |
read(streamId, options?) | Effect<ReadonlyArray<StreamEvent<TEvent>>, DynamoClientError | ValidationError, R> — read all events for a stream in version order. options is ReadOptions ({ consistentRead?: boolean }). A sealing event carries sealed: true. |
readFrom(streamId, afterVersion, options?) | Effect<ReadonlyArray<StreamEvent<TEvent>>, DynamoClientError | ValidationError, R> — read events strictly after a given version; { toVersion } stops the key condition at that version (inclusive), { consistentRead } as for read. |
currentVersion(streamId, options?) | Effect<number, DynamoClientError | ValidationError, R> — current head version (0 if the stream is empty). |
readLatest(streamId, options?) | Effect<LatestState<TState, TEvent>, DynamoClientError | ValidationError, R> — the snapshot (if any), the events after it (ascending) and the head version, in one Query when the snapshot is current. options is ReadLatestOptions: consistentRead, and verifySnapshot: false (mode: "inline" without every only, [EDD-9068] otherwise; ignored without a snapshot config) to read the snapshot item alone with one GetItem, returning { snapshot, events: [], version: asOfVersion } with an unverified version (the verified read when there is no snapshot). seal is the sealing event when the read found one (any marked event it fetched; with verifySnapshot: false, a marked snapshot costs one GetItem of the sealing event); an unverified read of a snapshot left unmarked does not report it. seal.version < version means events were written past the seal. |
writeSnapshot(streamId, state, asOfVersion) / readSnapshot(streamId) | Snapshot primitives on a stream with a snapshot config. Writes are monotonic; readSnapshot returns Option<Snapshot<TState>>. writeSnapshot is one conditional PutItem that never writes the seal marker: it must not target a sealed head with an unmarked snapshot, or a headProof: "snapshot" append can land past the seal — see Head proofs and sealed streams. |
readIndex(name, streamId, options?) | Effect<ReadonlyArray<StreamEvent<TEvent, M>>, DynamoClientError | ValidationError, R> — read a stream index’s sub-stream in index order. options: beginsWith or between, reverse, limit, consistentRead (DynamoDB rejects it on a GSI; the error names the index), and partition — required (by the types) for an index with a derived partition, refused with ValidationError for any other; the read then returns streamId’s events in that partition (filtered on the table pk). M is undefined for a "withoutMetadata" index (its reads drop metadata) and the stream’s metadata type otherwise. A "keys" LSI is read with Select: ALL_ATTRIBUTES; a "keys" GSI is read with the query, then BatchGetItem for each page’s events (100 keys per request, unprocessed keys retried, DynamoError after 5 retries), in index order. Rows lacking attributes the declared projection holds fail with a ValidationError (operation: "EventStore.index.decode") naming the index. |
query.events(streamId) | Query<StreamEvent<TEvent>> — raw query handle for use with Query combinators. |
query.index(name, streamId, options?) | Query<StreamEvent<TEvent, M>> — raw query handle over a stream index, typed and read as readIndex is (options.partition for an index with a derived partition; missing, or passed for another index, it throws [EDD-9080]) (a "keys" index hydrates; Query.count never fetches). A Query.filter on a "withoutMetadata" or "keys" index may name only projected attributes; use Query.filterBy. |
indexes | { [name]: StreamIndexSettings } — each index’s { type, index, pk, sk, projection } (projection resolved, "all" when omitted). |
measureAppend(streamId, events, expectedVersion, options?) | Effect<AppendMeasurement, ValidationError, TableConfig> — the bytes append would write, by DynamoDB’s item-size rules: { events, sentinel, snapshot, transactionBytes }. Built by append’s own item builder; nothing is read or written. additionalItems are not accepted; a headProof ("none" included) leaves the contiguity check out of transactionBytes. An append sent as a PutItem (one event and nothing else) is not subject to the 4 MB transaction limit. See Sizing |
measureSnapshot(streamId, state, asOfVersion) | Effect<number, ValidationError, TableConfig> — the item size of the snapshot writeSnapshot would write |
decodeStreamImage(image, options?) | Effect<Option<StreamEvent<TEvent, M>>, ValidationError> — decode a DynamoDB Streams NewImage / OldImage (DynamoDB JSON with B as bytes or base64, or unmarshalled) into the StreamEvent the read paths return; Option.none() for an image that is not one of this stream’s events |
decodeStreamImage(image, { raw: true }) | Effect<Option<RawStreamEvent<TEvent, M>>, ValidationError> — the same, each event also carrying rawData: its data as stored, in wire form (parsed JSON of a packed value, or the unmarshalled map), unmodelled fields included |
storedAs | { data: PackedConfig | undefined, state: PackedConfig | undefined } — the stream’s resolved storage settings |
streamIdFields | The stream id’s composite fields, in declaration order |
eventMarker | The __edd_e__ value of the stream’s event items (<streamname>.event, lower-cased) |
parseStreamId(streamId) | Effect<StreamIdInput, ValidationError> — the structured id of a StreamEvent.streamId (composites joined with #); a multi-field id whose part count differs from its field count (a value containing #) is ambiguous and fails |
BoundEventStream additionally exposes provide(effect) as an escape hatch to push services into an arbitrary effect.
Command handler
Section titled “Command handler”| Export | Description |
|---|---|
EventStore.commandHandler(decider, stream, options?) | Build a (streamId, command, options?) => Effect<CommandHandlerResult, …, R> that loads (strongly consistent by default), decides, folds the new events and appends atomically. Dual API: commandHandler(decider, stream) (data-first) or stream.pipe(EventStore.commandHandler(decider)) (data-last). Works with both EventStream (R = DynamoClient | TableConfig) and BoundEventStream (R = never). On a no-op command (decide returns []) the handler returns the current state and version without writing. A load that finds the stream sealed fails with StreamSealed before decide (after DuplicateCommand for a committed commandId, ahead of an If-Match mismatch). |
Handler options (CommandHandlerOptions):
| Option | Description |
|---|---|
retry | A number or Schedule; re-runs the whole load–decide–append cycle on VersionConflict only. Default: no retry. StreamSealed and DuplicateCommand are terminal. A VersionConflict naming another stream (an appendOp in additionalItems) is retried even with an expectedVersion, whose If-Match is checked again on the reload |
idempotency | { ttl? } — makes a per-call commandId required and writes a dedup sentinel with the events |
consistentRead | Load state with strongly consistent reads. Default true |
loadState | (streamId) => Effect<Option<LoadedState<State>>, EL, RL> — load the state and head from the caller’s own record instead of the stream; None is a new stream (initial state, version 0). The If-Match check, decide and the append run as before, a VersionConflict re-runs it under retry, and a no-op or domain error is answered from the record. EL / RL join the handler’s error and requirements (a bound handler’s only ones). Read strongly consistent. A version that is not a non-negative integer, or a result that is not an Option, is a ValidationError (EventStore.commandHandler.loadState), never retried. Return sealedVersion for a sealed stream: the handler fails with StreamSealed before decide (a sealedVersion that is not a non-negative integer, or is below version, is a ValidationError). Without headProof the append keeps the contiguity check. The snapshot is not read: with every, one is written when an append crosses a multiple of every. With verifySnapshot: false or headProof: "snapshot", creating the handler throws [EDD-9085] — see Step 23 |
headProof | { additionalItem: n } or "snapshot" — the append headProof for every append the handler makes. { additionalItem: n }: every call that appends must return additionalItems[n], conditioned on the loaded version (ValidationError otherwise). "snapshot": a mode: "inline" stream without every only ([EDD-9085] when the handler is created otherwise), applied when the load found the snapshot at the head (a verified load with no event after it, or an unverified one); otherwise that append writes the check. "none" is not accepted: excluded from the type, and a ValidationError (EventStore.commandHandler.headProof) on every call, before anything is read or written, for an untyped caller. Default: the check is written — see Proving the head with the snapshot |
verifySnapshot | Default true. false loads state from the snapshot item alone (one GetItem) on a mode: "inline" stream without every; any other snapshot config throws [EDD-9068] when the handler is created, and a stream without one ignores it. Only a successful append is returned unconfirmed: a domain error, a no-op or an append conflict is checked against the head (one Query for events after the snapshot’s version, which reads no items while the snapshot is current, or a verified load after an append conflict without expectedVersion). Falls back to a verified load when the snapshot is missing and before an If-Match mismatch is reported; an If-Match at a lagging snapshot’s version answers as the verified load would (DuplicateCommand, or VersionConflict with the head as actualVersion). Without expectedVersion, a stale snapshot makes decide (and a function-form additionalItems, when both decisions append) run a second time on the verified state; a snapshot at the head after an append conflict is a race left to retry |
Per-call options (CommandOptions):
| Option | Description |
|---|---|
metadata | One metadata value written onto every event the decision appends, validated against the stream’s metadata schema. Fixed before decide runs |
eventMetadata | A function of the Decision returning one metadata entry per event (undefined for none), directly or as an Effect whose error and requirements join the handler’s. Runs after the fold and before a function-form additionalItems; not called on a no-op; re-run on every attempt. A result of the wrong length fails with ValidationError (EventStore.commandHandler.eventMetadata), never retried. Mutually exclusive with metadata — see Event metadata |
eventTimestamps | A function of the Decision returning one DateTime per event (undefined keeps the append’s own time), passed to append as eventTimestamps. Returns the array directly or an Effect of it, whose error and requirements join the handler’s (its own E4 / R4); a failure of the Effect is not retried. Runs after eventMetadata and before a function-form additionalItems; not called on a no-op; re-run on every attempt. A result of the wrong length or with a non-DateTime entry fails with ValidationError (EventStore.commandHandler.eventTimestamps), never retried. Combines with either metadata option — see Original event times |
commandId | The delivery’s id; required with idempotency |
expectedVersion | If-Match: a loaded version other than this fails with VersionConflict (carrying actualVersion) before decide runs. Never retried |
additionalItems | A static array of transact ops, or a function of the Decision ({ events, state, previous, version }) returning the ops or an Effect of them, committed in the same transaction as the events. The ops are AppendWriteOps: another stream’s appendOp rides here too, its expectedVersion taken from the decision |
Change-feed projections
Section titled “Change-feed projections”Read models folded from DynamoDB Streams in version order, gap-filled from the event store — see Projections from DynamoDB Streams.
| Export | Description |
|---|---|
EventStore.projection({ stream, readModel, key, initial, evolve, checkpoint, terminal?, toItem?, retry? }) | An EventProjection of one source stream into a read-model entity. key(streamId, event) is the row an event folds into (the stream id alone: one row per instance; with event fields: several rows per instance, each fed by that instance only; an Option, Option.none() addressing no row — acknowledged, in ignored, and apply then returns an Option); initial(streamId, event) the state a new row starts from; evolve(state, event) returns Effect<State, E, R> (re-run on lost-race retries — keep it pure); checkpoint names a numeric model field holding the row’s checkpoint ({ field, offset } stores version + offset), set by the library — it may be optional, for rows written before the projection (absent: no checkpoint, guarded by attribute_not_exists); terminal(error) marks evolve failures that reject their event (skipped, reported in rejected, never creating a row on their own) instead of failing the row; toItem(state, { streamId, key }) maps the folded state to the row as written; retry (number or Schedule) bounds lost-race retries — default a jittered, growing delay up to 12 times. A versioned, softDelete or timeSeries read model, a checkpoint the model lacks and a non-integer offset throw [EDD-9081] |
projection.applyBatch(events, { concurrency? }) | Effect<ProjectionBatchResult, never, R> — per row: sort and deduplicate, read the row strongly consistent (Hidden fields included), drop events at or below its checkpoint (alreadyApplied), fill gaps with one strongly consistent readFrom bounded at the highest delivered version (none when the batch’s events of the stream cover every version from the checkpoint) keeping the row’s events, fold, and write once with put conditioned on the checkpoint, keeping createdAt (create for a new row; nothing when a new row’s events were all rejected); a lost race re-reads. outcomes are Applied (key, applied, rejected, alreadyApplied, state: Option, version, positions) or Failed (key, error, positions); failedPositions the earliest input position of each failed row; ignored the positions of events addressing no row. A throwing key / initial / toItem fails its row (or stream instance) with ValidationError. concurrency defaults to 10 |
projection.apply(event) | applyBatch([event]) with the row’s failure in the error channel |
projection.catchUp(streamId) | Effect<ProjectionCaughtUp, ProjectionError, R> — bring the row of the stream’s latest event to the head |
projection.rebuild(streamId) | Effect<ProjectionRebuilt, ProjectionError, R> — re-derive every row of the stream instance from version 1 ({ rows: [{ key, state, version, rejected }] }); returns no applied events. Pause live delivery for the instance while it runs |
projection.processBatch(records, options) | Decode the records’ images (default: an INSERT’s NewImage; image accessor for your own shape), applyBatch, and build the partial batch response: { outcomes, decodeFailures, failedRecords, response }, outcome positions indexing records. source: "lambda" | "pipe" or identifier accessor; onDecodeFailure: "report" (default) or "skip" (acknowledges the record; the next event of its row still gap-fills through the stored event and fails). Fails with StreamRecordIdentifierMissing for a failed record without an identifier |
EventStore.processBatch(records, { projections, streams?, ...options }) | One feed, several projections: each record decoded once (bound projections under their own context) and routed to every projection of its stream (others skipped); outcomes per projection name, positions indexing records; one response across all. streams names streams whose events come back in unrouted ({ position, stream, event }), whether or not a projection also reads them. A marker shared by two streams dies with [EDD-9082] |
EventStore.bindProjection(projection) | Resolve the client, both tables, evolve’s requirements and the Compression registry; every operation then has R = never. Checks the stream’s codecs as bind does ([EDD-9078]) |
projection options revision, removal, outbox | revision: a numeric model field the library owns, set one higher by every write and conditioned on (attribute_not_exists when absent); may be an existing write counter; required by outbox. removal: { ttl? }: evolve may return EventStore.removeRow, and the row becomes a tombstone at its own key (__edd_e__ <entityType>#tombstone) keeping the checkpoint and revision, absent to every entity read by key (the read model’s create may replace it, carrying its checkpoints over). outbox: { schema, effects(event, next, context), table?, keys?, index?, claimFor? }: effects written as outbox items (<entityType>#outbox) in the same TransactWriteItems as the row. removal / outbox on a read model with unique constraints, and outbox without revision, throw [EDD-9081] — see Multi-source Projections, Tombstones and the Outbox |
projection.drain(key, deliver) / drainPending(deliver) | Deliver a row’s outbox effects strictly in order — claim the head item, deliver(effect, meta), delete it, repeat — at least once (claimFor). { delivered, undelivered }; a deliver failure leaves the effect at the head. drainPending sweeps every row with pending effects through outbox.index. deliver on applyBatch / processBatch drains each row right after its write (undelivered per outcome) |
EventStore.multiProjection({ readModel, initial, sources: (source) => ({ name: source(stream, { key, evolve, mode?, terminal? }) }), checkpoints: { field?, sources? }, revision, toItem?, retry?, removal?, outbox?, sourcesOf? }) | A read model fed by several streams: a checkpoint per source instance (checkpoints.field, a Record<string, number> keyed <source>#<streamId>; or per-source { field, offset?, instanceField } for legacy rows — another instance goes to the map, or fails with ProjectionSourceConflict), each write guarded by revision. Every checkpoint, instance, map and revision field may be optional in the model: absent reads as none, and an empty map is not written. mode: "fold" (gap-filled) or "latest" (newest event per instance, falling back newest-first past terminal rejections; older ones superseded). An instance whose evolve fails (not terminal) is left out of the write and reported as its own Failed outcome; the row’s other instances apply. revision: { field, initial } sets a new row’s first value. applyBatch takes decoder-style { stream: <source>, event } events; catchUp(rowKey); rebuild(rowKey, { instances }) (or sourcesOf); processBatch; drain / drainPending. bindProjection and EventStore.processBatch accept it next to single-source projections |
EventStore.removeRow | Returned by evolve to remove the row (with removal) |
EventStore.skipEvent | Returned by evolve to skip the event: reported in skipped (never applied), no outbox effects, and no write when the row’s events are all skipped or already applied. The instance’s checkpoint stops at its last applied or rejected event, so a skipped event above it is decided again later. In "latest" mode a skip falls back to the next older event |
EventStore.decoder({ [name]: stream }) | (image) => Effect<Option<{ stream: name, event }>, ValidationError> — dispatch an image to its stream by __edd_e__; Option.none() for anything else. Accepts EventStream or BoundEventStream. Two streams with one event marker throw [EDD-9082] |
EventStore.eventFilterPattern(streams) | { eventName: ["INSERT"], dynamodb: { NewImage: { __edd_e__: { S: markers } } } } — JSON.stringify into an event source mapping’s or a Pipe’s FilterCriteria.Filters[].Pattern |
EventStore.batchResponse(failedRecords, { source } | { identifier }) | Effect<{ batchItemFailures: [{ itemIdentifier }] }, StreamRecordIdentifierMissing> — dynamodb.SequenceNumber for source: "lambda", eventID for "pipe", or your accessor |
Fold helpers
Section titled “Fold helpers”| Export | Description |
|---|---|
EventStore.fold(decider, events) | Pure: fold a list of StreamEvent<TEvent> through decider.evolve starting from decider.initialState. Dual API. |
EventStore.foldFrom(decider, startState, events) | Pure: fold from a supplied starting state (e.g. snapshot + delta events). Dual API. |
| Type | Shape |
|---|---|
StreamEvent<A> | { streamId, version, eventType, data: A, metadata, timestamp, sealed? } — sealed is true on a sealing event only; not reported by a "withoutMetadata" index or by currentVersion. timestamp is an ISO 8601 UTC string: the append’s Clock time, or the event’s eventTimestamps entry |
AppendResult<A> | { version: number, events: ReadonlyArray<A> } |
CommandHandlerResult<State, Event> | AppendResult<Event> & { state: State } |
Decision<State, Event> | { events, state, previous, version } — handed to a function-form additionalItems, to eventMetadata and to eventTimestamps. previous and state are the same reference when evolve mutates in place |
HeadProof | { readonly additionalItem: number } | "snapshot" | "none" — append’s headProof; the handler’s is Exclude<HeadProof, "none"> |
LoadedState<State> | { state: State, version: number, sealedVersion?: number } — what loadState returns inside its Option. sealedVersion says the stream is sealed at that version; it may be version + 1 (a record-proof stream keeps its record one below the seal) |
AppendOptions<M, State> | append’s options: { additionalItems?: ReadonlyArray<AppendWriteOp>, idempotency?, snapshot?, headProof?: HeadProof, eventTimestamps?: ReadonlyArray<DateTime | undefined>, seal?: boolean } and at most one of metadata?: M / eventMetadata?: ReadonlyArray<M | undefined>. A type alias (an intersection with a union), so it cannot be extended by an interface; likewise CommandOptions and IdempotentCommandOptions |
NestedAppendOptions<M, State> | appendOp’s options: AppendOptions without additionalItems, idempotency and headProof |
StreamAppendOp / AppendWriteOp | StreamAppendOp is what appendOp returns; AppendWriteOp is TransactWriteOp | StreamAppendOp, the element type of append’s and the handler’s additionalItems. Passing a TransactWriteOp[] in still compiles; code that reads these option types and forwards the value to Transaction.transactWrite must annotate it ReadonlyArray<TransactWriteOp> |
EventMetadataInput<State, Event, M, E3, R3> | (decision: Decision<State, Event>) => ReadonlyArray<M | undefined> | Effect<ReadonlyArray<M | undefined>, E3, R3> — a handler’s eventMetadata |
ReadOptions | { consistentRead?: boolean } |
ReadLatestOptions | ReadOptions & { verifySnapshot?: boolean } — readLatest’s options |
LatestState<State, Event> | { snapshot: Option<Snapshot<State>>, events: ReadonlyArray<StreamEvent<Event>>, version: number, seal?: StreamEvent<Event> } — seal is kept out of events when the snapshot already folds it in |
Snapshot<State> | { state, asOfVersion, timestamp } |
SnapshotMode | "after-append" | "inline" |
ReadIndexOptions | { beginsWith? | between?, reverse?, limit?, consistentRead? } |
StreamIndexSettings | { type: "lsi" | "gsi", index, pk, sk } |
EventStream<TEvent, TStreamIdFields, TMetadata> | Repository interface returned by makeStream |
BoundEventStream<TEvent, TStreamIdFields, TMetadata> | Same shape as EventStream with R = never on every operation |
EventProjection<S, A, TStreamIdFields, M, Err, R> | Returned by projection; bindProjection gives R = never |
ProjectionApplied<S, A, M, K, E> | { streamId, key, applied, rejected, alreadyApplied, skipped, state: Option<S>, version } — applied is at most once per event; republishing alreadyApplied gives at least once |
ProjectionOutcome | ({ _tag: "Applied", positions } & ProjectionApplied) | { _tag: "Failed", streamId, key, positions, error } |
ReadFromOptions | ReadOptions & { toVersion?: number } — readFrom’s options |
StreamRecord | { eventID?, eventName?, dynamodb?: { SequenceNumber?, NewImage? } } — structural; no aws-lambda dependency |
import { EventStore } from "effect-dynamodb"import { Effect, Schema, Context } from "effect"
class MatchStarted extends Schema.TaggedClass<MatchStarted>()("MatchStarted", { venue: Schema.String,}) {}
class InningsCompleted extends Schema.TaggedClass<InningsCompleted>()("InningsCompleted", { innings: Schema.Number, runs: Schema.Number,}) {}
const MatchEvents = EventStore.makeStream({ table: EventsTable, streamName: "Match", events: [MatchStarted, InningsCompleted], streamId: { composite: ["matchId"] },})
const decider = { initialState: { innings: 0, totalRuns: 0 }, decide: (cmd: { _tag: "Start"; venue: string }, _state) => Effect.succeed([new MatchStarted({ venue: cmd.venue })]), evolve: (state, event) => { if (event._tag === "MatchStarted") return state return { innings: event.innings, totalRuns: state.totalRuns + event.runs } },}
class MatchEventService extends Context.Service<MatchEventService>()( "@app/MatchEventService", { make: Effect.gen(function* () { const stream = yield* EventStore.bind(MatchEvents) const handle = EventStore.commandHandler(decider, stream) return { start: (matchId: string, venue: string) => handle({ matchId }, { _tag: "Start", venue }), history: (matchId: string) => stream.read({ matchId }), } }), },) {}See Event Sourcing for a complete tutorial.
Change feed (effect-dynamodb/change-feed)
Section titled “Change feed (effect-dynamodb/change-feed)”A polled DynamoDB Streams consumer for processes outside Lambda — see Change Feed outside Lambda. A subpath: it needs @aws-sdk/client-dynamodb-streams (an optional peer dependency), which the main entry never imports.
import { CheckpointStore, changeFeed, DynamoStreamsClient, runChangeFeed,} from "effect-dynamodb/change-feed"| Export | Description |
|---|---|
changeFeed({ streamArn, consumer?, startingPosition?, pollInterval?, batchSize?, describeInterval?, maxConcurrentReads?, backoff?, lease? }) | Stream<ChangeFeedBatch, ChangeFeedError, DynamoStreamsClient | CheckpointStore>. Shards found with DescribeStream (paginated) at start and every describeInterval (default 30 s); a drained shard’s children with ShardFilter (CHILD_SHARDS), or, where the filter is unsupported or finds none, the next full describe, run at once and shared by the shards drained meanwhile. A parent is read to its end, every batch acknowledged, before its children; closed shards are checkpointed finished and dropped. Shards are read concurrently, one batch in flight each: a shard reads again only once its batch is acknowledged. maxConcurrentReads (16) bounds the GetShardIterator / GetRecords calls in flight across shards, a permit per call, so no shard starves. One reader per shard per feed: AWS advises at most two readers of a shard, counting every consumer name, Lambda mapping and Pipe on the stream. Resumes AFTER_SEQUENCE_NUMBER a shard’s checkpoint; startingPosition ("TRIM_HORIZON" default, or "LATEST") applies when the consumer has no checkpoints at all. Expired iterators are re-acquired; a position past retention fails with ChangeFeedTrimmed, as does a restart that finds a checkpointed shard, or an unread shard’s unfinished parent, gone from the stream (a fresh start checkpoints already-gone parents as finished). A shard missing from the first describe is described again and probed with GetShardIterator(TRIM_HORIZON) before it counts as gone; one that exists is read, a parent before its children. An open shard without an iterator is retried, never finished. Throttling and InternalServerError retry without limit on backoff (jittered, initial 100 ms to max 10 s); an idle shard polls every pollInterval (1 s). batchSize is GetRecords.Limit (100, 1–1000). Throws [EDD-9083] for lease.renewEvery ≥ lease.duration / 2, a batchSize outside 1–1000, or a non-positive-integer maxConcurrentReads. consumer ("default") scopes checkpoints and the lease. Ends when the table’s stream is disabled and every shard is drained |
ChangeFeedBatch | { streamArn, shardId, records, firstSequenceNumber, lastSequenceNumber, commit(), complete(response) } — records non-empty, in sequence order, shaped like Lambda’s (ChangeFeedRecord, the SDK’s GetRecords record). commit() checkpoints at lastSequenceNumber; complete({ batchItemFailures }) (identifiers = SequenceNumber) checkpoints at the record before the earliest failure and re-reads the shard from it after a growing backoff — any identifier naming no record fails the whole batch. Both Effect<void, CheckpointStoreError | ChangeFeedLeaseLost>: the first acknowledgement whose write succeeds counts (later ones are no-ops), a failed write can be retried and writes again, and once the lease is lost they fail with ChangeFeedLeaseLost, writing nothing |
runChangeFeed(feed, { projections, streams?, onBatch?, onDecodeFailure?, image?, concurrency? }) | Effect<void, E | CheckpointStoreError | ChangeFeedLeaseLost | StreamRecordIdentifierMissing, R | projections' R> — each batch through EventStore.processBatch(batch.records, { source: "lambda", ... }), onBatch(batch, result) (before the acknowledgement; its failure ends the run unacknowledged), then batch.complete(result.response). Batches of different shards run concurrently |
lease option | { duration? (30 s), renewEvery? (duration / 3, must be < duration / 2), onHeld?: "fail" | "wait", owner? (random UUID) } or false. One lease per consumer and stream in the checkpoint store: taken at start, renewed, released when the stream ends. A renewal the store fails to write is retried until renewEvery before the lease lapses, then fails the feed. Held elsewhere: ChangeFeedLeaseHeld (or wait); taken over while running: ChangeFeedLeaseLost. Once lost, in-flight acknowledgements fail with ChangeFeedLeaseLost. Not a fencing token |
DynamoStreamsClient | Context.Service (@effect-dynamodb/DynamoStreamsClient): describeStream, getShardIterator, getRecords, listStreams, each Effect<Output, DynamoStreamsClientError>. layer({ region, endpoint?, credentials? }), layerConfig({ region, endpoint?, credentials? }) (Effect Configs), layerFromSdkClient(client) |
CheckpointStore | Context.Service (@effect-dynamodb/CheckpointStore): load(feed), commit(feed, shardId, checkpoint), acquireLease(feed, owner, duration), renewLease(feed, owner, duration), releaseLease(feed, owner); feed is { streamArn, consumer }, a checkpoint { _tag: "Sequence", sequenceNumber } or { _tag: "Finished" }. layerMemory (process-local), layerDynamoDB({ tableName, finishedTtl? }) (needs DynamoClient; finishedTtl default 2 days, at least 28 hours — [EDD-9084] below, CheckpointStore.minFinishedTtl), makeMemory() / makeDynamoDB(options) as values |
CheckpointStore.tableDefinition(tableName) | The checkpoint table’s CreateTable input: pk (S, HASH) + sk (S, RANGE), on demand. Items: { pk: "<consumer>#<streamArn>", sk: "shard#<shardId>", sequenceNumber | finished, updatedAt, ttl? } and { pk, sk: "lease", owner, expiresAt }; ttl (epoch s) only on finished shards, finishedTtl out (default 2 days) |
Errors
Section titled “Errors”| Error | Fields | Raised by |
|---|---|---|
ShardIteratorExpired | operation, cause | DynamoStreamsClient (ExpiredIteratorException); the feed re-acquires |
StreamDataTrimmed | operation, cause | DynamoStreamsClient (TrimmedDataAccessException) |
ChangeFeedTrimmed | streamArn, shardId, sequenceNumber, cause | The feed: a shard’s position is past the stream’s retention, or a shard it still owed records from has left the stream — records were lost |
ChangeFeedLeaseHeld | streamArn, consumer, owner, expiresAt | Another process holds the lease |
ChangeFeedLeaseLost | streamArn, consumer, owner | The lease was taken over while running; also an acknowledgement made after the lease was lost |
CheckpointStoreError | operation, cause | A checkpoint store operation failed |
DynamoStreamsClient maps LimitExceededException to ThrottlingError, and otherwise uses the shared DynamoValidationError, InternalServerError, ResourceNotFoundError and DynamoError. ChangeFeedError is those (less the two iterator errors), ChangeFeedTrimmed, the lease errors and CheckpointStoreError.
StorageEncoding
Section titled “StorageEncoding”Helpers for packed storage. Compressors come from the Compression service in context; none of these adds a requirement. The Effect forms cost one Effect per call and look the registry up only for a compressed value; to decode many values with no Effect per value, resolve the registry once (yield* Effect.serviceOption(Compression)) and call a *Sync twin in a loop.
import { StorageEncoding } from "effect-dynamodb"| Export | Type | Description |
|---|---|---|
encode | (storedAs, wire) => Effect<AttributeValue | undefined, ValidationError> | The AttributeValue a wire (encoded) value is stored as: S, B, NULL, or undefined for no attribute |
decode | (stored: AttributeValue) => Effect<unknown, ValidationError> | Read a stored AttributeValue (DynamoDB JSON) by its form (native, S, B as bytes or base64) and return its wire form as plain JSON |
decodeUnmarshalled | (stored: unknown) => Effect<unknown, ValidationError> | The same for an already-unmarshalled value: a JSON string, bytes or base64 text, or a native value returned unchanged |
valueBytes | ({ schema, value, storedAs? }) => Effect<number, ValidationError> | The DynamoDB size of one attribute value as stored: packed under storedAs, or as a native map when it is omitted |
valueBytesSync | ({ schema, value, storedAs?, compressors? }) => number | valueBytes, synchronously, for pure planning code: the schema encode runs synchronously, compressors are passed in (none by default), and a failure throws the ValidationError valueBytes fails with |
encodeSync / decodeSync / decodeUnmarshalledSync | (…, options?: { compressors? }) => … | Synchronous twins of encode / decode / decodeUnmarshalled for pure code: compressors are passed explicitly (a Compression.make(...) registry or a list; none by default) and a failure throws the ValidationError the Effect form fails with |
limits | { item: 409_600, transaction: 4_194_304 } | DynamoDB’s item and TransactWriteItems size limits, in bytes |
Compression
Section titled “Compression”| Export | Description |
|---|---|
Compressor | { codec: "gzip" | "brotli" | "zstd", compress(input: Uint8Array): Uint8Array, decompress(input: Uint8Array): Uint8Array } — a synchronous codec implementation |
Compression | Context.Service holding a CompressionRegistry (get(codec), codecs). Compression.layer(compressors) provides one; Compression.make(compressors) builds one (throws [EDD-9078] on two compressors for one codec or an unknown codec); Compression.empty has none |
node:zlib-backed implementations, from the effect-dynamodb/compression/zlib entry point (the only module that imports node:zlib):
import * as ZlibCompression from "effect-dynamodb/compression/zlib"| Export | Description |
|---|---|
gzip({ level?, maxOutputLength? }) | gzip Compressor |
brotli({ quality?, maxOutputLength? }) | brotli Compressor (text mode) |
zstd({ level?, maxOutputLength? }) | zstd Compressor, level 3 by default. Needs Node 22.15 or later; throws when created on older Node |
available({ maxOutputLength? }) | gzip and brotli, plus zstd when this runtime has it |
DEFAULT_MAX_OUTPUT_LENGTH | 67_108_864 (64 MiB), the default maxOutputLength |
hasZstd() | true when this runtime has zstd |
maxOutputLength caps one value’s decompressed size: 64 MiB by default (DEFAULT_MAX_OUTPUT_LENGTH), any positive integer to change it, Infinity for Node’s own limit only. Going over it fails the read with a ValidationError.
Errors
Section titled “Errors”Tagged error types for precise error handling with catchTag.
import { DynamoError, ThrottlingError, DynamoValidationError, InternalServerError, ResourceNotFoundError, ItemNotFound, ConditionalCheckFailed, ValidationError, TransactionCancelled, TransactionOverflow, UniqueConstraintViolation, OptimisticLockError, ItemDeleted, ItemNotDeleted, ConcurrentModification, UpdateAppliedButUnreadable, DeleteAppliedButUnreadable, CompositeKeyHoleError, CompositeNullableError, RefNotFound, AggregateAssemblyError, AggregateDecompositionError, AggregateTransactionOverflow, CascadePartialFailure, VersionConflict, AppendTooLarge, AdditionalItemConditionFailed, DuplicateCommand, StreamSealed, CompressorNotRegistered,} from "effect-dynamodb"| Error | Tag | Description |
|---|---|---|
DynamoError | "DynamoError" | AWS SDK error wrapper (includes operation and cause) |
ThrottlingError | "ThrottlingError" | AWS SDK throttling — request rate exceeded |
DynamoValidationError | "DynamoValidationError" | DynamoDB rejected the request as malformed |
InternalServerError | "InternalServerError" | Transient DynamoDB failure |
ResourceNotFoundError | "ResourceNotFoundError" | Table or index does not exist |
ItemNotFound | "ItemNotFound" | No item under the key: get, an update of a missing item (unless it is a plain .set() of a complete item), restore with no soft-deleted item or after a concurrent restore won |
ConditionalCheckFailed | "ConditionalCheckFailed" | A .condition() was not met (put, update, delete, create), or an op’s own guard was: create() of an existing item or aggregate, patch() or deleteIfExists() of a missing item, whatever .condition() was added. A lost version race is an OptimisticLockError instead |
ValidationError | "ValidationError" | Schema decode/encode failed, or the operation was refused: an item of a versioned entity with an incarnation token but no version, a write that would overwrite a different v#N snapshot, a replacing put in Batch.write, a write DynamoDB rejected for touching one item twice, passing 4 MB, holding an item over 400 KB, a partition key over 2048 bytes, a sort key over 1024 bytes or an empty key value, a condition over DynamoDB’s expression limits, or an isIn with no values (diagnosed from DynamoDB’s rejection, naming the item, with DynamoDB’s error as the cause), an eventMetadata or eventTimestamps whose length differs from the events (or an eventTimestamps entry that is not a DateTime), a malformed headProof, one naming no additional item or an item without a condition of its own, a "snapshot" proof without snapshot, a loadState result that is not an Option of a non-negative integer version, a condition or filter with an empty part under or() / not() (or an or() with no parts), .consistentRead() on a GSI (an aggregate’s assembly read included) or a query’s key bound DynamoDB rejected (an empty bound, a reversed between; diagnosed, naming the index), a vector search’s topK outside 1–100 (diagnosed), .expectedVersion() on an entity that isn’t versioned, or a stream index read whose rows lack attributes the index’s declared projection holds (operation: "EventStore.index.decode", naming the index: the deployed index likely projects less than declared; the decode error is the cause) |
ProjectionSourceConflict | "ProjectionSourceConflict" | A multi-source projection’s row keeps source’s checkpoint in a field owned by stream instance recorded, arriving addressed the row, and the projection declares no checkpoint map to hold a second instance. Nothing was written |
StreamRecordIdentifierMissing | "StreamRecordIdentifierMissing" | A change-feed record to report in a partial batch response has no identifier (index among the failed records, member read): EventStore.batchResponse / processBatch. Fail the invocation so the source redelivers the batch |
DeleteAppliedButUnreadable | "DeleteAppliedButUnreadable" | A delete with returnValues("allOld") was applied, but the item it removed (item, as stored) could not be decoded. Don’t retry it |
TransactionCancelled | "TransactionCancelled" | Transaction rejected (includes cancellation reasons) |
TransactionOverflow | "TransactionOverflow" | DynamoDB rejected a write’s own transaction (the item, its sentinels and snapshot) for exceeding its 100-item limit; nothing was written. cause holds DynamoDB’s error |
AppendTooLarge | "AppendTooLarge" | DynamoDB rejected an EventStore.append for exceeding the 100-item transaction limit; nothing was written (streamName, streamId, count — the items sent, limit; cause holds DynamoDB’s error). Never split: run a large command as stepped commands, and read count against limit as the step size being too large |
UniqueConstraintViolation | "UniqueConstraintViolation" | Unique constraint violated on put/create/upsert/update, or by a put in transactWrite / append’s additionalItems |
OptimisticLockError | "OptimisticLockError" | A versioned write lost a version race: expectedVersion() did not match, or another writer changed the item between the read and the write of a read-then-write update, or a put, upsert or transaction put lost that race on every attempt. actualVersion is the stored version (entityType, key, expectedVersion, actualVersion) |
ConcurrentModification | "ConcurrentModification" | An unversioned write that reads first (a unique-field change, a computed change to an index composite, a put or upsert of a unique-constrained entity — the last two after every attempt) found that another writer changed one of the attributes it read before its write landed, or a unique sentinel it was releasing changed hands. Nothing was written; retrying recomputes from the new values (entityType, key, attributes, current) |
UpdateAppliedButUnreadable | "UpdateAppliedButUnreadable" | A retain update WAS applied at version, but another write replaced the item before it could be read back, and no snapshot proves what this update wrote. Don’t retry a non-idempotent update on this error; read the item instead (entityType, key, version, reason) |
ItemDeleted | "ItemDeleted" | Item is soft-deleted (get returns this instead of the item) |
ItemNotDeleted | "ItemNotDeleted" | restore found a live item under the key as well as the soft-deleted one (for example, the item was recreated after it was deleted) |
CompositeKeyHoleError | "CompositeKeyHoleError" | EDD-9024 — deprecated in v1.7.1, no longer thrown at runtime. Class export retained for back-compat with consumers who type-imported it for Effect.catchTag handlers. Hole patterns now collapse into the unified per-half can’t-compose rule (drop under sparse, noop-or-cascade-override under preserve). |
CompositeNullableError | "CompositeNullableError" | EDD-9025 — composite attribute Schema includes null. Raised at Entity.make() time. Carries entityType, surface (e.g. "primaryKey", "index:...", "unique:..."), compositeAttribute, schemaPath. |
RefNotFound | "RefNotFound" | Referenced entity not found during ref hydration (entity, field, refEntity, refId) |
AggregateAssemblyError | "AggregateAssemblyError" | Aggregate read path failed — missing items, structural violations, or decode errors (aggregate, reason, key) |
AggregateDecompositionError | "AggregateDecompositionError" | Aggregate write path failed — schema validation or structural error (aggregate, member, reason) |
AggregateTransactionOverflow | "AggregateTransactionOverflow" | Sub-aggregate exceeds 100-item transaction limit (aggregate, subgraph, itemCount, limit, committed). Not checked before sending: DynamoDB rejects the group, nothing from it is written, and DynamoDB’s error is the cause. Each group is its own transaction, so committed names the sub-aggregates earlier groups of the same write already committed, and the message says so; for a failed create (operation: "create"), it adds that the root may be committed, so a retried create fails its root guard: update or delete the partial aggregate |
CascadePartialFailure | "CascadePartialFailure" | Cascade update partially failed in eventual mode (sourceEntity, sourceId, succeeded, failed, errors) |
VersionConflict | "VersionConflict" | Event store version mismatch: an append’s version guard failed (the contiguity check, an event put, or a headProof item’s own condition), or a command handler call’s expectedVersion did not match the loaded version (streamName, streamId, expectedVersion, actualVersion? — set by the pre-decide If-Match check and when a verifySnapshot: false If-Match decision is checked against the head, unset on any other append-time conflict). streamName / streamId name the stream whose check failed: a nested appendOp’s failure names that op’s stream, not the appending one |
AdditionalItemConditionFailed | "AdditionalItemConditionFailed" | A condition the caller set on an EventStore.append({ additionalItems }) op (.condition(), create(), Transaction.check) failed — NOT a version conflict, and never an op the caller set no condition on (streamName, streamId, indices, reasons) |
DuplicateCommand | "DuplicateCommand" | A commandId was already applied to this stream (streamName, streamId, commandId) |
StreamSealed | "StreamSealed" | An append’s stream is sealed (streamName, streamId, sealedVersion: the sealing event’s version, the stream’s final one): an append, a nested appendOp, or a command handler whose load found the seal. Terminal, never retried. Precedence is per stream: within one stream’s items it ranks after DuplicateCommand and ahead of that stream’s VersionConflict; across streams the appending stream’s own verdict comes first, then nested appendOps in op order (the first failing op wins), then the other additional items |
CompressorNotRegistered | "CompressorNotRegistered" | Never in an error channel — the cause of the ValidationError a packed value fails with when it needs a compression codec that is not registered: writing a value over its threshold, or reading a B value whose header names the codec (codec, direction: "compress" | "decompress", entityType, attribute, operation) |
Errors by operation
Section titled “Errors by operation”Each operation declares a fixed error union, whatever the entity’s configuration. Every union also includes DynamoClientError, plus ref and vector errors where the entity has refs or vector indexes. .condition() adds ConditionalCheckFailed to an operation that lacks it.
| Operation | Declared errors (besides DynamoClientError) |
|---|---|
put | ValidationError, UniqueConstraintViolation, OptimisticLockError, ConcurrentModification, TransactionOverflow |
create | as put, plus ConditionalCheckFailed |
upsert | as put, plus ItemNotFound, ConditionalCheckFailed |
update | ItemNotFound, OptimisticLockError, ConcurrentModification, UpdateAppliedButUnreadable, UniqueConstraintViolation, ValidationError, TransactionOverflow |
patch | as update, plus ConditionalCheckFailed |
delete | ItemNotFound, OptimisticLockError, ConcurrentModification, ValidationError, TransactionOverflow, DeleteAppliedButUnreadable |
deleteIfExists | as delete, plus ConditionalCheckFailed |
restore | ItemNotFound, ItemNotDeleted, ValidationError, UniqueConstraintViolation, TransactionOverflow |
purge | ValidationError |
Transaction.transactWrite | ValidationError, TransactionCancelled, UniqueConstraintViolation, OptimisticLockError, ConcurrentModification |
Aggregate create | AggregateWriteError plus ConditionalCheckFailed |
EventStore append / commandHandler (plus the decider’s errors) | VersionConflict, StreamSealed, DuplicateCommand, AdditionalItemConditionFailed, AppendTooLarge, ValidationError, TransactionCancelled, UniqueConstraintViolation, OptimisticLockError, ConcurrentModification |
const db = yield* DynamoClient.make({ entities: { UserEntity }, tables: { MainTable },})const users = db.entities.UserEntity
const user = yield* users.get({ userId: "u-1" }).pipe( Effect.catchTag("ItemNotFound", () => Effect.succeed(null)), Effect.catchTag("DynamoError", (e) => Effect.die(`DynamoDB ${e.operation} failed: ${e.cause}`) ),)