Skip to content

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.

timeSeriesversioned: { retain: true }
OrderingCaller-supplied (orderBy attribute)Server-monotonic integer
WritesUpdateItem + Put (scoped SET)Full PutItem
Late writesSilently dropped (stale value)Optimistic-lock retry
Enrichment fieldsPreserved (never touched)Wiped every write
History shapeEvent items under same PK, #e# SK infixSnapshots, #v# SK infix
RetentionPer-event TTLPer-snapshot TTL

For a partition { channel: "c-1", deviceId: "d-7" } the table contains:

  • One current item — SK $app#v1#telemetry, all GSI keys present, latest orderBy value
  • N event items — SK $app#v1#telemetry#e#<serialised-orderBy>, GSI keys stripped, _ttl set

The #e# infix on the event SK means a begins_with(<currentSk>#e#) query isolates events from the current without visiting any other partition.

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: a Schema.Struct (or trimmed Schema.Class) enumerating which fields .append() accepts and writes. Required — omission fails Entity.make() with EDD-9016. Must include orderBy plus all primary-key composites.

Optional:

  • ttl: Duration applied to event items (not current). Omit for retention-forever.
CombinationError
timeSeries + versionedEDD-9012
timeSeries + softDeleteEDD-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:

  1. UpdateItem on the current — scoped SET covers only appendInput fields + recomposed GSI keys + optional #createdAt = if_not_exists(#createdAt, :now). The ConditionExpression is attribute_not_exists(#pk) OR #orderBy < :newOrderBy.
  2. 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.current is Option.some(<post-state model>) carrying the winning state from a follow-up GetItem. This is the EXPECTED outcome of out-of-order arrivals.
  • ConditionalCheckFailed — only fires when you supplied an Entity.condition() AND that condition rejected the write while the CAS held. error.current is Option.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:

WorkaroundProblem
.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 payloadWrites 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 outside appendInput)
  • 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-undefined value (DynamoDB rejects SET/REMOVE overlap)

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()
TransactWriteItemsissuedissued
Follow-up GetItemissuedskipped
Success channel{ current: Model }void
StaleAppend.currentOption.some(<winner>)Option.none()
ConditionalCheckFaileddistinguishablecollapses into StaleAppend
TTL race / row vanishedsurfaces as ValidationErrorundetected

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.

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

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.

  • Not transactable. .append() is a BoundEntity-only terminal and cannot be composed into user-authored Transaction.transactWrite in v1.
  • No resurrection via append. .append() + softDelete is rejected at Entity.make() time (EDD-9015).
  • User conditions via .condition(...) are ANDed onto the CAS predicate. On the default path, a user-condition failure surfaces as ConditionalCheckFailed (distinguishable from StaleAppend). On the .skipFollowUp() path, both modes collapse into StaleAppend — see the table above.
  • No automated migration from versioned to timeSeries. The on-disk SK formats differ (#v#0000001 vs #e#<orderBy>); switching requires a bespoke backfill.