Entity Lifecycle
This guide covers soft delete, restore, purge, version retention, and TTL — mechanisms for managing the full lifecycle of entities.
Soft Delete
Section titled “Soft Delete”When softDelete is configured, entity.delete() performs a logical deletion instead of physical removal. The item becomes invisible to normal queries but remains recoverable.
Declaration
Section titled “Declaration”The simplest form enables soft delete with no TTL:
const Employees = Entity.make({ // ... softDelete: true,})Add a TTL to auto-purge deleted items after a duration:
const Employees = Entity.make({ model: EmployeeModel, entityType: "Employee", primaryKey: { pk: { field: "pk", composite: ["employeeId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byTenant: { name: "gsi1", pk: { field: "gsi1pk", composite: ["tenantId"] }, sk: { field: "gsi1sk", composite: ["department", "employeeId"] }, }, }, timestamps: true, versioned: { retain: true, ttl: Duration.days(90) }, softDelete: { ttl: Duration.days(30) },})With preserveUnique to control unique constraint behavior on delete:
const EmployeesReserve = Entity.make({ model: EmployeeModel, entityType: "EmployeeReserve", primaryKey: { pk: { field: "pk", composite: ["employeeId"] }, sk: { field: "sk", composite: [] }, }, timestamps: true, versioned: { retain: true }, softDelete: { ttl: Duration.days(30), preserveUnique: true }, unique: { email: ["email"] },})What Happens on Delete
Section titled “What Happens on Delete”// Soft delete (version 3) — item disappears from GSI queriesyield* db.entities.Employees.delete({ employeeId: "emp-alice" })yield* Console.log("Soft-deleted alice")- Sort key is modified:
$lifecycle#v1#employee→$lifecycle#v1#employee#deleted#2024-01-15T10:30:00Z - GSI keys are removed: The item falls out of all secondary indexes and collections
deletedAttimestamp is added to the item- Optional TTL is set for automatic purge by DynamoDB
- Item stays in the same partition (same pk) — co-located with version history
Visibility After Deletion
Section titled “Visibility After Deletion”| Access Path | Sees Deleted Items? |
|---|---|
db.entities.Employees.get(key) | No |
db.entities.Employees.byTenant(...) | No |
| Collection queries | No |
db.entities.Employees.deleted.get(key) | Yes |
db.entities.Employees.deleted.list(key) | Yes (returns BoundQuery) |
Normal get returns ItemNotFound:
// Normal get returns ItemNotFoundconst getResult = yield* db.entities.Employees.get({ employeeId: "emp-alice" }).pipe( Effect.map(() => "found" as const), Effect.catchTag("ItemNotFound", () => Effect.succeed("not-found" as const)),)yield* Console.log(`Normal get after delete: ${getResult}`)Collection queries also return empty — deleted items fall out of indexes:
// Index query also returns empty — deleted items fall out of indexesconst tenantEmployees = yield* db.entities.Employees.byTenant({ tenantId: "t-acme" }).collect()Accessing Deleted Items
Section titled “Accessing Deleted Items”// Access the soft-deleted item directlyconst deleted = yield* db.entities.Employees.deleted.get({ employeeId: "emp-alice" })yield* Console.log(`Deleted item: ${deleted.displayName} (deletedAt present)`)// List all deleted items in the partition — fluent BoundQueryconst allDeleted = yield* db.entities.Employees .deleted.list({ employeeId: "emp-alice" }) .limit(20) .collect()yield* Console.log(`Deleted items in partition: ${allDeleted.length}`)Unique Constraint Policy
Section titled “Unique Constraint Policy”When an entity has both softDelete and unique constraints, you choose what happens to the unique values:
| Policy | preserveUnique | On Delete | On Restore |
|---|---|---|---|
| Free (default) | false | Remove sentinel items | Re-establish sentinels (can fail) |
| Reserve | true | Keep sentinel items | No-op (sentinels still exist) |
Free policy — The unique values (e.g., email) become available for other entities immediately after deletion. If someone claims the email before you restore, restore fails with UniqueConstraintViolation.
Reserve policy — The unique values stay locked while the item is in the recycle bin. No one can claim the email until the item is purged. Restore always succeeds (for unique constraints).
// Free policy (default)softDelete: { preserveUnique: false }
// Reserve policysoftDelete: { preserveUnique: true }Restore
Section titled “Restore”Restore brings a soft-deleted item back to active status.
// Restore the soft-deleted item — recomposes all index keysconst restored = yield* db.entities.Employees.restore({ employeeId: "emp-alice" })yield* Console.log(`Restored: ${restored.displayName} (v${v(restored)})`)- Retrieves the soft-deleted item
- Recomposes all index keys from stored attribute values
- Re-establishes unique constraint sentinels (if
preserveUnique: false) - Removes
deletedAt, restores original sort key - Item reappears in all secondary indexes and collections
// Item is back in index queriesconst tenantAfterRestore = yield* db.entities.Employees.byTenant({ tenantId: "t-acme" }).collect()Error Handling
Section titled “Error Handling”Restore can fail if unique values were claimed while the item was deleted, or if no deleted item exists:
// Attempting to restore a non-existent deleted itemconst restoreResult = yield* db.entities.Employees.restore({ employeeId: "emp-nobody" }).pipe( Effect.map(() => "restored" as const), Effect.catchTag("ItemNotFound", () => Effect.succeed("not-in-recycle-bin" as const)),)yield* Console.log(`\nRestore non-existent: ${restoreResult}`)Purge permanently removes an item and all associated data. This is irreversible.
// Purge permanently removes the item, all version history, and sentinelsyield* db.entities.Employees.purge({ employeeId: "emp-bob" })yield* Console.log("\nPurged bob — item + version history permanently removed")What gets deleted:
- The soft-deleted item
- All version history snapshots
- All unique constraint sentinel items
Auto-Purge with TTL
Section titled “Auto-Purge with TTL”When ttl is configured on softDelete, DynamoDB automatically deletes the item after the specified duration. No manual purge required.
softDelete: { ttl: Duration.days(30) }The TTL is set on the DynamoDB item’s TTL attribute when the item is soft-deleted. DynamoDB’s background process handles cleanup.
Note: DynamoDB TTL deletion is eventually consistent — items may persist slightly beyond the TTL.
Version Retention
Section titled “Version Retention”When versioned: { retain: true } is configured, every mutation stores a snapshot of the previous state.
Storage Pattern
Section titled “Storage Pattern”PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee ← current (v4)PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee#v#0000001 ← snapshot v1PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee#v#0000002 ← snapshot v2PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee#v#0000003 ← snapshot v3All versions share the same partition key — single-partition operations for efficient access.
Querying Version History
Section titled “Querying Version History”// Get a specific version snapshotconst v1 = yield* db.entities.Employees.getVersion({ employeeId: "emp-alice" }, 1)yield* Console.log(`\nVersion 1: ${v1.displayName} (v${v(v1)})`)// Browse version history (most recent first) — fluent BoundQueryconst history = yield* db.entities.Employees .versions({ employeeId: "emp-alice" }) .reverse() .collect()yield* Console.log(`\nVersion history (${history.length} snapshots):`)for (const snapshot of history) { yield* Console.log(` v${v(snapshot)}: ${snapshot.displayName}`)}TTL on Snapshots
Section titled “TTL on Snapshots”Limit storage costs by expiring old version snapshots:
versioned: { retain: true, ttl: Duration.days(90) }- Historical snapshots get a DynamoDB TTL attribute
- The current item is never TTL’d
- After 90 days, DynamoDB automatically removes old snapshots
Version Numbering
Section titled “Version Numbering”- Version numbers are integers, starting at 1
- Updates use DynamoDB atomic counters (
SET version = version + 1) - In sort keys, versions are zero-padded (
#v#0000001) for correct lexicographic ordering
Soft Delete + Versioning
Section titled “Soft Delete + Versioning”When both features are enabled, delete and restore are versioned events. The version history captures the full lifecycle:
v1: created { email: "alice@acme.com", displayName: "Alice" }v2: updated { email: "alice@acme.com", displayName: "Alice Baker" }v3: deleted { email: "alice@acme.com", displayName: "Alice Baker", _deleted: true }v4: restored { email: "alice@acme.com", displayName: "Alice Baker" }This provides a complete audit trail of the entity’s lifecycle.
Transaction Composition
Section titled “Transaction Composition”When multiple features are enabled, entity operations compose into DynamoDB transactions automatically:
| Mutation | Transaction Items |
|---|---|
| Put | Entity + version snapshot + sentinel per unique field |
| Update (unique field changed) | Entity + version snapshot + delete old sentinel + put new sentinel |
| Update (no unique field change) | Entity + version snapshot |
| Delete (soft, free policy) | Delete current + put deleted item + version snapshot + delete sentinels |
| Delete (soft, reserve policy) | Delete current + put deleted item + version snapshot |
| Restore (free policy) | Delete deleted item + put restored item + version snapshot + put sentinels |
| Restore (reserve policy) | Delete deleted item + put restored item + version snapshot |
| Purge | Delete item + delete all version snapshots + delete sentinels |
DynamoDB limits transactions to 100 items. Plan accordingly.
Complete Example
Section titled “Complete Example”The model and entity definition:
class Employee extends Schema.Class<Employee>("Employee")({ employeeId: Schema.String, tenantId: Schema.String, email: Schema.String, displayName: Schema.NonEmptyString, department: Schema.String,}) {}
const EmployeeModel = DynamoModel.configure(Employee, { tenantId: { immutable: true },})The full program demonstrating create, update, soft delete, restore, version history, and purge:
const program = Effect.gen(function* () { const db = yield* DynamoClient.make({ entities: { Employees }, tables: { MainTable }, })
// Create (version 1) const alice = yield* db.entities.Employees.put({ employeeId: "emp-alice", tenantId: "t-acme", email: "alice@acme.com", displayName: "Alice", department: "Engineering", })
// Update (version 2) yield* db.entities.Employees.update( { employeeId: "emp-alice" }, Entity.set({ displayName: "Alice Baker", department: "Engineering", tenantId: "t-acme" }), )
// Soft delete (version 3) yield* db.entities.Employees.delete({ employeeId: "emp-alice" })
// Not found via normal get // yield* db.entities.Employees.get({ employeeId: "emp-alice" }) // ItemNotFound!
// Found via deleted access const deleted = yield* db.entities.Employees.deleted.get({ employeeId: "emp-alice" })
// Restore (version 4) const restored = yield* db.entities.Employees.restore({ employeeId: "emp-alice" })
// View full version history const history = yield* db.entities.Employees .versions({ employeeId: "emp-alice" }) .reverse() .collect() // [v3 (deleted), v2 (updated), v1 (created)]})Provide dependencies and run:
const AppLayer = Layer.mergeAll( DynamoClient.layer({ region: "us-east-1", endpoint: "http://localhost:8000", credentials: { accessKeyId: "local", secretAccessKey: "local" }, }), MainTable.layer({ name: "lifecycle-table" }),)
const main = program.pipe(Effect.provide(AppLayer))
Effect.runPromise(main).then( () => console.log("\nDone."), (err) => console.error("\nFailed:", err),)What’s Next?
Section titled “What’s Next?”- Advanced — Rich updates, batch operations, multi-table design
- DynamoDB Streams — Decode stream records into typed domain objects
- Data Integrity — Unique constraints and optimistic concurrency