Skip to content

API Reference

Module-by-module reference for all public exports of effect-dynamodb.

Provides annotations, date schemas, storage modifiers, and model configuration for effect-dynamodb.

import { DynamoModel } from "effect-dynamodb"
ExportTypeDescription
Hidden<S>(schema: S) => SMarks a field as hidden from asModel/asRecord decode. Still stored in DynamoDB, visible in asItem/asNative
isHidden(schema: Schema.Top) => booleanCheck if a schema field has the Hidden annotation
identifier<S>(schema: S) => SMarks the primary business identifier on a model. Exactly one per entity. Required for entities referenced via ref
isIdentifier(schema: Schema.Top) => booleanCheck if a schema field has the identifier annotation
getIdentifierField(model: Schema.Top) => { name, schema } | undefinedFind the identifier field in a model’s .fields
ref<S>(schema: S) => SMarks 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) => booleanCheck if a schema field has the ref annotation
getRefAnnotation(schema: Schema.Top) => RefAnnotation | undefinedGet the full ref annotation metadata

All date schemas carry a DynamoEncoding annotation that controls how the field is stored in DynamoDB.

ExportWire TypeDomain TypeDynamoDB Storage
DateStringISO 8601 stringDateTime.UtcString (S)
DateEpochMsepoch millisecondsDateTime.UtcNumber (N)
DateEpochSecondsepoch secondsDateTime.UtcNumber (N)
DateEpoch(opts)auto-detect ms/secondsDateTime.Utcconfigurable
DateTimeZonedISO+offset+zone stringDateTime.ZonedString (S)
UnsafeDateStringISO 8601 stringnative DateString (S)
UnsafeDateEpochMsepoch millisecondsnative DateNumber (N)
UnsafeDateEpochSecondsepoch secondsnative DateNumber (N)
TTLepoch secondsDateTime.UtcNumber (N) — alias for DateEpochSeconds
ExportTypeDescription
storedAs<A>(storageSchema: Schema<A>) => (fieldSchema: Schema<A>) => Schema<A>Override DynamoDB storage format. Type-safe: domain types must match
ExportTypeDescription
configure(model, attributes) => ConfiguredModel<M>Wrap a model with per-field DynamoDB overrides (field renaming, storage encoding, immutable)
isConfiguredModel(value) => booleanCheck if a value is a ConfiguredModel

Values for a field’s storedAs in configure that change how a structured field is laid out on the item.

ExportTypeDescription
SparseMap(options?: { prefix? }) => SparseMapConfigStore a Schema.Record as one top-level attribute per entry — see Sparse Maps
Packed(options: { format: "json", compression?: { codec: "gzip" | "brotli" | "zstd", threshold? } }) => PackedConfigStore 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 PackedConfigCheck if a value is a Packed config
getPackedFields(model) => Record<string, PackedConfig>Domain field name → config, for every field configured storedAs: Packed(...)
ExportTypeDescription
DynamoEncodingKeysymbolAnnotation key for reading DynamoEncoding from schema AST
getEncoding(schema) => DynamoEncoding | undefinedRead the DynamoEncoding annotation from a schema
TypeDescription
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
// Annotations
class Employee extends Schema.Class<Employee>("Employee")({
employeeId: Schema.String,
createdBy: Schema.String,
internalId: Schema.String.pipe(DynamoModel.Hidden),
}) {}
// configure — immutable fields + field overrides
const EmployeeModel = DynamoModel.configure(Employee, {
createdBy: { immutable: true },
})
// Date schemas
class 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 resolution
class Team extends Schema.Class<Team>("Team")({
id: Schema.String.pipe(DynamoModel.identifier),
name: Schema.String,
}) {}
// ref — denormalized reference to another entity
class Selection extends Schema.Class<Selection>("Selection")({
team: Team.pipe(DynamoModel.ref), // Input: teamId, Record: Team
player: Player.pipe(DynamoModel.ref), // Input: playerId, Record: Player
}) {}

Application namespace for key prefixing and versioning.

