Skip to content

Scheduled Tables

SpacetimeDB: Scheduled Tables ↗

Scheduled tables are private by default and reserve scheduledId and scheduledAt. A scheduled endpoint receives { data: table.row }.

Stdb.scheduledTable(name, options) auto-injects two columns before user columns:

  • scheduledId: a u64().primaryKey().autoInc() column with native column name scheduled_id.
  • scheduledAt: a ScheduleAt column with native column name scheduled_at.

Authors cannot also declare scheduledId, scheduledAt, scheduled_id, or scheduled_at; the constructor rejects those reserved names. A custom scheduledId value type may be supplied through options.scheduledId, but it must still be a u64 value type and is still marked primary-key and auto-inc.

Scheduled tables default to public: false, the same local default as Stdb.table(...), and they honor an explicit public: true. User columns are spread directly into the table shape after the injected scheduler columns; there is no { data } wrapper in the table row shape.

import * as Effect from "effect/Effect"
import * as Stdb from "effect-spacetimedb"
const reminderSchedule = Stdb.scheduledTable("reminderSchedule", {
columns: {
note: Stdb.string(),
},
})
const Reminders = Stdb.StdbGroup.make("Reminders").add(
Stdb.StdbFn.scheduledProcedure("reminderFire", {
table: reminderSchedule,
}),
)
const Module = Stdb.StdbModule.make("app")
.addTables(reminderSchedule)
.add(Reminders)
const RemindersLive = Stdb.StdbBuilder.group(Module, "Reminders", {
reminderFire: Effect.fn(function* ({ data }) {
yield* Effect.log(`reminder: ${data.note}`)
}),
})

To seed schedule rows inside a reducer or transaction body, use the generated .schedule(...) helper. It fills the auto-increment sentinel for you.

yield* db.reminderSchedule.schedule({
scheduledAt: Stdb.ScheduleAt.interval("5 minutes"),
note: "wake up",
})
Schedule formHost behavior
Stdb.ScheduleAt.at(...) or after(...)One-shot row. Reducers consume it after execution; procedures consume it before, so a scheduled procedure can no longer find or update its own row.
Stdb.ScheduleAt.interval(...)Row persists and re-fires until deleted.

Interval covers plain re-fire spacing. Reach for self-rescheduling one-shot rows when the target is a wall-clock or cron alignment an interval cannot express: handle a Time row, insert the next one with Stdb.ScheduleAt.nextCron(cron, ctx.timestamp) or at(...), then return.

Stdb.scheduledTable(...) creates the reserved scheduled_id/scheduled_at columns and StdbFn.scheduledReducer(...) or StdbFn.scheduledProcedure(...) binds exactly one typed handler to the table. The binding is registered on the function, which is where SpacetimeDB expects it; the table itself carries no schedule option.

These are SpacetimeDB host behaviours, not something this library adds.

Dispatch is concurrent. The scheduler does not wait for one due function to finish before dispatching the next, so a slow scheduled procedure does not delay unrelated scheduled work. Execution is bounded further down: scheduled reducers share the module’s single reducer lane and still run one at a time, serialized with client-invoked reducers, while scheduled procedures take an instance from a bounded pool — one per available CPU by default — and genuinely run in parallel.

Already-expired work has no order. When several schedules are past due at once, after a restart, a backlog, or an overrunning run, they are not guaranteed to run in scheduled_at order. Never encode ordering between two schedule rows: chain them, or carry an explicit sequence number in the row.

A row does not normally overlap itself. Each row is an independent schedule identified by its scheduledId, and the host does not queue that row’s next invocation until the current one has returned. Two different rows in the same table are separate schedules and may run at the same time. The exception is re-arming: writing a schedule row registers its timer entry immediately, so a procedure that updates its own row to an already-due time can be re-entered while it is still running. Treat self-exclusion as the normal case, never as a mutual-exclusion primitive.

Intervals do not drift. The next fire time is the previous intended time plus the interval, not the time the last run started or finished, so timer jitter and slow runs never accumulate. Ticks missed while the database was busy or down are skipped rather than replayed back to back. The first fire of a newly inserted Interval row is one interval away, not immediate.

A scheduled reducer is still one transaction. Its whole body commits or rolls back atomically, exactly like a client-invoked reducer. For a one-shot row, the host’s deletion of that row is a separate transaction committed immediately afterwards — subscribers see two updates — and it happens whether the reducer succeeded or failed. Failed invocations are logged and never retried; re-insert a schedule row from the handler if retry is part of the domain.

Scheduled Procedures Must Claim Their Work

Section titled “Scheduled Procedures Must Claim Their Work”

A procedure has no ambient transaction: each Tx.run(...) is its own transaction, and external work such as an HTTP call necessarily happens between two of them. The host holds nothing across that gap — no row locks, no snapshot, no exclusion. Combined with concurrent dispatch, a procedure that selects pending rows in one transaction, calls out, and marks them done in a later transaction will hand the same rows to a second invocation that starts in the window between the two.

Claim the work in the same transaction that selects it: flip a status column, or stamp an attempt id and a lease expiry, so the selecting transaction is what makes those rows invisible to everyone else. Do the external call afterwards and record its outcome against that claim.

Keep transaction bodies idempotent and free of captured mutable state for the same reason procedures generally require it: the runtime may re-run a transaction body after a commit conflict.

The TypeScript DSL rejects raw schemas in SATS positions at compile time by requiring opaque value types. Cross-object module invariants are validated by validateModule / Stdb.validate:

InvariantCompile-timeRuntime validation diagnostic
Raw Effect schema in a SATS positionYesUnsupportedTypeDescriptor remains as a malformed-spec fallback
Duplicate table/view relation namesNoDuplicateRelationName
Duplicate reducer/procedure/HTTP/view/lifecycle export namesNoDuplicateCallableName
Reserved __http_router__ export nameNoDuplicateCallableName
Non-canonical declared names under canonical naming policyNoNonCanonicalDeclaredName
Reserved declared error tagsNoReservedDeclaredErrorTag
Duplicate declared error tagsNoDuplicateDeclaredErrorTag
String literal authored/generated-client tag collisionsNoLiteralTagCollision

Broad cross-group uniqueness checks stay in runtime validation because lifting large cross-object comparisons into the type system can hit TypeScript instantiation-depth limits. Smaller bounded checks, such as reserved names and casing policy, are better candidates for future compile-time feedback.