From 9ed94ff62bf24676fd04fa4c3823882b14de9159 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Fri, 29 May 2026 22:15:06 -0700 Subject: [PATCH] be less aggressive about dropping fresh read positions --- backend/src/routes/reading.ts | 17 +++++++++++++---- backend/test/reading-positions.spec.ts | 2 +- frontend/src/lib/stores/itemLabels.svelte.ts | 2 +- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/routes/reading.ts b/backend/src/routes/reading.ts index 2f31ea4..2a50b8e 100644 --- a/backend/src/routes/reading.ts +++ b/backend/src/routes/reading.ts @@ -24,13 +24,22 @@ interface ItemLabelRow { // 1. Slim payload — only item_guid/read_at/rkey are returned (no url/title). // 2. Delta sync — `?since=` returns only rows changed since the // client's cursor; the common refresh is then tiny. -// 3. Windowing — a full sync (no `since`) is bounded to recent reads, since -// older read state can't suppress articles no longer fetched. +// 3. Windowing — a full sync (no `since`) is bounded to recent reads. +// The window must be wide enough to cover every article the feed can surface on +// a cold start (empty cache). The cold-start fetch pulls the most-recent N items +// PER FEED regardless of age (see COLD_START_LIMIT in feedFetcher.ts), so a +// low-frequency feed's backlog can be well over a year old. If a read falls +// outside this window, that already-read article reappears as unread on a fresh +// login — there's no local read state to fall back on after logout clears the +// cache. Hence a generous window; the delta sync keeps steady-state cheap anyway. // NOTE: the window must stay in sync with READ_POSITIONS_WINDOW_MS in the // frontend's itemLabels store, which scopes its reconcile deletions to the same // window so it never drops older local read state. -const READ_POSITIONS_WINDOW_SECONDS = 90 * 24 * 60 * 60; // 90 days -const READ_POSITIONS_MAX_ROWS = 5000; // hard backstop on a single response +const READ_POSITIONS_WINDOW_SECONDS = 2 * 365 * 24 * 60 * 60; // 2 years +// Backstop on a single response. Must stay above the number of reads a heavy +// user can accumulate within the window, or ORDER BY updated_at DESC would drop +// the OLDEST in-window reads first — exactly the old articles we need to suppress. +const READ_POSITIONS_MAX_ROWS = 100000; // GET /api/reading/positions - List read positions for the current user. // Pass `?since=` for an incremental (delta) fetch; omit it for a diff --git a/backend/test/reading-positions.spec.ts b/backend/test/reading-positions.spec.ts index 46b2658..2974f50 100644 --- a/backend/test/reading-positions.spec.ts +++ b/backend/test/reading-positions.spec.ts @@ -7,7 +7,7 @@ const IncomingRequest = Request; const TEST_DID = 'did:plc:readingpositions123'; const TEST_SESSION_ID = 'test-session-reading-positions'; const DAY_SECONDS = 24 * 60 * 60; -const WINDOW_SECONDS = 90 * DAY_SECONDS; // must match READ_POSITIONS_WINDOW_SECONDS +const WINDOW_SECONDS = 2 * 365 * DAY_SECONDS; // must match READ_POSITIONS_WINDOW_SECONDS async function setupTestUser() { await env.DB.prepare( diff --git a/frontend/src/lib/stores/itemLabels.svelte.ts b/frontend/src/lib/stores/itemLabels.svelte.ts index 6741b86..88ae83e 100644 --- a/frontend/src/lib/stores/itemLabels.svelte.ts +++ b/frontend/src/lib/stores/itemLabels.svelte.ts @@ -25,7 +25,7 @@ const BULK_BATCH_SIZE = 500; // READ_POSITIONS_WINDOW_SECONDS in the backend's reading route: a full sync only // reconciles (deletes) read labels within this window, since the server only // returns reads this recent. Older local read state is preserved untouched. -const READ_POSITIONS_WINDOW_MS = 90 * 24 * 60 * 60 * 1000; // 90 days +const READ_POSITIONS_WINDOW_MS = 2 * 365 * 24 * 60 * 60 * 1000; // 2 years // Re-export for consumers that used this type from reading store export interface SavedArticle {