import { DynamoSchema } from "effect-dynamodb"
ExportTypeDescription
make(config) => DynamoSchemaCreate a schema with name, version, and optional casing
prefix(schema) => stringBuild schema prefix: $name#vN
applyCasing(value, casing) => stringApply a casing to a key part — labels and composite values alike
composeKey(schema, entityType, composites) => stringCompose entity key: $schema#vN#entity_type#composites
composeCollectionKey(schema, collection, composites) => stringCompose collection PK
composeClusteredSortKey(schema, collection, entity, composites) => stringCompose clustered SK
composeIsolatedSortKey(schema, entity, composites) => stringCompose isolated SK
composeUniqueKey(schema, entity, constraint, values) => { pk, sk }Compose unique constraint sentinel keys
composeVersionKey(schema, entity, version) => stringCompose version snapshot SK
composeDeletedKey(schema, entity, timestamp) => stringCompose soft-deleted item SK
composeVersionKeyPrefix(schema, entity) => stringPrefix for version queries
composeDeletedKeyPrefix(schema, entity) => stringPrefix for deleted queries
TypeDescription
DynamoSchemaInterface: { 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#composites

See Modeling for details.


Minimal table definition with Layer-based name injection.

import { Table } from "effect-dynamodb"
ExportTypeDescription
make(config: { schema, entities?, aggregates? }) => TableCreate a table definition with entities and aggregates
definition(table) => TableDefinitionDerive 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.

TypeDescription
TableInterface 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 Layer
MainTable.layer({ name: "MyTable" })
// Typed client auto-derives table schema for creation
const db = yield* DynamoClient.make({
entities: { UserEntity, TaskEntity },
tables: { MainTable },
})
yield* db.tables.MainTable.create()

See Modeling for details.


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"
ExportTypeDescription
make(config) => EntityCreate an entity with model, table, indexes, and optional system fields

make config:

PropertyTypeRequiredDescription
modelSchema.Class | Schema.Struct | ConfiguredModelYesDomain model schema (or configured model with field overrides)
entityTypestringYesDiscriminator stored as __edd_e__
primaryKey{ pk: KeyDef, sk: KeyDef, casing? }YesPrimary key composition rules. Optional casing overrides the schema’s for the primary key
indexesRecord<string, GsiIndexDef>NoGSI 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
timestampsboolean | { created?, updated? }NoAuto-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
versionedboolean | { retain?, field?, ttl? }NoAuto-increment version with optional snapshot retention
softDeleteboolean | { ttl?, preserveUnique? }NoSoft delete with optional TTL
uniqueRecord<string, string[]>NoUnique constraint definitions
refsRecord<string, Entity>NoMap ref field names to their source entities for ref hydration

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).

OperationSignatureDescription
entity.put(input)EntityPutInsert 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)EntityPutInsert 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)EntityPutCreate 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)EntityGetGet a single item by primary key (descriptor)
entity.update(key)EntityUpdateStart 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)EntityUpdateUpdate with attribute_exists — fails with ConditionalCheckFailed if not exists (descriptor)
entity.delete(key)EntityDeleteDelete 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)EntityDeleteDelete 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 moduleBatch get items
entity.batchPut(items)See Batch moduleBatch put items
entity.batchDelete(keys)See Batch moduleBatch delete items

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.UserEntity

Bound entity methods:

MethodDescription
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):

MethodRequiresReturnsDescription
bound.getVersion(key, version)versioned: { retain: true }EffectGet a specific version snapshot
bound.versions(key)versioned: { retain: true }BoundQueryList all version snapshots as a fluent BoundQuery — supports .collect(), .fetch(), .paginate(), .count(), .limit(), .reverse(), .startFrom(), .filter(), .select()
bound.restore(key)softDeleteEffectRestore a soft-deleted item
bound.purge(key)AnyEffectPermanently 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)softDeleteEffectGet a specific soft-deleted item
bound.deleted.list(key)softDeleteBoundQueryList 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):

OperationRequiresDescription
entity.getVersion(key, version)versioned: { retain: true }Get a specific version snapshot (descriptor)
entity.deleted.get(key)softDeleteGet a soft-deleted item (descriptor)
entity.restore(key)softDeleteRestore a soft-deleted item (descriptor)
entity.purge(key)AnyDelete 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

