Event reminders

A visible alert some configurable time before an event starts. Two scheduled jobs share one durable queue.

Shape

crons/buildReminders.ts (:00/:15/:30/:45) sweeps the devices whose queues are stalest, composes each one's feed, and writes a notification_queue row per future item. crons/drainReminders.ts (:05/:20/:35/:50) claims every row whose send_after has passed and sends it.

The split exists because the halves have opposite properties. Building is slow and failure-prone — N devices times M source reads — and is allowed to run behind. Draining must be on time. Under a 1-minute wall clock a combined job would have to do both inside one run.

Invariants

  • Due, not windowed. A row is due when send_after has passed and nothing has sent it. Window matching would drop reminders on a skipped tick or a late-published event; this recovers on the next tick instead.
  • The claim is the only lock. claimReminder is one compare-and-swap. Rows affected answers "is this send mine?", so overlapping runs and repeated triggers are harmless. This is what allows an external trigger to be accepted at all.
  • At-most-once. A claim abandoned by a run that died mid-send is failed, never retried. A duplicate alert is the more visible failure than a missing one.
  • Lead time is not quantized to the tick. send_after is exact; the tick bounds only lateness. Any duration works, with ±tick precision.
  • Cancellation is in place. An item that leaves the feed voids its pending row. Nothing external needs recalling.
  • A row carries the event's whole window. event_at is the item's startsAt and expires_at its expiresAt, so send-time policy can ask both "has it begun?" and "is it over?" without re-reading a source. They settle distinctly (EventStarted vs EventExpired). expires_at is a source-owned estimate and is never shown to anyone.
  • rule names the policy, not a column. lead:3600 is implicitly anchored to the start. An expiry-anchored policy extends the value (lead:0@expiry) rather than adding a column, because UNIQUE(device_id,item_id,rule) is what lets distinct rules coexist for one item.

Timestamps

Every stored time is ISO-8601 UTC text matching Date.toISOString() (2026-09-10T08:30:00.000Z) — the format the rest of the parent's tables already use. Fixed-width and zero-padded means string order is chronological order, so the due predicate and the abandoned-claim sweep compare text directly with no conversion. The drainer's two lateness guards are the only arithmetic on stored times and convert at the point of use.

The T separator is load-bearing. SQLite's own datetime() emits a space instead, and 'T' sorts above ' ', so mixing the two would break the due predicate silently. Write times through NOW_UTC or isoFromUnix — never datetime() or unixepoch(). tools/reminder-check.ts asserts the format and that text compares chronologically.

Item bounds on the source protocol and widget wire stay Unix seconds; that is a separate contract and is not affected. It is also the origin of the epoch integers that spread inward — the widget's item times set the habit, and storage copied it. Converting at the storage boundary is what keeps the wire format from dictating the schema. The shared helpers live in lib/time.ts.

Sizing

Every device must be visited at least once inside its own reminder lead:

full sweep period < lead time − margin

Chunk size, cadence and device count set that period. Missing it does not make reminders late — it means they never queue. Watch the oldest devices.reminders_built_at.

The drain side has its own relationship, and it bites the other way:

MAX_LATENESS_SECONDS > drain cadence + scheduler slack

Val Town cron is not punctual — a :35 tick was observed firing at :40 on 2026-09-10. A row coming due just after a tick therefore waits nearly a full cadence plus that slack, so a cap equal to the cadence voids it as TooLate instead of sending it: a reminder dropped silently. The cap is 30 minutes against a 15-minute cadence for that reason, and tools/reminder-check.ts asserts the relationship rather than the number so retuning the cadence cannot quietly reintroduce the gap. Retune both together.

Configuration

VariableEffect
REMINDERS_ENABLED"true" sends. Anything else drains the queue settling rows disabled — a dry run showing what would have been sent.
REMINDERS_TOKENBearer secret for POST /internal/reminders/{build,drain}. Unset means 401.

devices.reminder_lead_seconds defaults to 3600. devices.last_tz_offset_seconds is captured from the widget's tz on resolve — conditionally, so the hot read path stays a read, and best-effort, so it can never fail a feed request.

The scheduler seam

Drain is an HTTP route, not a cron-only function, and takes an optional row id. Today the cron calls it with none and it sweeps. A scheduling service (QStash-style) that stores "call this URL at time T" would call the same route with an id, giving exact timing without polling. The ledger stays here and stays authoritative; the external service would hold no state and no business logic.

Known limit

An offset is not a timezone: it cannot survive a DST transition, so an event on the far side of one composes against the wrong local day. Harmless for lead-time math, which is absolute instants throughout. Not harmless for quiet hours — that needs an IANA identifier from the app first.

Checks

tools/reminder-check.ts covers arbitrary leads, the horizon, started-event and expired-event guards, an instantaneous (one-second) item, the due predicate, single-claim delivery, cancellation, settlement and abandoned-claim policy. Disposable fixtures, cleaned up, no APNs requests.