Skip to content

Entity Lifecycle

This guide covers soft delete, restore, purge, version retention, and TTL — mechanisms for managing the full lifecycle of entities.

When softDelete is configured, entity.delete() performs a logical deletion instead of physical removal. The item becomes invisible to normal queries but remains recoverable.

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"] },
})
// Soft delete (version 3) — item disappears from GSI queries
yield* db.entities.Employees.delete({ employeeId: "emp-alice" })
yield* Console.log("Soft-deleted alice")
  1. Sort key is modified: $lifecycle#v1#employee$lifecycle#v1#employee#deleted#2024-01-15T10:30:00Z
  2. GSI keys are removed: The item falls out of all secondary indexes and collections
  3. deletedAt timestamp is added to the item
  4. Optional TTL is set for automatic purge by DynamoDB
  5. Item stays in the same partition (same pk) — co-located with version history
Access PathSees Deleted Items?
db.entities.Employees.get(key)No
db.entities.Employees.byTenant(...)No
Collection queriesNo
db.entities.Employees.deleted.get(key)Yes
db.entities.Employees.deleted.list(key)Yes (returns BoundQuery)

Normal get returns ItemNotFound:

// Normal get returns ItemNotFound
const 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 indexes
const tenantEmployees = yield* db.entities.Employees.byTenant({ tenantId: "t-acme" }).collect()
// Access the soft-deleted item directly
const 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 BoundQuery
const allDeleted = yield* db.entities.Employees
.deleted.list({ employeeId: "emp-alice" })
.limit(20)
.collect()
yield* Console.log(`Deleted items in partition: ${allDeleted.length}`)

When an entity has both softDelete and unique constraints, you choose what happens to the unique values:

PolicypreserveUniqueOn DeleteOn Restore
Free (default)falseRemove sentinel itemsRe-establish sentinels (can fail)
ReservetrueKeep sentinel itemsNo-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 policy
softDelete: { preserveUnique: true }

Restore brings a soft-deleted item back to active status.

// Restore the soft-deleted item — recomposes all index keys
const restored = yield* db.entities.Employees.restore({ employeeId: "emp-alice" })
yield* Console.log(`Restored: ${restored.displayName} (v${v(restored)})`)
  1. Retrieves the soft-deleted item
  2. Recomposes all index keys from stored attribute values
  3. Re-establishes unique constraint sentinels (if preserveUnique: false)
  4. Removes deletedAt, restores original sort key
  5. Item reappears in all secondary indexes and collections
// Item is back in index queries
const tenantAfterRestore = yield* db.entities.Employees.byTenant({ tenantId: "t-acme" }).collect()

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 item
const 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 sentinels
yield* 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

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.

When versioned: { retain: true } is configured, every mutation stores a snapshot of the previous state.

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 v1
PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee#v#0000002 ← snapshot v2
PK: $lifecycle#v1#employee#emp-alice SK: $lifecycle#v1#employee#v#0000003 ← snapshot v3

All versions share the same partition key — single-partition operations for efficient access.

// Get a specific version snapshot
const 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 BoundQuery
const 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}`)
}

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

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.

When multiple features are enabled, entity operations compose into DynamoDB transactions automatically:

MutationTransaction Items
PutEntity + 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
PurgeDelete item + delete all version snapshots + delete sentinels

DynamoDB limits transactions to 100 items. Plan accordingly.

The model and entity definition:

models.ts
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:

main.ts
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:

main.ts
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),
)
  • Advanced — Rich updates, batch operations, multi-table design
  • DynamoDB Streams — Decode stream records into typed domain objects
  • Data Integrity — Unique constraints and optimistic concurrency