All files / src/handlers cron.ts

100% Statements 15/15
100% Branches 6/6
100% Functions 3/3
100% Lines 14/14

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);
}