diigo-cache: diigo counts pages from 0; migrate stored pages (schema v2)
Comparing a cache with diigo's RSS export: the size-100 walk had missed
exactly the 100 newest bookmarks (everything updated since 2026-09-16).
diigo's page_num is 0-based, and we sent our 1-based page number
unchanged, so "page 1" was really page 2 and the newest page was never
fetched. diigo's total (16744) was right; the "total-overstated"
reading was a misdiagnosis.
- page() asks for page_num = n - 1
- BREAKING for stored state, agreed beforehand, migrated in place:
schema v2 renumbers every stored page +1 (pages and page_members, via
negation so no number collides), once, recorded as kv "schema" = 2;
nothing is refetched, page 1 just becomes a miss
- fill jobs record their numbering; a job saved without it fails ("page
numbering changed …; start the fill again") instead of resuming
- the fake pages from 0 like diigo (7 tests failed until the fix);
tests feed v1-shaped storage and a v1-numbered job to the new code
- the next-page peek guard stays; its comments no longer claim diigo
overstates its total
- the fixed test clock moves to 2099: pinned to "today" its alarms came
due in real time and fired on their own
Generated-by: Claude Opus 5.5
This commit is contained in:
1 parent
e6deb9fda0
commit
cffb1fa9b2
5 files changed
+116
-17
No files matched your search
@@ -88,7 +88,7 @@ describe("page size", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("a short page whose next page is empty is the real end: diigo's total overstated, noted not refused", async () => {
|
||||
it("a short page whose next page is empty is the real end: an overstated total is noted, not refused", async () => {
|
||||
const spy = vi.spyOn(console, "log");
|
||||
try {
|
||||
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||
@@ -122,6 +122,43 @@ describe("pacing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("page numbering (schema v2)", () => {
|
||||
it("asks diigo for page_num n-1: page 1 is the newest page", async () => {
|
||||
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||
await stub.setup({ webappId: WEBAPP_ID });
|
||||
const first = (await stub.page(1, PAGE_SIZE)).items.map((j) => (JSON.parse(j) as { title: string }).title);
|
||||
expect(first[0]).toBe("bookmark 1");
|
||||
});
|
||||
|
||||
it("migrates v1 storage: a stored 'page n' was really page n+1, so it is renumbered, not refetched", async () => {
|
||||
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||
await stub.setup({ webappId: WEBAPP_ID });
|
||||
// the shape v1 left: tables, a page 1 holding what diigo's
|
||||
// page_num=1 returned (really page 2), and no schema marker
|
||||
await runInDurableObject(stub, (_obj, state) => {
|
||||
const sql = state.storage.sql;
|
||||
sql.exec(`create table items (item_id text primary key, url text not null, title text not null,
|
||||
private integer not null, json text not null, updated text not null)`);
|
||||
sql.exec("create table pages (page_num integer primary key, count integer not null, total integer, etched_at text not null)");
|
||||
sql.exec("create table page_members (page_num integer not null, pos integer not null, item_id text not null, primary key (page_num, pos))");
|
||||
sql.exec("insert into items values ('a', 'https://a.example/', 'old page one, first', 0, '{\"title\":\"A\"}', '1')");
|
||||
sql.exec("insert into items values ('b', 'https://b.example/', 'old page one, second', 0, '{\"title\":\"B\"}', '1')");
|
||||
sql.exec("insert into pages values (1, 100, 250, '2026-10-07T00:00:00.000Z')");
|
||||
sql.exec("insert into page_members values (1, 1, 'a'), (1, 2, 'b')");
|
||||
});
|
||||
const before = await testEnv.WEBAPP.calls();
|
||||
const two = await stub.page(2, PAGE_SIZE);
|
||||
expect(two.cached).toBe(true);
|
||||
expect(two.items.map((j) => (JSON.parse(j) as { title: string }).title)).toEqual(["A", "B"]);
|
||||
expect(await testEnv.WEBAPP.calls()).toBe(before);
|
||||
// page 1 was never fetched under v1: now it is a miss
|
||||
expect((await stub.page(1, PAGE_SIZE)).cached).toBe(false);
|
||||
await runInDurableObject(stub, (_obj, state) => {
|
||||
expect(state.storage.kv.get("schema")).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("eviction", () => {
|
||||
it("a cache miss leaves no undisposed RPC stubs to block eviction", async () => {
|
||||
const stub = testEnv.CACHE_DO.get(testEnv.CACHE_DO.newUniqueId());
|
||||
|
||||
@@ -14,7 +14,8 @@ const TOTAL = 250;
|
||||
const CAPPED_WEBAPP = "fake-capped-webapp";
|
||||
const CAP = 64;
|
||||
/** the plain account, but its `total` claims 60 more items than it has
|
||||
* (as diigo's does, observed 2026-10-07) */
|
||||
* (hypothetical: what looked like this on 2026-10-07 was the 0-based
|
||||
* page_num, but the guard stays) */
|
||||
const OVERSTATED_WEBAPP = "fake-overstated-webapp";
|
||||
const OVERSTATEMENT = 60;
|
||||
|
||||
@@ -93,7 +94,9 @@ class FakeGet extends RpcTarget {
|
||||
const total = this.#dated === "deep" ? DEEP_TOTAL : dated ? DATED_TOTAL : TOTAL;
|
||||
const now = Date.now();
|
||||
const items = [];
|
||||
for (let n = (page - 1) * count + 1; n <= Math.min(page * count, total); n += 1) {
|
||||
// diigo's page_num counts from 0 (observed 2026-10-07: page_num=1
|
||||
// skipped the newest page)
|
||||
for (let n = page * count + 1; n <= Math.min((page + 1) * count, total); n += 1) {
|
||||
items.push(dated ? datedItem(n, now, this.#dated === "deep" ? "deep_dee" : "dated_dan") : item(n));
|
||||
}
|
||||
// past the end, diigo answers no items and a total of 0 (observed
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("fill job (goal 1e)", () => {
|
||||
expect(status?.error).toMatch(/page size not honored/);
|
||||
});
|
||||
|
||||
it("fills to the real end when diigo's total overstates its items", async () => {
|
||||
it("fills to the real end if a total overstates the items", async () => {
|
||||
const stub = await wired("fake-overstated-webapp");
|
||||
await stub.startFill();
|
||||
await drain(stub);
|
||||
@@ -143,6 +143,18 @@ describe("fill job (goal 1e)", () => {
|
||||
expect((await stub.page(3, PAGE_SIZE)).items).toHaveLength(50);
|
||||
});
|
||||
|
||||
it("a job saved under the old page numbering fails instead of resuming", async () => {
|
||||
const stub = await wired();
|
||||
await runInDurableObject(stub, async (_obj, state) => {
|
||||
const at = new Date().toISOString();
|
||||
await state.storage.put("fill", { state: "running", nextPage: 40, pages: 168, fetched: 39, pageSize: 100, startedAt: at, updatedAt: at });
|
||||
await state.storage.setAlarm(Date.now() + 60_000);
|
||||
});
|
||||
await drain(stub);
|
||||
expect((await stub.fillStatus())?.error).toMatch(/page numbering changed/);
|
||||
expect(await stub.startFill()).toMatchObject({ state: "running", nextPage: 1, numbering: 2 });
|
||||
});
|
||||
|
||||
it("a job saved under another page size fails instead of resuming; a new start replaces it", async () => {
|
||||
const stub = await wired();
|
||||
// the shape a 24-per-page job left in storage (no pageSize field)
|
||||
|
||||
@@ -9,8 +9,9 @@ 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);
|
||||
/** 2099-01-01T00:00:00Z: in the future, so an alarm set by this clock is
|
||||
* never due in real time — tests run it with runDurableObjectAlarm */
|
||||
export const FIXED_NOW = Date.UTC(2099, 0, 1, 0, 0, 0);
|
||||
|
||||
/** a fixed clock that records each sleep it is asked for */
|
||||
export class FixedClockCache extends DiigoCacheLogic {
|
||||
|
||||
Reference in new issue
Block a user