Time-series entities
This guide covers the timeSeries entity primitive — a split-item pattern where each partition holds one current item (latest state) plus N immutable event items (history, TTL-bounded). Event-time ordering is controlled by a caller-supplied monotonic attribute (the orderBy field), and late arrivals are dropped via CAS with no retry.
Use timeSeries when you need:
- IoT/telemetry-style workloads where devices publish events at their own clock
- A “latest state + history” pattern that can’t tolerate out-of-order writes overwriting newer data
- Enrichment preservation — background processes that decorate the current item (e.g.
accountId, analytics tags) must not be clobbered by regular event ingestion
Use versioned: { retain: true } instead when you need server-order (monotonic integer) versioning with full audit history.
When to use timeSeries vs versioned
Section titled “When to use timeSeries vs versioned”timeSeries | versioned: { retain: true } | |
|---|---|---|
| Ordering | Caller-supplied (orderBy attribute) | Server-monotonic integer |
| Writes | UpdateItem + Put (scoped SET) | Full PutItem |
| Late writes | Silently dropped (stale value) | Optimistic-lock retry |
| Enrichment fields | Preserved (never touched) | Wiped every write |
| History shape | Event items under same PK, #e# SK infix | Snapshots, #v# SK infix |
| Retention | Per-event TTL | Per-snapshot TTL |
Item-on-disk layout
Section titled “Item-on-disk layout”For a partition { channel: "c-1", deviceId: "d-7" } the table contains:
- One current item — SK
$app#v1#telemetry, all GSI keys present, latestorderByvalue - N event items — SK
$app#v1#telemetry#e#<serialised-orderBy>, GSI keys stripped,_ttlset
The #e# infix on the event SK means a begins_with(<currentSk>#e#) query isolates events from the current without visiting any other partition.
Configuring an entity
Section titled “Configuring an entity”class TelemetryRecord extends Schema.Class<TelemetryRecord>("TelemetryRecord")({ channel: Schema.String, deviceId: Schema.String, // `timestamp` is the caller-supplied monotonic clock used for CAS ordering. timestamp: Schema.DateTimeUtc, // Device-reported fields (flow through `.append()` — in appendInput): location: Schema.optional(Schema.String), alert: Schema.optional(Schema.Boolean), gpio: Schema.optional(Schema.Number), // Enrichment fields (set by background jobs — NOT in appendInput): accountId: Schema.optional(Schema.String), diagnostics: Schema.optional(Schema.String),}) {}
// Only these fields are accepted by .append() — other model fields (accountId,// diagnostics) are never overwritten. This is the enrichment-preservation// contract. See guides/timeseries.mdx § "Enrichment Preservation".const TelemetryAppendInput = Schema.Struct({ channel: Schema.String, deviceId: Schema.String, timestamp: Schema.DateTimeUtc, location: Schema.optional(Schema.String), alert: Schema.optional(Schema.Boolean), gpio: Schema.optional(Schema.Number),})const Telemetries = Entity.make({ model: TelemetryRecord, entityType: "Telemetry", primaryKey: { pk: { field: "pk", composite: ["channel", "deviceId"] }, sk: { field: "sk", composite: [] }, }, indexes: { byAccount: { name: "gsi1", pk: { field: "gsi1pk", composite: ["accountId"] }, sk: { field: "gsi1sk", composite: ["deviceId"] }, }, }, timestamps: { created: "createdAt" }, // `updated` auto-disabled by timeSeries timeSeries: { orderBy: "timestamp", ttl: Duration.days(7), appendInput: TelemetryAppendInput, },})Required fields on timeSeries:
orderBy: the model attribute used as the monotonic clock. Must not be a primary-key composite (EDD-9011) or a ref field (EDD-9014).appendInput: aSchema.Struct(or trimmedSchema.Class) enumerating which fields.append()accepts and writes. Required — omission failsEntity.make()withEDD-9016. Must includeorderByplus all primary-key composites.
Optional:
ttl:Durationapplied to event items (not current). Omit for retention-forever.
Mutual-exclusion rules
Section titled “Mutual-exclusion rules”| Combination | Error |
|---|---|
timeSeries + versioned | EDD-9012 |
timeSeries + softDelete | EDD-9015 |
Time-series entities auto-suppress updatedAt — the orderBy attribute IS the update clock. createdAt is preserved and materialised via if_not_exists on the first append.
.append() — success type and stale-as-error
Section titled “.append() — success type and stale-as-error”const { current } = yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T10:00:00.000Z"), location: "cabinet-A", gpio: 1,})yield* Console.log(`Applied. Current timestamp: ${DateTime.formatIso(current.timestamp)}`)Internally, .append(input) issues a single TransactWriteItems with two items:
- UpdateItem on the current — scoped
SETcovers onlyappendInputfields + recomposed GSI keys + optional#createdAt = if_not_exists(#createdAt, :now). The ConditionExpression isattribute_not_exists(#pk) OR #orderBy < :newOrderBy. - Put of the event — full decoded input +
__edd_e__+_ttl(if configured), GSI keys stripped, SK replaced with<currentSk>#e#<orderByValue>.
The success channel is { readonly current: Model }. Stale outcomes are surfaced on the Effect error channel — handle them with Effect.catchTag:
const result = yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T09:00:00.000Z"), location: "cabinet-B",}).asEffect().pipe( Effect.catchTag("StaleAppend", (e) => Effect.succeed({ applied: false as const, // `e.current` is `Option<unknown>` — use Option.match to handle the // skipFollowUp case where it is `Option.none()`. winner: Option.getOrUndefined(e.current), }), ),)yield* Console.log(`Stale append surfaced: applied=${"applied" in result}`)Two error tags can fire:
StaleAppend— the CAS predicate (stored < attempted) rejected the write.error.currentisOption.some(<post-state model>)carrying the winning state from a follow-upGetItem. This is the EXPECTED outcome of out-of-order arrivals.ConditionalCheckFailed— only fires when you supplied anEntity.condition()AND that condition rejected the write while the CAS held.error.currentisOption.some(<live current>).
Both errors carry current as Option<unknown> rather than Option<Model> because tagged errors don’t capture the entity’s model generic; decode it with the entity’s record schema if you need a typed value.
Why on the error channel? Stale and user-condition rejection are PROGRAM-LEVEL FAILURES — the write you intended did not happen. Effect’s error channel exists precisely to make those failures visible to typed combinators (Effect.catchTag, Effect.retry, Effect.orElse) and impossible to forget at the type level. The previous discriminated-union return hid the failure inside a value, which routinely produced bugs where callers destructured r.current without inspecting r.applied.
.remove(attrs) — clear attributes atomically
Section titled “.remove(attrs) — clear attributes atomically”.append(input).remove(attrs) rides the same UpdateItem as the scoped SET and CAS predicate. Use it when an event should clear one or more appendInput attributes on the current item — e.g. a status event whose absence of an alert field means “no alert this cycle; drop the existing alert state.”
yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T10:02:00.000Z"), // Note: `alert` is intentionally omitted from the payload. The .remove() // call below clears it on the current item in the same UpdateItem.}).remove(["alert"])Why this matters. Without .remove(), callers were stuck with three unsatisfactory workarounds:
| Workaround | Problem |
|---|---|
.append(...) then .update().remove([...]) (two writes) | Race window — a concurrent writer between the two writes can clobber the cleared state |
Sentinel value (e.g. alertState: "DISABLED") | Keeps the attribute set; sparse-policied GSIs continue to include the item |
Schema.NullOr + null payload | Writes a literal NULL into the item; downstream readers must tolerate it |
.remove() closes the race window structurally — the single UpdateItem carries SET + REMOVE + CAS atomically.
GSI cascade. Any GSI half whose composite list intersects attrs follows the v1.7.1 cascade-override semantics: the half evaluates with the removed composite treated as absent. Under 'sparse' the half drops; under 'preserve' it’s a no-op (the stored key field is left as-is unless overridden). The motivating shape is a sparse-PK GSI keyed on the cleared attribute — the item drops out of that GSI in the same write.
Validation. Names listed in .remove() are checked at execution time. The Effect fails with ValidationError(operation: "append.remove") if any name:
- is not declared in
appendInput(enrichment-preservation contract — use.update().remove([...])for fields outsideappendInput) - names
orderBy(would invalidate the CAS anchor) - names a primary-key composite (would orphan the item)
- names a ref field (refs are create-time denormalisations — reassign via
.update()) - also appears in the encoded payload with a non-
undefinedvalue (DynamoDB rejectsSET/REMOVEoverlap)
Chained .remove() calls accumulate. The combinator composes with .condition() and .skipFollowUp() in any order.
.skipFollowUp() — fire-and-forget ingest
Section titled “.skipFollowUp() — fire-and-forget ingest”For high-volume ingest paths that don’t need current, chain .skipFollowUp() to suppress the post-transaction GetItem. The success channel narrows to void:
yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T10:01:00.000Z"), gpio: 0,}) .skipFollowUp() .asEffect() .pipe( Effect.catchTag("StaleAppend", () => Effect.void), )| Default | .skipFollowUp() | |
|---|---|---|
| TransactWriteItems | issued | issued |
| Follow-up GetItem | issued | skipped |
| Success channel | { current: Model } | void |
StaleAppend.current | Option.some(<winner>) | Option.none() |
ConditionalCheckFailed | distinguishable | collapses into StaleAppend |
| TTL race / row vanished | surfaces as ValidationError | undetected |
The trade-off is one read saved per append at the cost of (a) losing the disambiguation between CAS and user-condition rejection and (b) losing TTL-race detection. For a telemetry pipeline doing tens of thousands of appends per minute, none of which read the result, this is the right default.
Enrichment preservation
Section titled “Enrichment preservation”.append()’s UpdateExpression SET clause enumerates ONLY the fields declared in appendInput. Fields in the model but outside appendInput are never referenced — DynamoDB’s UpdateItem semantics guarantee unnamed attributes are left alone.
// Device appends (no accountId in appendInput — cannot touch enrichment): yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T10:05:00.000Z"), location: "cabinet-C", })
// Background job enriches with accountId (via `.update()`, not `.append()`): yield* db.entities.Telemetries.update({ channel: "c-1", deviceId: "d-7" }).set({ accountId: "acct-1", })
// Device appends again — accountId is preserved even though the device // doesn't know about it. yield* db.entities.Telemetries.append({ channel: "c-1", deviceId: "d-7", timestamp: DateTime.makeUnsafe("2026-04-22T10:10:00.000Z"), location: "cabinet-D", })
const cur = yield* db.entities.Telemetries.get({ channel: "c-1", deviceId: "d-7", }) yield* Console.log(`accountId preserved: ${cur.accountId}`)What NOT to do. Do not pass the full model schema as appendInput unless you genuinely want every append to overwrite every field. The whole point of timeSeries over versioned is that enrichment survives ingestion. The Entity.make() validator rejects a missing appendInput (EDD-9016) precisely to make the decision visible at the entity definition.
.history(key).where(...) — range queries
Section titled “.history(key).where(...) — range queries”const fromIso = "2026-04-22T10:00:00.000Z"const toIso = "2026-04-22T10:10:00.000Z"const range = yield* db.entities.Telemetries.history({ channel: "c-1", deviceId: "d-7",}) .where((t, { between }) => between(t.timestamp, fromIso, toIso)) .collect()yield* Console.log(`History in range: ${range.length} events`).history(key) returns a BoundQuery auto-scoped via begins_with(<currentSk>#e#). The .where() callback’s t exposes only the orderBy attribute (here t.timestamp); attempting to constrain other attributes via .where() is a compile-time error. For non-orderBy attribute conditions, chain .filter(...):
const alerts = yield* db.entities.Telemetries.history({ channel, deviceId }) .where((t, { gte }) => gte(t.timestamp, since)) .filter({ alert: true }) .collect()Terminals are the standard BoundQuery set: .collect(), .fetch(), .paginate(), .count(). Ordering is lexicographic on the stored orderBy value — for DateTime.Utc this equals chronological ordering. Call .reverse() to iterate newest-first.
TTL and retention
Section titled “TTL and retention”ttl: Duration.days(N) on TimeSeriesConfig sets a TTL attribute on each event item at Math.floor(Date.now()/1000) + toSeconds(ttl). DynamoDB’s built-in TTL processor prunes expired events asynchronously (typically within 48 hours of expiration).
The current item never has a TTL — it is the live projection and must not expire.
Aligning with TimeToLiveSpecification.AttributeName
Section titled “Aligning with TimeToLiveSpecification.AttributeName”By default the library writes the TTL value to an attribute named _ttl. DynamoDB tables enable expiry by setting TimeToLiveSpecification.AttributeName (the TTL configuration on the console / CloudFormation), and that name must match what the library writes. If your table is already provisioned with a different name (e.g. ttl), set ttlAttributeName when providing the runtime TableConfig:
// Default: TTL writes go to `_ttl`.MainTable.layer({ name: "telemetry-table" })
// Override: align with a table whose TimeToLiveSpecification uses `ttl`.MainTable.layer({ name: "telemetry-table", ttlAttributeName: "ttl",})
// Or from Effect Config (env-driven, optional):MainTable.layerConfig({ name: Config.string("TABLE_NAME"), ttlAttributeName: Config.string("TTL_ATTRIBUTE").pipe(Config.withDefault("_ttl")),})A single ttlAttributeName applies to every lifecycle feature on the physical table — timeSeries: { ttl }, softDelete: { ttl }, and versioned: { retain, ttl } all write to the same attribute, and Entity.restore() strips it. DynamoDB allows only one TTL attribute per table, so this matches the underlying constraint by design.
Use this to give yourself a migration path: declare the legacy attribute name in code, deploy without a destructive table replacement, then perform the standard two-step DDB rename later when you can.
The example below provides a MainTable layer whose TTL attribute is the non-default "ttl", then asserts the appended event carries the expiry on the configured attribute name:
// Provide the table layer with a non-default TTL attribute name.const overriddenLayer = MainTable.layer({ name: "timeseries-demo-table", ttlAttributeName: "ttl",})
const program = Effect.gen(function* () { const db = yield* DynamoClient.make({ entities: { Telemetries }, tables: { MainTable } }) yield* db.entities.Telemetries.append({ channel: "c-ttl-demo", deviceId: "d-1", timestamp: DateTime.makeUnsafe("2026-04-22T10:00:00.000Z"), })})
yield* program.pipe(Effect.provide(Layer.mergeAll(ClientLayer, overriddenLayer)))Multi-stream per partition
Section titled “Multi-stream per partition”If one device publishes two distinct event streams (e.g. "status", "diagnostics") you can co-locate them in the same partition by adding a stream discriminator to the primary-key SK composite:
primaryKey: { pk: { field: "pk", composite: ["channel", "deviceId"] }, sk: { field: "sk", composite: ["stream"] }, // ← discriminator},timeSeries: { orderBy: "timestamp", appendInput: ... },Current SKs become $app#v1#telemetry#status and $app#v1#telemetry#diagnostics; event SKs extend to $app#v1#telemetry#status#e#<value> etc. .history({ channel, deviceId, stream: "status" }) narrows to one stream. The stream field must also appear in appendInput so each append can address a specific stream.
Known limits (v1)
Section titled “Known limits (v1)”- Not transactable.
.append()is aBoundEntity-only terminal and cannot be composed into user-authoredTransaction.transactWritein v1. - No resurrection via append.
.append()+softDeleteis rejected atEntity.make()time (EDD-9015). - User conditions via
.condition(...)are ANDed onto the CAS predicate. On the default path, a user-condition failure surfaces asConditionalCheckFailed(distinguishable fromStaleAppend). On the.skipFollowUp()path, both modes collapse intoStaleAppend— see the table above. - No automated migration from
versionedtotimeSeries. The on-disk SK formats differ (#v#0000001vs#e#<orderBy>); switching requires a bespoke backfill.