These functions transform entity operations via pipe:

ExportWorks OnDescription
set(updates)EntityUpdateSet fields to new values (dual API)
expectedVersion(n)EntityUpdateOptimistic lock — fail if version doesn’t match. Requires a versioned entity: on any other it fails with a ValidationError before anything is sent
consistentReadEntityGetEnable strongly consistent reads
condition(input)EntityPut, EntityUpdate, EntityDeleteAdd 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)EntityUpdateREMOVE attributes from the item
add(values)EntityUpdateAtomically ADD to numeric attributes
subtract(values)EntityUpdateSubtract from numeric attributes (SET #f = #f - :v)
append(values)EntityUpdateAppend to list attributes (list_append)
deleteFromSet(values)EntityUpdateDELETE elements from Set attributes
returnValues(mode)EntityUpdate, EntityDeleteControl 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)EntityUpdatePropagate source entity changes to target entities that embed it via DynamoModel.ref. Config: { targets, filter?, mode? } (dual API)

Control what type an operation returns:

ExportReturnsDescription
asModelEntity.Model<E>Pure domain object (default for yield*)
asRecordEntity.Record<E>Domain + system fields (version, timestamps)
asItemEntity.Item<E>Full DynamoDB item (all keys + __edd_e__)
asNativeEntity.Marshalled<E>Raw DynamoDB AttributeValue format
TypeDescription
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
ExportDescription
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 operations
const db = yield* DynamoClient.make({
entities: { UserEntity },
tables: { MainTable },
})
const users = db.entities.UserEntity
// CRUD
const user = yield* users.put({ userId: "u-1", email: "a@b.com", ... })
const found = yield* users.get({ userId: "u-1" })
// Update with combinators
yield* users.update({ userId: "u-1" }, Entity.set({ email: "new@b.com" }))
// Update with multiple combinators
yield* users.update(
{ userId: "u-1" },
Entity.set({ email: "new@b.com" }),
Entity.expectedVersion(1),
)
yield* users.delete({ userId: "u-1" })
// Query execution via bound entity
const 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"
ExportTypeDescription
where(conditions)DualAdd sort key conditions (KeyConditionExpression)
limit(n)DualReturn at most n items (a contract on results — accumulates across requests, works under a filter)
pageSize(n)DualFetch in batches of n rows — sets DynamoDB’s Limit (a contract on round trips)
maxPages(n)DualLimit total number of pages fetched
reverseCombinatorReverse sort order (descending)
consistentReadCombinatorEnable 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
ignoreOwnershipCombinatorSkip __edd_e__ entity type filter — for mixed-table scenarios
startFrom(cursor)DualResume pagination from a previous cursor
select(attrs)DualProject specific attributes (returns Query<Record<string, unknown>>)
filterExpr(expr)DualAdd an Expr ADT filter (from callback API)
selectPaths(paths)DualProject path segments (from callback API)
ExportReturnsDescription
executeEffect<Page<A>>Execute and return a page: { items: Array<A>, cursor: string | null }
collectEffect<Array<A>>Execute, fetch all pages, and flatten into a single array
paginateEffect<Stream<Array<A>>>Execute and return a Stream of pages for lazy pagination
countEffect<number>Execute with SELECT COUNT — returns total matching items (respects maxPages)
asParamsEffect<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

Used in entity-level filter():

OperatorExampleDescription
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
ExportDescription
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.


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.

MethodDescription
.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.
MethodReturns
.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() 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 + limit
const 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 + cursor
const page = yield* db.entities.Tasks
.byProject({ projectId: "p-1" })
.fetch()
// Lazy stream
yield* db.entities.Tasks
.byProject({ projectId: "p-1" })
.paginate()
.pipe(Stream.runForEach((task) => Console.log(task.title)))
// Count only
const total = yield* db.entities.Tasks.byProject({ projectId: "p-1" }).count()

See Queries and Expressions for details.


Multi-entity queries across a shared index. Collections are auto-discovered from entity indexes that share the same collection property.

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 collection
const 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 client
const 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:

TerminalReturns
.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.

