A visible alert some configurable time before an event starts. Two scheduled jobs share one durable queue.
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.
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.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.send_after is exact; the tick bounds
only lateness. Any duration works, with ±tick precision.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.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.
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.
| Variable | Effect |
|---|---|
REMINDERS_ENABLED | "true" sends. Anything else drains the queue settling rows disabled — a dry run showing what would have been sent. |
REMINDERS_TOKEN | Bearer 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.
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.
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.
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.