summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 10:41:35 +0900
committerAdam Malczewski <[email protected]>2026-06-01 10:41:35 +0900
commitdbabca525ee70126b3d2264dad88c0dcca630b83 (patch)
tree3682514520e2108f3b318f9a9c1896744a1da545 /packages/api/src
parentee8af7448a72fbd49710d8df24ecda4c23a58a8d (diff)
downloaddispatch-dbabca525ee70126b3d2264dad88c0dcca630b83.tar.gz
dispatch-dbabca525ee70126b3d2264dad88c0dcca630b83.zip
fix(api): wake-schedule — accept skewed toggles, atomic persist, boot-recovery reason
Three review-finding fixes in models.ts + regression tests: 1. POST /wake-schedule/toggle no longer rejects 'past' timestamps (Gemini #1, High). Client-server clock skew + request latency meant a freshly-computed nextOccurrenceAt(HH:MM) for an imminent slot could land in the past by the time the server validated it, silently failing the UI toggle. The scheduler's recoverScheduleEntry already fires within MISSED_WAKE_GRACE_MS and rolls forward by 24h × N, so the strict <= now check was actively harmful. Kept Number.isFinite + slot-present validation. 2. persistSchedule is now transactional (Gemini #3, Medium). The old DELETE-then-N-INSERTs path, when an INSERT failed mid-loop, left the table empty (DELETE had committed) and silently wiped the user's schedule on next boot — the catch swallowed the error. Wrapped both in db.transaction(...): on failure everything rolls back, in-memory state is untouched, and the previously persisted snapshot stays intact. 3. Boot-recovery reason no longer masked when boot recovery + due slots coincide (Gemini #5, Nit). Capture bootFireRequested before clearing the flag and append ' (boot recovery)' to the reason so the lastWake/pendingRetry surface tells the truth. Tests: - Replaced 'POST toggle rejects past timestamp' (the bug-as-feature test) with 'POST toggle ACCEPTS a slightly-past timestamp (clock skew / latency)' regression guard. - Added 'POST toggle rejects NaN / Infinity / non-number slot values' to lock the malformed-input path. - Added 'snapshot remains consistent across toggle round-trips (persistSchedule atomicity)' — exercises GET/POST cycles to ensure the transactional impl agrees with itself across add/remove. All 427 tests pass; biome clean.
Diffstat (limited to 'packages/api/src')
-rw-r--r--packages/api/src/routes/models.ts51
1 files changed, 35 insertions, 16 deletions
diff --git a/packages/api/src/routes/models.ts b/packages/api/src/routes/models.ts
index 5f37a1f..5f47231 100644
--- a/packages/api/src/routes/models.ts
+++ b/packages/api/src/routes/models.ts
@@ -688,22 +688,31 @@ function persistSchedule(scheduleToSave?: WakeSchedule): void {
try {
const db = getDatabase();
const data = scheduleToSave ?? wakeSchedule;
- db.run("DELETE FROM wake_schedule");
const insert = db.query(
"INSERT INTO wake_schedule (hour, slot_minute, next_wake_at) VALUES ($hour, $slot, $nextWakeAt)",
);
- for (const [hour, slots] of Object.entries(data)) {
- for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
- if (nextWakeAt === undefined) continue;
- insert.run({
- $hour: Number(hour),
- $slot: Number(slotMinute),
- $nextWakeAt: nextWakeAt,
- });
+ // One atomic transaction: DELETE + every INSERT either all commit or all
+ // roll back. Without this, an INSERT failure (disk full, bad row, etc.)
+ // would leave the table empty — silently wiping the user's schedule on
+ // next boot since the DELETE has already committed.
+ const writeAll = db.transaction(() => {
+ db.run("DELETE FROM wake_schedule");
+ for (const [hour, slots] of Object.entries(data)) {
+ for (const [slotMinute, nextWakeAt] of Object.entries(slots)) {
+ if (nextWakeAt === undefined) continue;
+ insert.run({
+ $hour: Number(hour),
+ $slot: Number(slotMinute),
+ $nextWakeAt: nextWakeAt,
+ });
+ }
}
- }
+ });
+ writeAll();
} catch {
- // Ignore DB errors — schedule still lives in-memory for this process.
+ // Ignore DB errors — schedule still lives in-memory for this process,
+ // and the previously persisted snapshot stays intact thanks to the
+ // transaction rollback above.
}
}
@@ -825,7 +834,8 @@ async function schedulerTick(): Promise<void> {
const due = collectDueSlots(now);
let firedThisTick = false;
- if (due.length > 0 || needsBootFire) {
+ const bootFireRequested = needsBootFire;
+ if (due.length > 0 || bootFireRequested) {
needsBootFire = false;
// Advance every due slot before firing — so a slow upstream call
// can't cause us to re-fire the same slot on the next tick.
@@ -836,8 +846,11 @@ async function schedulerTick(): Promise<void> {
persistSchedule();
const reasonParts = due.map((d) => `${d.hour}:${String(d.minute).padStart(2, "0")}`);
+ const fromBoot = bootFireRequested ? " (boot recovery)" : "";
const reason =
- reasonParts.length > 0 ? `scheduled probe(s) ${reasonParts.join(", ")}` : "boot recovery";
+ reasonParts.length > 0
+ ? `scheduled probe(s) ${reasonParts.join(", ")}${fromBoot}`
+ : "boot recovery";
firedThisTick = true;
// COALESCED: one upstream call covers all slots due this tick.
await fireWake(reason);
@@ -912,12 +925,18 @@ modelsRoutes.post("/wake-schedule/toggle", async (c) => {
400,
);
}
- const now = Date.now();
const parsed: Partial<Record<ProbeSlotMinute, number>> = {};
for (const slot of PROBE_SLOT_MINUTES) {
const raw = (timestamps as Record<string, unknown>)[String(slot)];
- if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= now) {
- return c.json({ error: `timestamps['${slot}'] must be a future Unix ms value` }, 400);
+ // Accept any finite Unix-ms number. We deliberately do NOT reject
+ // past timestamps: client-server clock skew + request latency mean
+ // a freshly-computed `nextOccurrenceAt(HH:MM)` for an imminent slot
+ // can land "in the past" by the time the server validates it. The
+ // scheduler tick handles past entries correctly via
+ // `recoverScheduleEntry` — fires within MISSED_WAKE_GRACE_MS, then
+ // advances by 24h * N to the next future occurrence.
+ if (typeof raw !== "number" || !Number.isFinite(raw)) {
+ return c.json({ error: `timestamps['${slot}'] must be a finite Unix ms value` }, 400);
}
parsed[slot] = raw;
}