For advanced use, Collection.make() creates collections explicitly:

import { Collection } from "effect-dynamodb"
ExportTypeDescription
make(name, entities) => CollectionCreate a collection from entities sharing an index

See Indexes & Collections for details.


Atomic multi-item operations.

import { Transaction } from "effect-dynamodb"
ExportTypeDescription
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) => ConditionCheckOpCreate a condition-check operation for transactWrite (dual API). Takes either get spelling
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.

yield* Transaction.transactWrite(
UserEntity.put({ userId: "u-1", ... }),
OrderEntity.delete({ orderId: "o-old" }),
Transaction.check(
UserEntity.get({ userId: "u-1" }),
{ attributeExists: "email" },
),
)

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 featureWhy 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 refsWrite-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.)
generatedIdId generation needs the Crypto service
vectorIndexesComputing 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 itemEDD-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 vectorIndexesEDD-9061. Recomputing the embedding needs the Embedder service

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 with EventStore.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: TransactionCancelled from transactWrite, AdditionalItemConditionFailed from append.
  • 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 ValidationError naming 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. In append, 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. transactGet reading one item twice fails with a DynamoError naming 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, no IN (), 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 its cause. Over 100 items is TransactionOverflow (entity writes), AppendTooLarge (append), AggregateTransactionOverflow (an aggregate group), CascadePartialFailure (a transactional cascade) or a DynamoError (transactWrite, transactGet); the others are a ValidationError naming 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; a retain put 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.write and EventStore snapshots. A rejection the library does not recognise, or cannot confirm by measuring, passes through as DynamoDB’s DynamoValidationError.
  • The reads cost capacity. Each guarded put makes one consistent GetItem of its item, one more per sentinel it would release, and, for a missing retain item, one Limit 1 query of its version history (plus, with primary sort key composites, a keys-only Limit 1 query for older history).
  • The 100-item cap counts the expanded total. Ten unique + retain puts occupy 30 slots or more. The error message reports both numbers.
  • softDelete does not affect a put — only a delete — so puts of soft-deletable entities are unaffected.

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 one Update item, 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; otherwise ItemNotFound; patch fails ConditionalCheckFailed), 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 (patch of a missing item), OptimisticLockError (a stale expectedVersion), 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 expectedVersion the row no longer has fails OptimisticLockError. Only the caller’s own .condition() rejecting a row that is unchanged since the read is reported as TransactionCancelled — the stored item the cancellation returns is compared with the row the plan read, as for a guarded put; patch’s and deleteIfExists’ existence guards are not caller conditions. A race lost on every attempt is OptimisticLockError (versioned) or ConcurrentModification, and a retain snapshot the history already holds fails with a ValidationError without 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 no update (at the type level), and refuses these deletes (EDD-9048).
TypeDescription
ConditionCheckOpA condition-check operation for inclusion in transactWrite

Batch get and write with auto-chunking and unprocessed item retry.

import { Batch } from "effect-dynamodb"
ExportTypeDescription
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 mixed
const [user1, user2] = yield* Batch.get([
UserEntity.get({ userId: "u-1" }),
db.entities.Users.get({ userId: "u-2" }),
])
// Batch write — mixed puts and deletes
yield* 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, since Batch.write was never atomic across chunks, and the message says how many; the batch’s other requests aren’t sent. Use the entity’s own put() or Transaction.transactWrite to replace an item.
  • Each chunk costs twice the write capacity of a batch write.
  • Contention cancellations (TransactionConflict, throttling) are retried with the batch’s maxRetries / baseDelayMs backoff.
  • Puts of other entities, and deletes, are still plain BatchWriteItem requests.

Condition, filter, and update expression builders.

import { Expression } from "effect-dynamodb"
ExportTypeDescription
condition(input: ConditionInput) => ExpressionResultBuild ConditionExpression
filter(input: ConditionInput) => ExpressionResultBuild FilterExpression
update(input: UpdateInput) => ExpressionResultBuild UpdateExpression
OperatorExampleDescription
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
PropertyExampleDescription
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
TypeDescription
ExpressionResult{ expression: string, names: Record, values: Record }
ConditionInputDeclarative condition expression input
UpdateInputDeclarative 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.

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"
ExportTypeDescription
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) => CompileResultCompile 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 ExprType guard for Expr nodes
parseShorthand(input)(ConditionInput) => ExprConvert ConditionInput to Expr
parseSimpleShorthand(input)(Record) => ExprConvert { key: value } to Expr

