Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 | 3x 3x 3x 3x 1x 1x 2x 2x 2x 5x 2x 6x 3x 3x | /**
* Phase 9 — cron trigger + email handlers.
*
* Two wire-level concerns:
*
* 1. `upsertCronTriggers(db, row)` — called from the `post-command`
* glue when `parseSettimetrigger(cmdstr)` returns non-null. The
* legacy flow (src/sc.ls:220-244) replaced the entire comma-list
* for a (room, cell) pair each time. We mirror that: delete
* existing rows for (room, cell), then insert one row per time.
* When `times` is empty the net effect is a delete (clear).
*
* 2. `buildEmailSender(env)` — factory that returns a
* `BindingEmailSender` when `env.EMAIL` is bound, otherwise a
* `StubEmailSender`. Callers never branch on the binding directly;
* the factory keeps the business logic environment-agnostic.
*
* Pure of `Date.now()` — every helper takes values as arguments.
* Pure of network — only the D1 binding and the injected sender are
* touched.
*/
import {
BindingEmailSender,
type EmailSender,
StubEmailSender,
} from '../lib/email.ts';
import { withCronSchema } from '../lib/d1-schema.ts';
import type { Env } from '../env.ts';
/**
* Replace the stored trigger set for (room, cell) with `times`. The
* legacy behavior treats a `settimetrigger` command as "set" semantics
* — previously stored times for that cell are dropped and the new
* list takes their place. Empty `times` clears the cell entirely.
*
* Implementation: two statements, batched so a partial failure doesn't
* leave the table half-updated. The DELETE + INSERT ordering matches
* what the legacy HSET → HDEL flow produced at steady state.
*/
export async function upsertCronTriggers(
db: D1Database,
room: string,
cell: string,
times: readonly number[],
): Promise<void> {
await withCronSchema(db, async () => {
const del = db
.prepare('DELETE FROM cron_triggers WHERE room = ?1 AND cell = ?2')
.bind(room, cell);
// Deduplicate times to keep the PK stable under repeat values from
// the client. `Set` preserves insertion order; we only care about
// uniqueness here because the index ordering is applied at scan
// time by `scheduled()`.
const uniqueTimes = Array.from(new Set(times));
if (uniqueTimes.length === 0) {
await del.run();
return;
}
const insertStmt = db.prepare(
'INSERT OR IGNORE INTO cron_triggers (room, cell, fire_at) VALUES (?1, ?2, ?3)',
);
const batch: D1PreparedStatement[] = [del];
for (const t of uniqueTimes) {
batch.push(insertStmt.bind(room, cell, t));
}
await db.batch(batch);
});
}
/**
* Factory: return a live sender when `env.EMAIL` is bound, else the
* stub. The caller's async contract is identical regardless — both
* impls return `{ message: string }` and neither rejects.
*/
export function buildEmailSender(env: Env): EmailSender {
if (!env.EMAIL) return new StubEmailSender();
const from = env.EMAIL_FROM ?? 'noreply@ethercalc.invalid';
return new BindingEmailSender(env.EMAIL, from);
}
|