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
This commit is contained in:
dckc committed 2026-10-07 04:34:48 -05:00
1 parent 7d2100bf77
commit b6823867ff
4 files changed
+76 -17

No files matched your search

+35 -12
View File
@@ -84,9 +84,19 @@ function disposeStub(stub: unknown): void {
if (typeof dispose === "function") dispose.call(stub);
}
function sleep(ms: number): Promise<void> {
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<void>;
};
/** 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<CacheEnv> {
#step = 0;
#pause?: Promise<void>;
#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<CacheEnv> {
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<CacheEnv> {
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<CacheEnv> {
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<CacheEnv> {
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<CacheEnv> {
}
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<CacheEnv> {
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<CacheEnv> {
}
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 };
+14
View File
@@ -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<import("../src/cache.js").DiigoCache>;
CLOCKED_DO: DurableObjectNamespace<import("./test-worker.js").FixedClockCache>;
PUBLIC_DO: DurableObjectNamespace<import("../src/cache.js").PublicCache>;
WEBAPP: { calls(): Promise<number> };
};
@@ -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" });
+19
View File
@@ -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 () => {} });
}
}
+8 -5
View File
@@ -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" }
}
}