Entity-Level Combinators:

CombinatorDescription
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:

CombinatorDescription
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:

TypeDescription
ExprDiscriminated 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

ProjectionExpression builder for selecting specific attributes.

import { Projection } from "effect-dynamodb"
ExportTypeDescription
projection(attrs: string[]) => ProjectionResultBuild ProjectionExpression from attribute names
TypeDescription
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" }

Composite key composition from index definitions. Used internally by Entity, also available for advanced use cases.

import { KeyComposer } from "effect-dynamodb"
ExportTypeDescription
composePk(schema, entity, index, record) => stringCompose partition key value
composeSk(schema, entity, index, record) => stringCompose sort key value
composeIndexKeys(schema, entity, index, record) => RecordCompose all key attributes for an index
tryComposeIndexKeys(schema, entity, index, record) => Record | undefinedNon-throwing variant for sparse GSIs
composeAllKeys(schema, entity, indexes, record) => RecordCompose 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) => stringPartial SK prefix for begins_with queries
extractComposites(keyPart, record) => string[]Extract composite attribute values from a record
tryExtractComposites(keyPart, record) => string[] | undefinedNon-throwing variant
serializeValue(value) => stringSerialize a value for key composition
effectiveCasing(schema, index) => CasingThe casing an index composes with: its own casing, else the schema’s
assertCollectionCasingAgreement(collectionName, members) => voidThrow EDD-9055 when a collection’s members would compose its keys with different casings
TypeDescription
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> }

Thin wrapper around @aws-sdk/util-dynamodb.

import { Marshaller } from "effect-dynamodb"
ExportTypeDescription
toAttributeMap(record) => Record<string, AttributeValue>Marshall JS object to DynamoDB format
fromAttributeMap(item) => Record<string, unknown>Unmarshall DynamoDB format to JS object
toAttributeValue(value) => AttributeValueMarshall a single value
fromAttributeValue(av) => unknownUnmarshall a single value

Effect service wrapping AWS SDK DynamoDBClient.

import { DynamoClient } from "effect-dynamodb"
ExportTypeDescription
DynamoClientContext.ServiceEffect 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 optionReadonlyArray<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
MethodDescription
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 layer
DynamoClient.layer({ region: "us-east-1" })
// DynamoDB Local
DynamoClient.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")),
})

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"
ExportTypeDescription
makeOverloadedCreate a sub-aggregate or top-level aggregate (see below)
one(name, { entityType }) => OneEdgeCreate a one-to-one edge descriptor
many(name, config) => ManyEdgeCreate a one-to-many edge descriptor
ref(entity) => RefEdgeCreate 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 } }),
},
})
OperationSignatureDescription
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
TypeDescription
OneEdgeOne-to-one edge: { _tag: "OneEdge", name, entityType }
ManyEdgeOne-to-many edge: { _tag: "ManyEdge", name, entityType, edgeAttributes?, sk? }
RefEdgeInline ref edge: { _tag: "RefEdge", entity } — no separate DynamoDB item
AggregateEdgeUnion: OneEdge | ManyEdge | RefEdge
TypeDescription
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 }
TypeDescription
Aggregate.Type<A>Assembled domain type (e.g., Match)
Aggregate.Key<A>Partition key type
ExportDescription
isOneEdge(edge)Check if edge is OneEdge
isManyEdge(edge)Check if edge is ManyEdge

See Aggregates & Refs for details.


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"
ExportDescription
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.

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.

projectionEmitted ProjectionIndex write per eventRead through an LSIRead through a GSImetadata type
"all" (default)ALLthe full eventone Queryone Querythe stream’s metadata type
"withoutMetadata"INCLUDE of __edd_e__, data, eventType, streamId, timestamp, version (6 NonKeyAttributes)the event without metadataone Queryone Queryundefined
"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 tableone 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.count never 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 Projection from indexDefinitions exactly. 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 a ValidationError (operation: "EventStore.index.decode") naming the index.
  • See Index projection in the tutorial.

