ethercalc
Version:
Multi-User Spreadsheet Server — TypeScript rewrite (Cloudflare fullstack)
149 lines (142 loc) • 5.9 kB
text/typescript
/**
* Phase 9 — `scheduled()` handler for Cloudflare Cron Triggers.
*
* The legacy stack relied on an EXTERNAL cron job pinging
* `GET /_timetrigger` once a minute. Cloudflare fully obviates the
* external pinger: `wrangler.toml` registers a 1-minute cron
* expression and the Worker's `scheduled()` export runs on that
* cadence.
*
* Flow (every invocation):
* 1. Derive `nowMinutes = toEpochMinutes(Date.now())`.
* 2. SELECT due rows (`fire_at <= nowMinutes`).
* 3. For each due row: fire-and-forget
* `env.ROOM.get(idFromName(room)).fetch('/_do/fire-trigger?cell=<cell>')`.
* 4. DELETE the fired rows.
*
* Failures in individual DO fetches are swallowed so one bad room
* doesn't block the rest of the batch. Legacy semantics (src/main.ls:196
* — `SC[room].triggerActionCell cell ->`) also ignored individual send
* errors, so the behavior matches.
*
* Exports:
* - `scheduled(event, env, ctx)` — the Cloudflare ScheduledHandler
* entrypoint. Registered in `src/index.ts` via
* `export default { fetch, scheduled }`.
* - `runScheduled({env, nowMinutes})` — pure-ish helper extracted so
* tests can drive it with stubbed `env.DB` + `env.ROOM` + a
* deterministic `nowMinutes`. Returns the list of fired triggers
* for assertions + downstream reuse by the backwards-compat
* `/_timetrigger` HTTP endpoint.
*
* The HTTP `/_timetrigger` route (Deliverable G) delegates here too,
* so self-host users whose external cron still pings the legacy URL
* get the same semantics.
*
* Excluded from the Node coverage gate because it imports
* `@ethercalc/shared` at runtime (Workers-bundled path); the
* `runScheduled` helper is fully covered via a Node unit test with
* stubbed bindings — see `test/scheduled.node.test.ts`.
*/
import {
type CronTriggerRow,
type DueTrigger,
pickDueTriggers,
toEpochMinutes,
} from './lib/cron.ts';
import { withCronSchema } from './lib/d1-schema.ts';
import type { Env } from './env.ts';
/**
* Fetch every due trigger, dispatch to its room DO, and delete the
* fired rows from D1. Returns the fired triggers and the remaining
* rows so callers (the HTTP compat endpoint) can shape their own
* response bodies.
*
* Pure of `Date.now()` — `nowMinutes` is passed in so tests pin time.
* Pure of `ctx.waitUntil` — writes and fetches are `await`ed so the
* runtime knows we're busy without needing waitUntil hand-off.
*/
export async function runScheduled(params: {
readonly env: Env;
readonly nowMinutes: number;
}): Promise<{
readonly due: readonly DueTrigger[];
readonly keep: readonly CronTriggerRow[];
readonly fired: readonly DueTrigger[];
}> {
const { env, nowMinutes } = params;
// Without a D1 binding there's nothing to scan. The scheduled()
// handler still succeeds so Cloudflare doesn't retry.
const db = env.DB;
if (!db) return { due: [], keep: [], fired: [] };
// Read every row. In production `cron_triggers` is bounded by the
// number of users actively scheduling emails — a full-table scan is
// cheaper than a WHERE clause for a typical EtherCalc deployment
// (legacy `DB.hgetall "cron-list"` also returned the full hash).
const allRes = await withCronSchema(db, async () =>
db.prepare(
'SELECT room, cell, fire_at FROM cron_triggers ORDER BY fire_at ASC',
).all<CronTriggerRow>(),
);
const rows = allRes.results ?? [];
const { due, keep } = pickDueTriggers(nowMinutes, rows);
const fired: DueTrigger[] = [];
for (const trigger of due) {
try {
const id = env.ROOM.idFromName(trigger.room);
const stub = env.ROOM.get(id);
const cell = encodeURIComponent(trigger.cell);
await stub.fetch(
`https://do.local/_do/fire-trigger?cell=${cell}&room=${encodeURIComponent(trigger.room)}`,
{ method: 'POST' },
);
fired.push(trigger);
} catch {
// Swallow individual failures — legacy behavior. The row stays
// in D1 on failure so the next scheduled() pass retries it.
}
}
// Delete fired rows. We join them in one DELETE for batching — D1
// accepts up to 100 statements per batch, so for practical cron
// volumes this runs in one round-trip. We key on (room, cell,
// fire_at) composite PK to survive concurrent inserts of the
// same (room, cell) at a different fire_at.
if (fired.length > 0) {
// Build a single DELETE with OR-of-triples. D1 doesn't support
// ON CONFLICT DO DELETE or CTEs elegantly for this shape; the
// statement is linear in batch size but cheap for the typical
// < 100 entries we expect per minute.
//
// We need the actual fire_at values to target the right row
// (PK). `due` lost them to the `DueTrigger` shape. Re-derive
// from `rows` by filtering on identity — for each due trigger
// re-match rows with fire_at <= nowMinutes.
const dueRows = rows.filter((r: CronTriggerRow) => r.fire_at <= nowMinutes);
const stmt = db.prepare(
'DELETE FROM cron_triggers WHERE room = ?1 AND cell = ?2 AND fire_at = ?3',
);
// Batch each fired row as one bound statement. We intentionally
// don't wrap in a transaction; each delete is idempotent on PK
// collision anyway.
const batch = dueRows.map((r: CronTriggerRow) =>
stmt.bind(r.room, r.cell, r.fire_at),
);
if (batch.length > 0) {
await db.batch(batch);
}
}
return { due, keep, fired };
}
/**
* `scheduled()` handler registered at `export default { scheduled, fetch }`.
* `event` currently carries no fields we need (cron expression matching
* is done by Cloudflare); we rely on `Date.now()` for the time pin.
*/
export async function scheduled(
_event: ScheduledEvent,
env: Env,
_ctx: ExecutionContext,
): Promise<void> {
const nowMinutes = toEpochMinutes(Date.now());
await runScheduled({ env, nowMinutes });
}