From b6823867ffbd7437834a3262200f59e9dcc0ce80 Mon Sep 17 00:00:00 2001 From: Dan Connolly Date: Wed, 7 Oct 2026 04:34:48 -0500 Subject: [PATCH] diigo-cache: the caches take their clock as a constructor argument Ocap discipline: no ambient time in logic. PagedCache reads time and waits only through a Clock { now, sleep }, a default constructor argument (the live clock; the runtime constructs DOs with ctx and env only). The fill job's timestamps and alarms, the etch time, and the diigo-call pacing all go through it. Tests interpose through the constructor: tests/test-worker.ts (now the tests' main module) adds FixedClockCache, bound as CLOCKED_DO, and a test pins startedAt, updatedAt, and the alarm time to the fixed clock (an ambient new Date() fails it). Generated-by: Claude Opus 5.5 --- diigo-cache/src/cache.ts | 47 ++++++++++++++++++++++++-------- diigo-cache/tests/fill.test.ts | 14 ++++++++++ diigo-cache/tests/test-worker.ts | 19 +++++++++++++ diigo-cache/wrangler.tests.jsonc | 13 +++++---- 4 files changed, 76 insertions(+), 17 deletions(-) create mode 100644 diigo-cache/tests/test-worker.ts diff --git a/diigo-cache/src/cache.ts b/diigo-cache/src/cache.ts index c2ffdf5..86eafee 100644 --- a/diigo-cache/src/cache.ts +++ b/diigo-cache/src/cache.ts @@ -84,9 +84,19 @@ function disposeStub(stub: unknown): void { if (typeof dispose === "function") dispose.call(stub); } -function sleep(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} +/** the clock authority a cache uses: reading time, and waiting. Passed + * in (ocap discipline), so tests can interpose a fixed clock. */ +export type Clock = { + /** epoch milliseconds */ + now(): number; + sleep(ms: number): Promise; +}; + +/** the live clock: the one place this module touches time */ +const liveClock: Clock = { + now: () => Date.now(), + sleep: (ms) => new Promise((r) => setTimeout(r, ms)), +}; /** a cache's wiring: the webapp instance it asks on a miss, plus (private * cache only) the public companion's id, recorded once published */ @@ -110,6 +120,19 @@ export type CacheEnv = { abstract class PagedCache extends DurableObject { #step = 0; #pause?: Promise; + #clock: Clock; + + /** the runtime constructs DOs with (ctx, env); `clock` defaults to the + * live one, and a subclass can pass another */ + constructor(ctx: DurableObjectState, env: CacheEnv, clock: Clock = liveClock) { + super(ctx, env); + this.#clock = clock; + } + + /** now, as an ISO timestamp */ + #iso(): string { + return new Date(this.#clock.now()).toISOString(); + } /** one-shot wiring. Idempotent; different values are refused (mint a * new cache instead). */ @@ -146,7 +169,7 @@ abstract class PagedCache extends DurableObject { if (current?.state === "running") return current; const step = Math.trunc(opts.pagesPerStep ?? 20); if (!(step >= 1 && step <= 50)) throw new Error("pagesPerStep: expected 1–50"); - const now = new Date().toISOString(); + const now = this.#iso(); const status: FillStatus = { state: "running", nextPage: 1, @@ -156,7 +179,7 @@ abstract class PagedCache extends DurableObject { updatedAt: now, }; await this.ctx.storage.put({ fill: status, fillStep: step }); - await this.ctx.storage.setAlarm(Date.now() + FILL_STEP_DELAY_MS); + await this.ctx.storage.setAlarm(this.#clock.now() + FILL_STEP_DELAY_MS); console.log({ event: "fill", kind: this.kind, state: status.state, pagesPerStep: step }); return status; } @@ -173,7 +196,7 @@ abstract class PagedCache extends DurableObject { const current = await this.fillStatus(); if (!current) throw new Error("no fill job to stop"); if (current.state !== "running") return current; - const stopped: FillStatus = { ...current, state: "stopped", updatedAt: new Date().toISOString() }; + const stopped: FillStatus = { ...current, state: "stopped", updatedAt: this.#iso() }; await this.ctx.storage.put("fill", stopped); await this.ctx.storage.deleteAlarm(); console.log({ event: "fill", kind: this.kind, state: "stopped", nextPage: stopped.nextPage, pages: stopped.pages }); @@ -197,10 +220,10 @@ abstract class PagedCache extends DurableObject { nextPage: status.nextPage + 1, pages: Math.max(1, Math.ceil(page.total / PAGE_SIZE)), fetched: status.fetched + (page.cached ? 0 : 1), - updatedAt: new Date().toISOString(), + updatedAt: this.#iso(), }; } catch (err) { - status = { ...status, state: "failed", error: String(err), updatedAt: new Date().toISOString() }; + status = { ...status, state: "failed", error: String(err), updatedAt: this.#iso() }; console.error({ event: "fill", kind: this.kind, state: "failed", page: status.nextPage, error: status.error }); } // a stop that landed mid-step wins @@ -209,11 +232,11 @@ abstract class PagedCache extends DurableObject { } if (!status) return; if (status.state === "running" && status.pages !== null && status.nextPage > status.pages) { - status = { ...status, state: "done", updatedAt: new Date().toISOString() }; + status = { ...status, state: "done", updatedAt: this.#iso() }; } await this.ctx.storage.put("fill", status); console.log({ event: "fill", kind: this.kind, state: status.state, nextPage: status.nextPage, pages: status.pages, fetched: status.fetched }); - if (status.state === "running") await this.ctx.storage.setAlarm(Date.now() + FILL_STEP_DELAY_MS); + if (status.state === "running") await this.ctx.storage.setAlarm(this.#clock.now() + FILL_STEP_DELAY_MS); } /** diigo's page count, if any cached page recorded `total` */ @@ -244,7 +267,7 @@ abstract class PagedCache extends DurableObject { if (this.#pause) await this.#pause; } while (before !== this.#step); this.#step += 1; - this.#pause = sleep(MS_PER_CALL); + this.#pause = this.#clock.sleep(MS_PER_CALL); const hit = await this.#pageFromStorage(pageNum, count); if (hit) { @@ -288,7 +311,7 @@ abstract class PagedCache extends DurableObject { } this.ctx.storage.sql.exec( "insert into pages (page_num, count, total, etched_at) values (?, ?, ?, ?) on conflict(page_num) do update set count = excluded.count, total = excluded.total, etched_at = excluded.etched_at", - pageNum, count, total, new Date().toISOString(), + pageNum, count, total, this.#iso(), ); }); return { total, items: rows.map((r) => r.json), cached: false }; diff --git a/diigo-cache/tests/fill.test.ts b/diigo-cache/tests/fill.test.ts index 8ff6b9c..d151a69 100644 --- a/diigo-cache/tests/fill.test.ts +++ b/diigo-cache/tests/fill.test.ts @@ -5,6 +5,7 @@ import worker from "../src/index.js"; import { newHttpBatchRpcSession } from "capnweb"; import type { CacheApi } from "../src/api.js"; import { toWebkey } from "../src/webkeys.js"; +import { FIXED_NOW } from "./test-worker.js"; // goal 1e (capsite checklist): a job that grabs every page. Fake // accounts: tests/fake-webapp.js ("fake-webapp-id": 30 items = 2 pages @@ -12,6 +13,7 @@ import { toWebkey } from "../src/webkeys.js"; type TestEnv = { CACHE_DO: DurableObjectNamespace; + CLOCKED_DO: DurableObjectNamespace; PUBLIC_DO: DurableObjectNamespace; WEBAPP: { calls(): Promise }; }; @@ -107,6 +109,18 @@ describe("fill job (goal 1e)", () => { } }); + it("reads time only through its clock: a fixed clock pins the timestamps and the alarm", async () => { + const stub = testEnv.CLOCKED_DO.get(testEnv.CLOCKED_DO.newUniqueId()); + await stub.setup({ webappId: "fake-webapp-id" }); + const at = new Date(FIXED_NOW).toISOString(); + expect(await stub.startFill()).toMatchObject({ startedAt: at, updatedAt: at }); + await runInDurableObject(stub, async (_obj, state) => { + expect(await state.storage.getAlarm()).toBe(FIXED_NOW + 1000); + }); + await drain(stub); + expect(await stub.fillStatus()).toMatchObject({ state: "done", updatedAt: at }); + }); + it("a public cache's fill stores no private rows", async () => { const stub = testEnv.PUBLIC_DO.get(testEnv.PUBLIC_DO.newUniqueId()); await stub.setup({ webappId: "fake-webapp-id" }); diff --git a/diigo-cache/tests/test-worker.ts b/diigo-cache/tests/test-worker.ts new file mode 100644 index 0000000..2caba86 --- /dev/null +++ b/diigo-cache/tests/test-worker.ts @@ -0,0 +1,19 @@ +/** + * @file the tests' main module: the real worker, plus a DiigoCache on a + * fixed clock (bound as CLOCKED_DO in wrangler.tests.jsonc), so tests can + * interpose time through the constructor. + */ +import { DiigoCache } from "../src/cache.js"; +import type { CacheEnv } from "../src/cache.js"; + +export * from "../src/index.js"; +export { default } from "../src/index.js"; + +/** 2026-10-07T12:00:00Z */ +export const FIXED_NOW = Date.UTC(2026, 9, 7, 12, 0, 0); + +export class FixedClockCache extends DiigoCache { + constructor(ctx: DurableObjectState, env: CacheEnv) { + super(ctx, env, { now: () => FIXED_NOW, sleep: async () => {} }); + } +} diff --git a/diigo-cache/wrangler.tests.jsonc b/diigo-cache/wrangler.tests.jsonc index e14cd1e..1a6823a 100644 --- a/diigo-cache/wrangler.tests.jsonc +++ b/diigo-cache/wrangler.tests.jsonc @@ -1,9 +1,10 @@ { "$schema": "node_modules/wrangler/config-schema.json", - // the real worker, with WEBAPP bound to tests/fake-webapp.js (an aux - // worker declared in vitest.config.ts) + // the real worker (via tests/test-worker.ts, which adds a fixed-clock + // cache), with WEBAPP bound to tests/fake-webapp.js (an aux worker + // declared in vitest.config.ts) "name": "diigo-cache-tests", - "main": "./src/index.ts", + "main": "./tests/test-worker.ts", "compatibility_date": "2026-09-29", "rules": [{ "type": "Text", "globs": ["**/capnweb/dist/index.js"], "fallthrough": true }], "services": [ @@ -12,11 +13,13 @@ "durable_objects": { "bindings": [ { "name": "CACHE_DO", "class_name": "DiigoCache" }, - { "name": "PUBLIC_DO", "class_name": "PublicCache" } + { "name": "PUBLIC_DO", "class_name": "PublicCache" }, + { "name": "CLOCKED_DO", "class_name": "FixedClockCache" } ] }, "exports": { "DiigoCache": { "type": "durable-object", "storage": "sqlite" }, - "PublicCache": { "type": "durable-object", "storage": "sqlite" } + "PublicCache": { "type": "durable-object", "storage": "sqlite" }, + "FixedClockCache": { "type": "durable-object", "storage": "sqlite" } } }