A Decider<State, Command, Event, E = never> encodes one aggregate’s command-event-state triad:

FieldType
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
initialStateState

EventStream and BoundEventStream expose the same operations. The only difference is the R parameter — EventStream ops require DynamoClient | TableConfig, BoundEventStream ops have R = never.

MethodReturns
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
streamIdFieldsThe stream id’s composite fields, in declaration order
eventMarkerThe __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.

ExportDescription
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):

OptionDescription
retryA 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
consistentReadLoad 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
verifySnapshotDefault 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):

OptionDescription
metadataOne metadata value written onto every event the decision appends, validated against the stream’s metadata schema. Fixed before decide runs
eventMetadataA 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
eventTimestampsA 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
commandIdThe delivery’s id; required with idempotency
expectedVersionIf-Match: a loaded version other than this fails with VersionConflict (carrying actualVersion) before decide runs. Never retried
additionalItemsA 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

Read models folded from DynamoDB Streams in version order, gap-filled from the event store — see Projections from DynamoDB Streams.

ExportDescription
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, outboxrevision: 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.removeRowReturned by evolve to remove the row (with removal)
EventStore.skipEventReturned 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
ExportDescription
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.
TypeShape
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 / AppendWriteOpStreamAppendOp 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 }
ReadLatestOptionsReadOptions & { 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 }
ReadFromOptionsReadOptions & { 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.


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"
ExportDescription
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
DynamoStreamsClientContext.Service (@effect-dynamodb/DynamoStreamsClient): describeStream, getShardIterator, getRecords, listStreams, each Effect<Output, DynamoStreamsClientError>. layer({ region, endpoint?, credentials? }), layerConfig({ region, endpoint?, credentials? }) (Effect Configs), layerFromSdkClient(client)
CheckpointStoreContext.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)
ErrorFieldsRaised by
ShardIteratorExpiredoperation, causeDynamoStreamsClient (ExpiredIteratorException); the feed re-acquires
StreamDataTrimmedoperation, causeDynamoStreamsClient (TrimmedDataAccessException)
ChangeFeedTrimmedstreamArn, shardId, sequenceNumber, causeThe 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
ChangeFeedLeaseHeldstreamArn, consumer, owner, expiresAtAnother process holds the lease
ChangeFeedLeaseLoststreamArn, consumer, ownerThe lease was taken over while running; also an acknowledgement made after the lease was lost
CheckpointStoreErroroperation, causeA 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.


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"
ExportTypeDescription
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? }) => numbervalueBytes, 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
ExportDescription
Compressor{ codec: "gzip" | "brotli" | "zstd", compress(input: Uint8Array): Uint8Array, decompress(input: Uint8Array): Uint8Array } — a synchronous codec implementation
CompressionContext.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"
ExportDescription
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_LENGTH67_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.

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"
ErrorTagDescription
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)

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.

OperationDeclared errors (besides DynamoClientError)
putValidationError, UniqueConstraintViolation, OptimisticLockError, ConcurrentModification, TransactionOverflow
createas put, plus ConditionalCheckFailed
upsertas put, plus ItemNotFound, ConditionalCheckFailed
updateItemNotFound, OptimisticLockError, ConcurrentModification, UpdateAppliedButUnreadable, UniqueConstraintViolation, ValidationError, TransactionOverflow
patchas update, plus ConditionalCheckFailed
deleteItemNotFound, OptimisticLockError, ConcurrentModification, ValidationError, TransactionOverflow, DeleteAppliedButUnreadable
deleteIfExistsas delete, plus ConditionalCheckFailed
restoreItemNotFound, ItemNotDeleted, ValidationError, UniqueConstraintViolation, TransactionOverflow
purgeValidationError
Transaction.transactWriteValidationError, TransactionCancelled, UniqueConstraintViolation, OptimisticLockError, ConcurrentModification
Aggregate createAggregateWriteError 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}`)
),
)