diff --git a/CLAUDE.md b/CLAUDE.md index 4a8c3b99..60cd4043 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,6 +59,10 @@ src/server/ plugins/ # Content source plugins (LessWrong, Google Docs, ArXiv, GitHub) mcp/ # MCP server src/lib/ # Shared utilities (client and server) + collections/ # TanStack DB collections (primary client state store) + hooks/ # Custom React hooks + cache/ # SSE event handlers and collection update operations + trpc/ # tRPC clients (React, vanilla, server, query-client) src/components/ # React components src/app/ # Next.js routes tests/unit/ # Pure logic tests (no mocks, no DB) diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3cdb6154..bb9f414e 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -20,10 +20,10 @@ For detailed feature designs, see the docs in `docs/features/`. Visual architecture diagrams are available in `docs/diagrams/`: -- **[frontend-data-flow.d2](diagrams/frontend-data-flow.d2)** - Delta-based state management with React Query +- **[frontend-data-flow.d2](diagrams/frontend-data-flow.d2)** - TanStack DB collections with on-demand data loading - **[backend-api.d2](diagrams/backend-api.d2)** - tRPC routers, services layer, and database - **[feed-fetcher.d2](diagrams/feed-fetcher.d2)** - Background job queue and feed processing pipeline -- **[sse-cache-updates.d2](diagrams/sse-cache-updates.d2)** - SSE event flow from backend to frontend cache updates +- **[sse-cache-updates.d2](diagrams/sse-cache-updates.d2)** - SSE event flow from backend to frontend collection updates To render these diagrams, use the [D2 CLI](https://d2lang.com/) or [D2 Playground](https://play.d2lang.com/). @@ -229,8 +229,8 @@ Lion Reader respects server Cache-Control headers, Retry-After directives, and H 2. Worker publishes to per-feed Redis channel: `PUBLISH feed:{feedId}:events {type, entryId, ...}` 3. SSE connections subscribe only to channels for feeds their user cares about 4. App server receives message, forwards to client -5. Client receives event, invalidates React Query cache -6. UI updates automatically +5. Client receives event, updates TanStack DB collections (counts, subscriptions, entries) +6. UI updates automatically via live queries ### Channel Design @@ -386,6 +386,64 @@ app/ - `components/app/` - App-level components - `components/ui/` - Generic UI primitives +### Client-Side State Management + +Lion Reader uses **TanStack DB collections** as the primary client-side state store, with React Query handling SSR prefetching and entry detail views. + +#### Collections Architecture (`src/lib/collections/`) + +TanStack DB collections provide reactive, live-queryable state. There are two types: + +- **Local-only collections**: Client-managed state updated by SSE events and mutations (subscriptions, entries, counts) +- **Query-backed on-demand collections**: Fetch pages from the server as the user scrolls, using an offset-to-cursor bridge to translate TanStack DB's offset-based loading to the cursor-based API (per-view entries, per-tag sidebar subscriptions) + +| Collection | Type | Purpose | +| ------------------------ | ------------------------ | -------------------------------------------- | +| **subscriptions** | Local-only | Sidebar subscription state and unread counts | +| **tags** | Query-backed (eager) | Tag list with feed/unread counts | +| **entries** (global) | Local-only | Lookup cache for SSE updates and detail view | +| **entries** (per-view) | Query-backed (on-demand) | Paginated entry list for the current view | +| **counts** | Local-only | Entry counts (all, starred, saved, etc.) | +| **activeViewCollection** | Reference | Points to current view's entry collection | + +#### Data Flow + +``` +Server ──(tRPC)──> React Query (SSR prefetch) ──> TanStack DB Collections + │ +SSE/Sync events ──────────────────────────────────> Collection writes + │ + Live Queries + │ + Components +``` + +1. **SSR prefetch**: React Query prefetches data on the server; collections check this cache to avoid redundant first-page fetches +2. **On-demand loading**: As users scroll, view collections fetch additional pages via the vanilla tRPC client +3. **Mutations**: Write to both the global collection and the `activeViewCollection` for immediate UI updates +4. **SSE events**: Update counts, subscriptions, and entry state directly in collections +5. **Display stability**: `useStableEntryList` merges live query results with previously-seen entries so items don't disappear when their state changes (e.g., marking read in "unread only" view) + +#### Key Hooks + +| Hook | Purpose | +| ------------------------------- | ------------------------------------------------------- | +| `useViewEntriesCollection` | Creates per-view on-demand entry collection | +| `useStableEntryList` | Prevents entries from disappearing on state change | +| `useEntryNavigation` | Shares next/prev entry IDs between list and detail view | +| `useTagSubscriptionsCollection` | Creates per-tag subscription collection for sidebar | +| `useEntryMutations` | Entry mutations with collection dual-writes | +| `useRealtimeUpdates` | SSE connection with collection-based event handling | + +#### React Query's Remaining Role + +React Query is still used for: + +- **SSR prefetch seeding** via `prefetchInfiniteQuery` / `prefetchQuery` +- **Entry detail view** (`entries.get`) cache +- **Import progress** tracking +- **Pagination invalidation** as a trigger for collection refresh + --- ## MCP Server diff --git a/docs/diagrams/frontend-data-flow.d2 b/docs/diagrams/frontend-data-flow.d2 index 652fb921..026ee02e 100644 --- a/docs/diagrams/frontend-data-flow.d2 +++ b/docs/diagrams/frontend-data-flow.d2 @@ -1,5 +1,6 @@ -# Frontend Data Flow - React Query with Direct Cache Updates -# Lion Reader uses React Query as the source of truth with helper functions for cache updates +# Frontend Data Flow - TanStack DB Collections with On-Demand Loading +# Lion Reader uses TanStack DB collections as the primary client state store, +# with React Query for SSR prefetch seeding and entry detail views. direction: down @@ -39,31 +40,43 @@ client: { style.fill: "#fff3e0" react_query: { - label: React Query Cache + label: React Query shape: cylinder style.fill: "#ffcc80" description: |md - **Server state cache** - - entries.list (infinite) - - entries.get - - entries.count - - subscriptions.list - - tags.list + **SSR prefetch + detail views** + - entries.get (detail view) + - SSR prefetch seeding + - imports.get/list | } - cache_helpers: { - label: Cache Helpers - shape: rectangle + collections: { + label: TanStack DB Collections + shape: cylinder style.fill: "#a5d6a7" description: |md - **src/lib/cache/** - - adjustSubscriptionUnreadCounts - - adjustTagUnreadCounts - - addSubscriptionToCache - - updateEntriesReadStatus + **Primary state store** + - subscriptions (local-only) + - tags (query-backed, eager) + - entries global (local-only) + - entries per-view (on-demand) + - counts (local-only) + | + } + + writes: { + label: Collection Writes + shape: rectangle + style.fill: "#c8e6c9" + + description: |md + **src/lib/collections/writes.ts** + - Dual-write: global + activeViewCollection + - Subscription/tag count adjustments + - Entry state updates (read, starred, score) | } } @@ -79,19 +92,31 @@ hooks: { useRealtimeUpdates: { label: useRealtimeUpdates shape: rectangle - tooltip: "Manages SSE connection with polling fallback" + tooltip: "SSE connection with polling fallback, delegates to collection writes" } useEntryMutations: { label: useEntryMutations shape: rectangle - tooltip: "Mutations with direct cache updates" + tooltip: "Mutations with dual-write to global + view collections" + } + + useViewEntriesCollection: { + label: useViewEntriesCollection + shape: rectangle + tooltip: "Creates per-view on-demand collection with offset-to-cursor bridge" } - useKeyboardShortcuts: { - label: useKeyboardShortcuts + useStableEntryList: { + label: useStableEntryList shape: rectangle - tooltip: "Keyboard navigation and entry selection" + tooltip: "Display stability - merges live entries with previously-seen entries" + } + + useEntryNavigation: { + label: useEntryNavigation + shape: rectangle + tooltip: "External store sharing next/prev entry IDs between list and detail" } } @@ -106,19 +131,19 @@ ui: { sidebar: { label: Sidebar shape: rectangle - tooltip: "Subscription list with unread counts" + tooltip: "Subscription list from per-tag on-demand collections" } unified_entries: { label: UnifiedEntriesContent shape: rectangle - tooltip: "Unified entry page with navigation and swipe support" + tooltip: "Unified entry page with swipe navigation" } suspending_list: { label: SuspendingEntryList shape: rectangle - tooltip: "Entry list with Suspense and keyboard navigation" + tooltip: "Entry list via useLiveInfiniteQuery over view collection" } entry_list: { @@ -135,55 +160,69 @@ ui: { } # ============================================================================== -# Connections - Initial Data Fetch +# Connections - SSR Prefetch # ============================================================================== -server.api -> client.react_query: "Initial fetch" { +server.api -> client.react_query: "SSR prefetch" { style.stroke: "#1976d2" style.stroke-width: 2 } +client.react_query -> client.collections: "Seed first page\n(avoid redundant fetch)" { + style.stroke: "#1976d2" + style.stroke-dash: 3 +} + # ============================================================================== -# Connections - Real-time Updates +# Connections - On-Demand Loading # ============================================================================== -server.sse -> hooks.useRealtimeUpdates: "SSE events\n(new_entry, subscription_*)" { - style.stroke: "#388e3c" +server.api -> client.collections: "On-demand pages\n(vanilla tRPC client)" { + style.stroke: "#00796b" style.stroke-width: 2 } -hooks.useRealtimeUpdates -> client.cache_helpers: "subscription_created\nsubscription_deleted" { +# ============================================================================== +# Connections - Real-time Updates +# ============================================================================== + +server.sse -> hooks.useRealtimeUpdates: "SSE events\n(new_entry, subscription_*)" { style.stroke: "#388e3c" + style.stroke-width: 2 } -hooks.useRealtimeUpdates -> client.react_query: "invalidate (new_entry)" { +hooks.useRealtimeUpdates -> client.writes: "Update collections\n(counts, subs, entries)" { style.stroke: "#388e3c" - style.stroke-dash: 3 } -client.cache_helpers -> client.react_query: "Direct cache updates" { +client.writes -> client.collections: "Write to collections" { style.stroke: "#00796b" style.stroke-width: 2 } # ============================================================================== -# Connections - Query Data Flow +# Connections - Collection Data Flow # ============================================================================== -client.react_query -> ui.unified_entries: "Paginated entries\n(non-suspending)" { +client.collections -> hooks.useViewEntriesCollection: "Create per-view\non-demand collection" { style.stroke: "#7b1fa2" } -client.react_query -> ui.suspending_list: "Paginated entries\n(suspending)" { +hooks.useViewEntriesCollection -> ui.suspending_list: "useLiveInfiniteQuery\n(reactive entries)" { + style.stroke: "#7b1fa2" + style.stroke-width: 2 +} + +ui.suspending_list -> hooks.useStableEntryList: "Merge with\npreviously-seen entries" { style.stroke: "#7b1fa2" } -ui.suspending_list -> ui.entry_list: "Entries with\nread/starred state" { +hooks.useStableEntryList -> ui.entry_list: "Stable entries\n(no disappearing)" { style.stroke: "#7b1fa2" style.stroke-width: 2 } -client.react_query -> ui.sidebar: "Subscriptions/tags\nwith counts" { +client.collections -> ui.sidebar: "Per-tag subscription\ncollections" { style.stroke: "#7b1fa2" } @@ -199,35 +238,39 @@ hooks.useEntryMutations -> server.api: "1. Server mutation" { style.stroke: "#f57c00" } -server.api -> hooks.useEntryMutations: "2. Response with\nsubscription context" { +server.api -> hooks.useEntryMutations: "2. Response" { style.stroke: "#f57c00" style.stroke-dash: 3 } -hooks.useEntryMutations -> client.cache_helpers: "3. Update counts\n(instant)" { +hooks.useEntryMutations -> client.writes: "3. Dual-write\n(global + view)" { style.stroke: "#f57c00" style.stroke-width: 2 } -hooks.useEntryMutations -> client.react_query: "4. Invalidate\nentry lists" { - style.stroke: "#f57c00" - style.stroke-dash: 3 -} - # ============================================================================== -# Connections - Navigation and Keyboard +# Connections - Entry Navigation # ============================================================================== -ui.suspending_list -> hooks.useKeyboardShortcuts: "Keyboard navigation" { +ui.suspending_list -> hooks.useEntryNavigation: "Publish next/prev\nentry IDs" { style.stroke: "#9e9e9e" style.stroke-dash: 3 } -ui.unified_entries -> ui.entry_content: "Entry view with\nswipe navigation" { +hooks.useEntryNavigation -> ui.unified_entries: "Consume next/prev\nfor swipe" { style.stroke: "#9e9e9e" style.stroke-dash: 3 } +ui.unified_entries -> ui.entry_content: "Entry detail view" { + style.stroke: "#9e9e9e" + style.stroke-dash: 3 +} + +client.react_query -> ui.entry_content: "entries.get\n(full content)" { + style.stroke: "#1976d2" +} + # ============================================================================== # Legend # ============================================================================== @@ -238,10 +281,10 @@ legend: { style.fill: "#fafafa" style.stroke: "#e0e0e0" - initial: "Blue: Initial data fetch" - realtime: "Green: Real-time updates (SSE)" - query: "Purple: Query data flow" - mutation: "Orange: User mutations" - cache: "Teal: Direct cache updates" - dashed: "Dashed: Invalidation" + initial: "Blue: SSR prefetch + detail views (React Query)" + ondemand: "Teal: On-demand loading + collection writes" + realtime: "Green: Real-time updates (SSE → collections)" + query: "Purple: Collection data flow to UI" + mutation: "Orange: User mutations (dual-write)" + nav: "Gray: Navigation state" } diff --git a/docs/diagrams/sse-cache-updates.d2 b/docs/diagrams/sse-cache-updates.d2 index 218915e0..0a58a03c 100644 --- a/docs/diagrams/sse-cache-updates.d2 +++ b/docs/diagrams/sse-cache-updates.d2 @@ -1,7 +1,8 @@ -# SSE Event Flow and Cache Update Architecture +# SSE Event Flow and Collection Update Architecture # # This diagram shows how events flow from backend origins through Redis pub/sub -# to the SSE endpoint, and how the frontend handles them to update caches. +# to the SSE endpoint, and how the frontend handles them to update TanStack DB +# collections and React Query caches. direction: down @@ -35,6 +36,13 @@ event-sources: Event Sources { saved-updated: publishEntryUpdated(savedFeedId, entryId) } + entries-router: Entries Router { + style.fill: "#e0e7ff" + tooltip: "src/server/trpc/routers/entries.ts" + + entry-state-changed: publishEntryStateChanged(userId, entryId, read, starred) + } + subscriptions-router: Subscriptions Router { style.fill: "#e0e7ff" tooltip: "src/server/trpc/routers/subscriptions.ts" @@ -43,6 +51,15 @@ event-sources: Event Sources { sub-deleted: publishSubscriptionDeleted(userId, feedId, subscriptionId) } + tags-service: Tags Service { + style.fill: "#e0e7ff" + tooltip: "src/server/services/tags.ts" + + tag-created: publishTagCreated(userId, tag) + tag-updated: publishTagUpdated(userId, tag) + tag-deleted: publishTagDeleted(userId, tagId) + } + job-handlers: Background Jobs { style.fill: "#e0e7ff" tooltip: "src/server/jobs/handlers.ts" @@ -81,8 +98,10 @@ redis: Redis Pub/Sub { Pattern: user:{userId}:events Events: + - entry_state_changed (entryId, read, starred) - subscription_created (full subscription + feed data) - subscription_deleted (subscriptionId) + - tag_created / tag_updated / tag_deleted - import_progress (per-feed progress) - import_completed (final counts) | @@ -146,16 +165,18 @@ frontend: Frontend (useRealtimeUpdates) { - connected - new_entry - entry_updated + - entry_state_changed - subscription_created - subscription_deleted + - tag_created / tag_updated / tag_deleted - import_progress - import_completed | } - handle-event: Event Handler { + handle-event: handleSyncEvent { style.fill: "#f5d0fe" - tooltip: "handleEvent callback" + tooltip: "src/lib/cache/event-handlers.ts - unified handler for SSE + polling" } polling-fallback: Polling Fallback { @@ -166,29 +187,29 @@ frontend: Frontend (useRealtimeUpdates) { - Poll sync.changes every 30s - Retry SSE every 60s - Uses granular cursors per entity type + - Same handleSyncEvent handler as SSE | } } # ============================================================================== -# CACHE UPDATE OPERATIONS +# COLLECTION UPDATE OPERATIONS # ============================================================================== -cache-ops: Cache Operations { +cache-ops: Collection & Cache Operations { style.fill: "#fff7ed" - tooltip: "src/lib/cache/operations.ts" + tooltip: "src/lib/cache/event-handlers.ts + operations.ts + collections/writes.ts" new-entry-handler: handleNewEntry { style.fill: "#fed7aa" updates: | - Updates (surgical, no refetch): - - subscriptions.list: unreadCount += 1 - - tags.list: unreadCount += 1 - - entries.count({}): unread += 1, total += 1 - - entries.count({ type: "saved" }): if feedType === "saved" + Collection updates (no refetch): + - subscriptions collection: unreadCount += 1 + - tags collection: unreadCount += 1 + - counts collection: unread += 1, total += 1 - Does NOT invalidate entries.list + Does NOT invalidate entry lists (entries appear on navigation) | } @@ -197,26 +218,37 @@ cache-ops: Cache Operations { style.fill: "#fed7aa" updates: | - Direct updates (metadata from event): + React Query (detail view): - entries.get: title, author, summary, url, publishedAt - - entries.list: same metadata fields (in-place) - Invalidations: - - entries.get({ id }): refetch full content + Collection (list view): + - entries collection: metadata update via + updateEntryMetadataInCollection() | } - sub-created-handler: handleSubscriptionCreated { + entry-state-handler: entry_state_changed { style.fill: "#fed7aa" updates: | - Direct updates (no refetch): - - subscriptions.list: add subscription - - tags.list: feedCount += 1, unreadCount += N - - entries.count({}): unread += N, total += N + React Query (detail view): + - entries.get: read, starred state + + Collection (list view): + - entries collection: read/starred via + updateEntryReadInCollection() + updateEntryStarredInCollection() + | + } + + sub-created-handler: handleSubscriptionCreated { + style.fill: "#fed7aa" - Targeted invalidations: - - subscriptions.list per-tag queries (affected tags only) + updates: | + Collection updates (no refetch): + - subscriptions collection: add subscription + - tags collection: feedCount += 1, unreadCount += N + - counts collection: unread += N, total += N | } @@ -224,25 +256,30 @@ cache-ops: Cache Operations { style.fill: "#fed7aa" updates: | - Direct updates (no refetch): - - subscriptions.list: remove subscription - - tags.list: feedCount -= 1, unreadCount -= N - - entries.count({}): unread -= N, total -= N + Collection updates (no refetch): + - subscriptions collection: remove subscription + - tags collection: feedCount -= 1, unreadCount -= N + - counts collection: unread -= N, total -= N - Invalidations: + React Query invalidation: - entries.list (entries should be filtered out) | } - import-handlers: import_progress / import_completed { + tag-handlers: tag_created / tag_updated / tag_deleted { style.fill: "#fed7aa" updates: | - import_progress: - - imports.get({ id }): invalidate - - imports.list: invalidate + Collection updates (no refetch): + - tags collection: add/update/remove tag + | + } - import_completed: + import-handlers: import_progress / import_completed { + style.fill: "#fed7aa" + + updates: | + React Query invalidations (no collection): - imports.get({ id }): invalidate - imports.list: invalidate (entry/subscription updates handled by individual events) @@ -270,12 +307,24 @@ event-sources.saved-service.saved-updated -> redis.feed-channels: entry_updated style.stroke: "#2563eb" } +event-sources.entries-router.entry-state-changed -> redis.user-channels: entry_state_changed { + style.stroke: "#7c3aed" +} event-sources.subscriptions-router.sub-created -> redis.user-channels: subscription_created { style.stroke: "#7c3aed" } event-sources.subscriptions-router.sub-deleted -> redis.user-channels: subscription_deleted { style.stroke: "#7c3aed" } +event-sources.tags-service.tag-created -> redis.user-channels: tag_created { + style.stroke: "#7c3aed" +} +event-sources.tags-service.tag-updated -> redis.user-channels: tag_updated { + style.stroke: "#7c3aed" +} +event-sources.tags-service.tag-deleted -> redis.user-channels: tag_deleted { + style.stroke: "#7c3aed" +} event-sources.job-handlers.import-sub-created -> redis.user-channels: subscription_created { style.stroke: "#7c3aed" } @@ -324,12 +373,18 @@ frontend.handle-event -> cache-ops.new-entry-handler: new_entry + feedType { frontend.handle-event -> cache-ops.entry-updated-handler: entry_updated { style.stroke: "#ea580c" } +frontend.handle-event -> cache-ops.entry-state-handler: entry_state_changed { + style.stroke: "#ea580c" +} frontend.handle-event -> cache-ops.sub-created-handler: subscription_created { style.stroke: "#ea580c" } frontend.handle-event -> cache-ops.sub-deleted-handler: subscription_deleted { style.stroke: "#ea580c" } +frontend.handle-event -> cache-ops.tag-handlers: tag_* { + style.stroke: "#ea580c" +} frontend.handle-event -> cache-ops.import-handlers: import_* { style.stroke: "#ea580c" } @@ -346,7 +401,7 @@ entry-types: Entry Type Differences { description: | - Published from entry-processor - Has subscriptionId - - Updates subscription + tag unread counts + - Updates subscription + tag collections (unread counts) | } @@ -355,7 +410,7 @@ entry-types: Entry Type Differences { description: | - Published from email inbound processor - Has subscriptionId - - Updates subscription + tag unread counts + - Updates subscription + tag collections (unread counts) | } @@ -364,49 +419,58 @@ entry-types: Entry Type Differences { description: | - Published from saved service/router - subscriptionId is NULL (no subscription row) - - Updates entries.count({ type: "saved" }) + - Updates counts collection (saved count) - Does NOT update subscription/tag counts | } } # ============================================================================== -# CACHE UPDATE STRATEGY NOTES +# UPDATE STRATEGY NOTES # ============================================================================== -strategy-notes: Cache Strategy { +strategy-notes: Update Strategy { style.fill: "#ecfccb" - direct-updates: Direct Updates (preferred) { + collection-writes: Collection Writes (preferred) { style.fill: "#d9f99d" description: | - Surgical updates avoid refetch: + Direct writes to TanStack DB collections: - Subscription unread counts - - Tag unread counts + - Tag unread/feed counts - Global entry counts - - Entry read/starred state - - Subscription list add/remove + - Entry read/starred/score state + - Subscription add/remove + - Tag add/update/remove + + Live queries react automatically. | } - invalidations: Invalidations (force refetch) { + react-query: React Query (detail + imports) { style.fill: "#d9f99d" description: | - Force refetch when necessary: - - Entry lists (too many filter combinations) - - Entry content on update - - Import progress (complex state) + entries.get setData for detail view: + - Entry metadata updates + - Entry state changes (read/starred) + + Invalidations for imports: + - imports.get/list on progress/completion | } design-rationale: Design Rationale { style.fill: "#d9f99d" description: | - entries.list is NOT invalidated on new_entry: - - Too many filter combinations (sub, tag, unread, starred) + Entry lists are NOT refreshed on new_entry: + - On-demand collections only fetch when scrolled - User won't see new entries until navigation - Unread counts still update (sidebar shows activity) - Keeps scrolling smooth without flicker + + Display stability via useStableEntryList: + - Previously-seen entries stay visible after state change + - Resets only on view navigation | } } diff --git a/docs/features/optimistic-updates-and-cache-management.md b/docs/features/optimistic-updates-and-cache-management.md index 81620b66..5a3627a6 100644 --- a/docs/features/optimistic-updates-and-cache-management.md +++ b/docs/features/optimistic-updates-and-cache-management.md @@ -1,10 +1,12 @@ # Optimistic Updates and Cache Management Optimization -**Status:** Draft +**Status:** Largely Superseded **Created:** 2026-01-10 **Author:** Claude **Related Issues:** N/A +> **Note (2026-02):** Most of the cache management concerns in this document have been superseded by the migration to TanStack DB collections (Phases 4-6). Sidebar counts, entry lists, subscription state, and tag state are now managed via TanStack DB collections with direct writes instead of React Query cache manipulation. React Query is only used for SSR prefetch seeding, entry detail views (`entries.get`), and import progress tracking. The SSE event handler (`handleSyncEvent`) now writes directly to collections. See `src/lib/collections/` for the current architecture. + ## Overview This document describes identified inefficiencies in our client-side cache management and proposes optimizations to reduce unnecessary network requests through optimistic updates and targeted cache mutations. diff --git a/docs/features/query-normalization-plan.md b/docs/features/query-normalization-plan.md index 3cdb36dc..e2a3f02e 100644 --- a/docs/features/query-normalization-plan.md +++ b/docs/features/query-normalization-plan.md @@ -1,5 +1,7 @@ # Query Normalization Design +> **Note (2026-02):** This plan has been superseded by the migration to TanStack DB collections (Phases 4-6). Entry data is now managed through TanStack DB collections rather than React Query cache entries, eliminating the cross-query duplication problem. See `src/lib/collections/` for the current architecture. + ## Problem Entry data is duplicated across multiple React Query cache entries: diff --git a/drizzle/0055_nullable_read_changed_at.sql b/drizzle/0055_nullable_read_changed_at.sql new file mode 100644 index 00000000..5a571542 --- /dev/null +++ b/drizzle/0055_nullable_read_changed_at.sql @@ -0,0 +1,17 @@ +-- Make read_changed_at nullable so entries that have never had their read state +-- explicitly changed by the user have NULL, distinguishing them from entries +-- the user has actually interacted with. This fixes the "Recently Read" list +-- which sorts by read_changed_at and previously showed all entries. + +-- 1. Drop the NOT NULL constraint and change default to NULL +ALTER TABLE user_entries ALTER COLUMN read_changed_at DROP NOT NULL; +ALTER TABLE user_entries ALTER COLUMN read_changed_at SET DEFAULT NULL; + +-- 2. Set existing rows to NULL where the user never explicitly changed read state. +-- An entry was explicitly interacted with if the user marked it read from the list, +-- marked it unread, or if it's currently read (auto-read-on-open also counts). +UPDATE user_entries +SET read_changed_at = NULL +WHERE has_marked_read_on_list = false + AND has_marked_unread = false + AND read = false; diff --git a/drizzle/schema.sql b/drizzle/schema.sql index 700ec6d2..b715db1d 100644 --- a/drizzle/schema.sql +++ b/drizzle/schema.sql @@ -311,7 +311,7 @@ CREATE TABLE public.user_entries ( read boolean DEFAULT false NOT NULL, starred boolean DEFAULT false NOT NULL, updated_at timestamp with time zone DEFAULT now() NOT NULL, - read_changed_at timestamp with time zone DEFAULT now() NOT NULL, + read_changed_at timestamp with time zone, starred_changed_at timestamp with time zone DEFAULT now() NOT NULL, score smallint, score_changed_at timestamp with time zone, diff --git a/package.json b/package.json index ee75432f..bf5caf75 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,9 @@ "@modelcontextprotocol/sdk": "^1.26.0", "@mozilla/readability": "^0.6.0", "@sentry/nextjs": "^10.39.0", + "@tanstack/db": "^0.5.25", + "@tanstack/query-db-collection": "^1.0.22", + "@tanstack/react-db": "^0.1.69", "@tanstack/react-query": "^5.90.21", "@trpc/client": "^11.10.0", "@trpc/react-query": "^11.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6618ead..a526ef86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,15 @@ importers: '@sentry/nextjs': specifier: ^10.39.0 version: 10.39.0(@opentelemetry/context-async-hooks@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.5.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.5.1(@opentelemetry/api@1.9.0))(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(webpack@5.104.1(esbuild@0.27.3)) + '@tanstack/db': + specifier: ^0.5.25 + version: 0.5.28(typescript@5.9.3) + '@tanstack/query-db-collection': + specifier: ^1.0.22 + version: 1.0.25(@tanstack/query-core@5.90.20)(typescript@5.9.3) + '@tanstack/react-db': + specifier: ^0.1.69 + version: 0.1.72(react@19.2.4)(typescript@5.9.3) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -2807,9 +2816,34 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1' + '@tanstack/db-ivm@0.1.17': + resolution: {integrity: sha512-DK7vm56CDxNuRAdsbiPs+gITJ+16tUtYgZg3BRTLYKGIDsy8sdIO7sQFq5zl7Y+aIKAPmMAbVp9UjJ75FTtwgQ==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/db@0.5.28': + resolution: {integrity: sha512-Uh6CmtNzGW0BW4Tuy61nezzonUMVsVh/o3pXkq6Y2elg+V7lcmvEhW5ZxOAmyNcVgZVKdWJPJ3XfXt0Mnk8UOw==} + peerDependencies: + typescript: '>=4.7' + + '@tanstack/pacer-lite@0.2.1': + resolution: {integrity: sha512-3PouiFjR4B6x1c969/Pl4ZIJleof1M0n6fNX8NRiC9Sqv1g06CVDlEaXUR4212ycGFyfq4q+t8Gi37Xy+z34iQ==} + engines: {node: '>=18'} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + '@tanstack/query-db-collection@1.0.25': + resolution: {integrity: sha512-UqOpgEXds8Q5T4/Ee+2FKv9c47g7OOvTQAPrDTMlGOMyT2+CL+8gbgHVVByBHi+C4ia57vucKq53hIJLFRIhWQ==} + peerDependencies: + '@tanstack/query-core': ^5.0.0 + typescript: '>=4.7' + + '@tanstack/react-db@0.1.72': + resolution: {integrity: sha512-D5tW6tL5/wm58BRqlhGqvouhfFR612DEagBksdDL72m/kmpGrVr/k9Svf/5Vq0BVIEXuHon5LtSx8WAHzeRLNg==} + peerDependencies: + react: '>=16.8.0' + '@tanstack/react-query@5.90.21': resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} peerDependencies: @@ -4269,6 +4303,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fractional-indexing@3.2.0: + resolution: {integrity: sha512-PcOxmqwYCW7O2ovKRU8OoQQj2yqTfEB/yeTYk4gPid6dN5ODRfU1hXd9tTVZzax/0NkO7AxpHykvZnT1aYp/BQ==} + engines: {node: ^14.13.1 || >=16.0.0} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -5766,6 +5804,9 @@ packages: react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + sorted-btree@1.8.1: + resolution: {integrity: sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ==} + source-list-map@2.0.1: resolution: {integrity: sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==} @@ -6178,6 +6219,11 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -9476,8 +9522,38 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.1.18 + '@tanstack/db-ivm@0.1.17(typescript@5.9.3)': + dependencies: + fractional-indexing: 3.2.0 + sorted-btree: 1.8.1 + typescript: 5.9.3 + + '@tanstack/db@0.5.28(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db-ivm': 0.1.17(typescript@5.9.3) + '@tanstack/pacer-lite': 0.2.1 + typescript: 5.9.3 + + '@tanstack/pacer-lite@0.2.1': {} + '@tanstack/query-core@5.90.20': {} + '@tanstack/query-db-collection@1.0.25(@tanstack/query-core@5.90.20)(typescript@5.9.3)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@tanstack/db': 0.5.28(typescript@5.9.3) + '@tanstack/query-core': 5.90.20 + typescript: 5.9.3 + + '@tanstack/react-db@0.1.72(react@19.2.4)(typescript@5.9.3)': + dependencies: + '@tanstack/db': 0.5.28(typescript@5.9.3) + react: 19.2.4 + use-sync-external-store: 1.6.0(react@19.2.4) + transitivePeerDependencies: + - typescript + '@tanstack/react-query@5.90.21(react@19.2.4)': dependencies: '@tanstack/query-core': 5.90.20 @@ -11160,6 +11236,8 @@ snapshots: forwarded@0.2.0: {} + fractional-indexing@3.2.0: {} + fresh@2.0.0: {} fs-extra@9.1.0: @@ -12764,6 +12842,8 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + sorted-btree@1.8.1: {} + source-list-map@2.0.1: {} source-map-js@1.2.1: {} @@ -13197,6 +13277,10 @@ snapshots: dependencies: punycode: 2.3.1 + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + util-deprecate@1.0.2: {} uuid@9.0.1: {} diff --git a/src/FRONTEND_STATE.md b/src/FRONTEND_STATE.md index 28e2e0f6..07f1eb53 100644 --- a/src/FRONTEND_STATE.md +++ b/src/FRONTEND_STATE.md @@ -24,24 +24,11 @@ The frontend uses React Query (via tRPC) for server state management with a hybr Centralized helpers in `src/lib/cache/` ensure consistent updates across the codebase: -### Entry Cache (`entry-cache.ts`) - -| Function | Purpose | -| -------------------------- | ------------------------------------------------------------- | -| `updateEntriesReadStatus` | Updates `entries.get` cache + `entries.list` in-place | -| `updateEntryStarredStatus` | Updates `entries.get` cache + `entries.list` in-place | -| `updateEntryScoreInCache` | Updates score/implicitScore in `entries.get` + `entries.list` | - ### Count Cache (`count-cache.ts`) -| Function | Purpose | -| ------------------------------------- | ------------------------------------------------------ | -| `adjustSubscriptionUnreadCounts` | Directly updates unread counts in `subscriptions.list` | -| `adjustTagUnreadCounts` | Directly updates unread counts in `tags.list` | -| `adjustEntriesCount` | Directly updates `entries.count` cache | -| `addSubscriptionToCache` | Adds new subscription to `subscriptions.list` | -| `removeSubscriptionFromCache` | Removes subscription from `subscriptions.list` | -| `calculateTagDeltasFromSubscriptions` | Calculates tag deltas from subscription deltas | +| Function | Purpose | +| ------------------------------------- | ------------------------------------------------------------ | +| `calculateTagDeltasFromSubscriptions` | Calculates tag deltas from subscription deltas (collections) | ## Queries @@ -109,14 +96,14 @@ Centralized helpers in `src/lib/cache/` ensure consistent updates across the cod ### Entry Mutations -| Mutation | Used In | Cache Updates | -| -------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | -| `entries.markRead` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `subscriptions.list` counts, `tags.list` counts, entry scores. Server returns absolute counts. | -| `entries.markAllRead` | `useEntryMutations` | Invalidate: `entries.list`, `subscriptions.list`, `tags.list`, `entries.count` (bulk operation, direct update not practical) | -| `entries.star` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | -| `entries.unstar` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | -| `entries.setScore` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), entry scores | -| `entries.fetchFullContent` | `EntryContent` | Invalidate: `entries.get({ id })` | +| Mutation | Used In | Cache Updates | +| -------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `entries.markRead` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `subscriptions.list` counts, `tags.list` counts, entry scores. Server returns absolute counts. | +| `entries.markAllRead` | `useEntryMutations` | Invalidate: `entries.list`, `subscriptions.list`, `tags.list`, `entries.count`. Direct: subscription unread counts zeroed in collection (by subscriptionId, tagId, or all). | +| `entries.star` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | +| `entries.unstar` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), `entries.count({ starredOnly: true })`, entry scores. Server returns absolute counts. | +| `entries.setScore` | `useEntryMutations` | Direct: `entries.get`, `entries.list` (in-place), entry scores | +| `entries.fetchFullContent` | `EntryContent` | Invalidate: `entries.get({ id })` | ### Subscription Mutations @@ -165,11 +152,11 @@ Centralized helpers in `src/lib/cache/` ensure consistent updates across the cod The `useRealtimeUpdates` hook manages SSE connections and updates caches. -**Key principle:** Direct cache updates where possible, invalidation only when necessary. `entries.list` is NOT invalidated for new entries - users see new entries when they navigate (sidebar counts update immediately). +**Key principle:** Direct cache updates where possible, invalidation only when necessary. The active view collection is invalidated on new entries, subscription deletes, and navigation so the entry list stays current. | SSE Event | Cache Updates | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -| `new_entry` | Direct: `subscriptions.list` unreadCount, `tags.list` unreadCount, `entries.count`. Does NOT invalidate `entries.list`. | +| `new_entry` | Direct: `subscriptions.list` unreadCount, `tags.list` unreadCount, `entries.count`. Invalidates active view collection so new entries appear. | | `entry_updated` | Direct: `entries.get`, `entries.list` (metadata: title, author, summary, url, publishedAt). No invalidation - avoids race condition when viewing. | | `subscription_created` | Direct: add to `subscriptions.list`, update `tags.list` feedCount/unreadCount, update `entries.count`. | | `subscription_deleted` | Direct: remove from `subscriptions.list`, update `tags.list`, update `entries.count`. Invalidate: `entries.list`. | @@ -309,10 +296,8 @@ Returns the updated entry with score fields: | File | Purpose | | -------------------------------------------------- | ------------------------------------------------- | -| `src/lib/cache/index.ts` | Cache helper exports | | `src/lib/cache/operations.ts` | High-level cache operations (primary API) | -| `src/lib/cache/entry-cache.ts` | Low-level entry cache update helpers | -| `src/lib/cache/count-cache.ts` | Low-level subscription/tag count update helpers | +| `src/lib/cache/count-cache.ts` | Tag delta calculation from subscription deltas | | `src/lib/hooks/useEntryMutations.ts` | Entry mutations with cache updates | | `src/lib/hooks/useRealtimeUpdates.ts` | SSE connection and cache updates | | `src/lib/hooks/useKeyboardShortcuts.ts` | Keyboard navigation and entry selection | diff --git a/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts b/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts index 315a1f65..33bfa269 100644 --- a/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts +++ b/src/app/api/greader.php/reader/api/0/mark-all-as-read/route.ts @@ -55,7 +55,7 @@ export async function POST(request: Request): Promise { const conditions: SQL[] = [ eq(userEntries.userId, userId), eq(userEntries.read, false), - lte(userEntries.readChangedAt, now), + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${now})`, ]; // Add timestamp filter if provided diff --git a/src/app/api/v1/events/route.ts b/src/app/api/v1/events/route.ts index 474cd0e7..1b241323 100644 --- a/src/app/api/v1/events/route.ts +++ b/src/app/api/v1/events/route.ts @@ -379,18 +379,18 @@ export async function GET(req: Request): Promise { const subscriptionId = feedSubscriptionMap.get(event.feedId) ?? null; // Transform event to use subscriptionId instead of feedId - // Include metadata for entry_updated events to enable direct cache updates + // Include type-specific fields for direct cache updates const clientEvent: Record = { type: event.type, subscriptionId, entryId: event.entryId, timestamp: event.timestamp, updatedAt: event.updatedAt, // Database updated_at for cursor tracking - feedType: event.feedType, }; - // Include metadata for entry_updated events - if (event.type === "entry_updated") { + if (event.type === "new_entry") { + clientEvent.feedType = event.feedType; + } else if (event.type === "entry_updated") { clientEvent.metadata = event.metadata; } diff --git a/src/components/entries/EntryContent.tsx b/src/components/entries/EntryContent.tsx index 8f844893..96b13b33 100644 --- a/src/components/entries/EntryContent.tsx +++ b/src/components/entries/EntryContent.tsx @@ -15,6 +15,12 @@ import { trpc } from "@/lib/trpc/client"; import { toast } from "sonner"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useShowOriginalPreference } from "@/lib/hooks/useShowOriginalPreference"; +import { useCollections } from "@/lib/collections/context"; +import { + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryScoreInCollection, +} from "@/lib/collections/writes"; import { ScrollContainer } from "@/components/layout/ScrollContainerContext"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { getDomain } from "@/lib/format"; @@ -80,6 +86,17 @@ function EntryContentInner({ // Entry is guaranteed to exist after suspense resolves const entry = data.entry; + // Sync entries.get data to entries collection. + // This ensures the collection has the latest server state for this entry + // (e.g., if it was marked read by another client between list fetch and detail fetch). + const collections = useCollections(); + useEffect(() => { + if (!entry) return; + updateEntryReadInCollection(collections, [entry.id], entry.read); + updateEntryStarredInCollection(collections, entry.id, entry.starred); + updateEntryScoreInCollection(collections, entry.id, entry.score, entry.implicitScore); + }, [collections, entry]); + // Show original preference is stored per-feed in localStorage const [showOriginal, setShowOriginal] = useShowOriginalPreference(entry?.feedId); diff --git a/src/components/entries/EntryContentFallback.tsx b/src/components/entries/EntryContentFallback.tsx index b2476e9e..bd149b9a 100644 --- a/src/components/entries/EntryContentFallback.tsx +++ b/src/components/entries/EntryContentFallback.tsx @@ -12,8 +12,7 @@ "use client"; import { useCallback } from "react"; -import { useQueryClient } from "@tanstack/react-query"; -import { findEntryInListCache } from "@/lib/cache/entry-cache"; +import { useCollections } from "@/lib/collections/context"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { ScrollContainer } from "@/components/layout/ScrollContainerContext"; import { Button } from "@/components/ui/button"; @@ -48,10 +47,10 @@ function ButtonShimmer({ width = "w-24" }: { width?: string }) { * Star and Read buttons are functional via optimistic updates. */ export function EntryContentFallback({ entryId, onBack }: EntryContentFallbackProps) { - const queryClient = useQueryClient(); + const collections = useCollections(); - // Try to find cached entry data from list queries - const cachedEntry = findEntryInListCache(queryClient, entryId); + // O(1) lookup from entries collection (populated by SuspendingEntryList) + const cachedEntry = collections.entries.get(entryId) ?? null; // Entry mutations for star/read - these work optimistically even during loading const { markRead, star, unstar, setScore } = useEntryMutations(); diff --git a/src/components/entries/EntryListFallback.tsx b/src/components/entries/EntryListFallback.tsx deleted file mode 100644 index fff71fbe..00000000 --- a/src/components/entries/EntryListFallback.tsx +++ /dev/null @@ -1,103 +0,0 @@ -/** - * EntryListFallback Component - * - * Smart Suspense fallback for entry lists. Tries to show cached entries - * from parent lists (e.g., "All" list when viewing a subscription) while - * the actual query loads. Falls back to skeleton if no cached data available. - */ - -"use client"; - -import { useQueryClient } from "@tanstack/react-query"; -import { findParentListPlaceholderData } from "@/lib/cache/entry-cache"; -import { NULL_PREDICTED_SCORE_SENTINEL } from "@/server/services/entries"; -import { EntryListItem } from "./EntryListItem"; -import { EntryListSkeleton } from "./EntryListSkeleton"; -import { EntryListLoadingMore } from "./EntryListStates"; - -/** - * Filter options for finding placeholder data. - */ -interface EntryListFilters { - subscriptionId?: string; - tagId?: string; - uncategorized?: boolean; - unreadOnly?: boolean; - starredOnly?: boolean; - sortOrder?: "newest" | "oldest"; - type?: "web" | "email" | "saved"; -} - -interface EntryListFallbackProps { - /** Filters for finding matching cached data */ - filters: EntryListFilters; - /** Number of skeleton items if no placeholder data */ - skeletonCount?: number; - /** Currently selected entry ID */ - selectedEntryId?: string | null; - /** Callback when entry is clicked (disabled during fallback) */ - onEntryClick?: (entryId: string) => void; - /** Sort fallback data by predicted score (for algorithmic feed) */ - sortByPredictedScore?: boolean; -} - -/** - * Suspense fallback that shows cached entries when available. - * - * Hierarchy for finding placeholder data: - * 1. For subscription pages: try the subscription's tag list first - * 2. Fall back to "All" list (no filters) - * - * If no cached data matches, renders a skeleton. - */ -export function EntryListFallback({ - filters, - skeletonCount = 5, - selectedEntryId, - onEntryClick, - sortByPredictedScore = false, -}: EntryListFallbackProps) { - const queryClient = useQueryClient(); - - // Try to find placeholder data from cached parent lists - // Subscriptions are automatically looked up from cache for tag/uncategorized filtering - const placeholderData = findParentListPlaceholderData(queryClient, filters); - - // No cached data - show skeleton - if (!placeholderData || placeholderData.pages[0]?.items.length === 0) { - return ; - } - - // Show cached entries with a subtle loading indicator - let entries = placeholderData.pages.flatMap((page) => page.items); - - // For algorithmic feed, sort by predicted score while loading. - // Matches backend sort: COALESCE(predicted_score, sentinel) DESC, id DESC - if (sortByPredictedScore) { - entries = [...entries].sort((a, b) => { - const scoreA = a.predictedScore ?? NULL_PREDICTED_SCORE_SENTINEL; - const scoreB = b.predictedScore ?? NULL_PREDICTED_SCORE_SENTINEL; - if (scoreA !== scoreB) return scoreB - scoreA; - return b.id.localeCompare(a.id); - }); - } - - return ( -
- {entries.map((entry) => ( - - ))} - - {/* Show loading indicator at the bottom */} - -
- ); -} diff --git a/src/components/entries/EntryListTitle.tsx b/src/components/entries/EntryListTitle.tsx new file mode 100644 index 00000000..329b73f7 --- /dev/null +++ b/src/components/entries/EntryListTitle.tsx @@ -0,0 +1,75 @@ +/** + * EntryListTitle Component + * + * Reactively reads subscription/tag titles from TanStack DB collections. + * Uses useLiveQuery with findOne() so the title re-renders automatically + * when the data appears in the collection (e.g., after SSR prefetch + * hydrates or a validation query populates it). + * + * Loaded via dynamic() with ssr: false because useLiveQuery uses + * useSyncExternalStore without getServerSnapshot. + */ + +"use client"; + +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { TitleSkeleton, TitleText } from "./EntryPageLayout"; +import { useCollections } from "@/lib/collections/context"; + +interface EntryListTitleProps { + routeInfo: { + title: string | null; + subscriptionId?: string; + tagId?: string; + }; +} + +export function EntryListTitle({ routeInfo }: EntryListTitleProps) { + const { subscriptions, tags } = useCollections(); + + // Reactive lookup — re-renders when the subscription appears in the collection + const { data: subscription } = useLiveQuery( + (q) => + q + .from({ s: subscriptions }) + .where(({ s }) => eq(s.id, routeInfo.subscriptionId ?? "")) + .findOne(), + [routeInfo.subscriptionId] + ); + + // Reactive lookup — re-renders when the tag appears in the collection + const { data: tag } = useLiveQuery( + (q) => + q + .from({ t: tags }) + .where(({ t }) => eq(t.id, routeInfo.tagId ?? "")) + .findOne(), + [routeInfo.tagId] + ); + + // Static title + if (routeInfo.title !== null) { + return {routeInfo.title}; + } + + // Subscription title + if (routeInfo.subscriptionId) { + if (subscription) { + return ( + {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} + ); + } + return ; + } + + // Tag title + if (routeInfo.tagId) { + if (tag) { + return {tag.name}; + } + return ; + } + + return All Items; +} diff --git a/src/components/entries/SuspendingEntryList.tsx b/src/components/entries/SuspendingEntryList.tsx index 92fea2e1..2f40d579 100644 --- a/src/components/entries/SuspendingEntryList.tsx +++ b/src/components/entries/SuspendingEntryList.tsx @@ -1,18 +1,24 @@ /** * SuspendingEntryList Component * - * A wrapper around EntryList that uses useSuspenseInfiniteQuery. - * Suspends until entry data is ready, allowing independent loading - * from other parts of the page (like entry content). + * Renders the entry list using a TanStack DB on-demand collection. + * The collection fetches pages from the server as the user scrolls, + * bridging TanStack DB's offset-based windowing to our cursor-based API. * - * Uses useEntriesListInput to get query input, ensuring cache is shared - * with the parent's non-suspending query (used for navigation). + * Uses useLiveInfiniteQuery for reactive pagination and useStableEntryList + * to prevent entries from disappearing when their state changes mid-session + * (e.g., marking an entry as read while viewing "unread only"). + * + * Note: Despite the name, this component no longer uses React Suspense. + * The name is kept for compatibility with the dynamic import in + * UnifiedEntriesContent. Loading state is handled via isLoading/isReady. */ "use client"; import { useMemo, useCallback, useEffect, useLayoutEffect, useRef } from "react"; -import { trpc } from "@/lib/trpc/client"; +import { useLiveInfiniteQuery } from "@tanstack/react-db"; +import { eq } from "@tanstack/db"; import { useEntryMutations } from "@/lib/hooks/useEntryMutations"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; import { useKeyboardShortcutsContext } from "@/components/keyboard/KeyboardShortcutsProvider"; @@ -20,7 +26,14 @@ import { useKeyboardShortcuts } from "@/lib/hooks/useKeyboardShortcuts"; import { useUrlViewPreferences } from "@/lib/hooks/useUrlViewPreferences"; import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { useScrollContainer } from "@/components/layout/ScrollContainerContext"; +import { useCollections } from "@/lib/collections/context"; +import { upsertEntriesInCollection } from "@/lib/collections/writes"; +import { useViewEntriesCollection } from "@/lib/hooks/useViewEntriesCollection"; +import { useStableEntryList } from "@/lib/hooks/useStableEntryList"; +import { useEntryNavigationUpdater } from "@/lib/hooks/useEntryNavigation"; +import type { EntriesViewFilters } from "@/lib/collections/entries"; import { EntryList, type ExternalQueryState } from "./EntryList"; +import { EntryListSkeleton } from "./EntryListSkeleton"; interface SuspendingEntryListProps { emptyMessage: string; @@ -30,61 +43,105 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) const { openEntryId, setOpenEntryId } = useEntryUrlState(); const { showUnreadOnly, sortOrder, toggleShowUnreadOnly } = useUrlViewPreferences(); const { enabled: keyboardShortcutsEnabled } = useKeyboardShortcutsContext(); - const utils = trpc.useUtils(); const scrollContainerRef = useScrollContainer(); + const collections = useCollections(); - // Get query input from URL - shared with parent's non-suspending query + // Get query input from URL const queryInput = useEntriesListInput(); - // Suspending query - component suspends until data is ready - // Shares cache with parent's useInfiniteQuery via same queryInput - const [data, { fetchNextPage, hasNextPage, isFetchingNextPage, refetch }] = - trpc.entries.list.useSuspenseInfiniteQuery(queryInput, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Infinity, - refetchOnWindowFocus: false, - }); - - // Flatten entries from all pages - const entries = useMemo( - () => - data?.pages.flatMap((page) => - page.items.map((entry) => ({ - id: entry.id, - feedId: entry.feedId, - subscriptionId: entry.subscriptionId, - type: entry.type, - url: entry.url, - title: entry.title, - author: entry.author, - summary: entry.summary, - publishedAt: entry.publishedAt, - fetchedAt: entry.fetchedAt, - read: entry.read, - starred: entry.starred, - feedTitle: entry.feedTitle, - siteName: entry.siteName, - })) - ) ?? [], - [data?.pages] + // Build filters for the on-demand view collection + const viewFilters: EntriesViewFilters = useMemo( + () => ({ + subscriptionId: queryInput.subscriptionId, + tagId: queryInput.tagId, + uncategorized: queryInput.uncategorized, + unreadOnly: showUnreadOnly, + starredOnly: queryInput.starredOnly, + sortOrder, + type: queryInput.type, + limit: queryInput.limit, + }), + [queryInput, showUnreadOnly, sortOrder] + ); + + // Create the on-demand view collection (recreates on filter change) + const { collection: viewCollection, filterKey } = useViewEntriesCollection(viewFilters); + + const sortDescending = sortOrder === "newest"; + + // Live infinite query over the view collection + // Client-side where clauses ensure correct hasNextPage / dataNeeded calculation + const { + data: liveData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isReady, + } = useLiveInfiniteQuery( + (q) => { + let query = q + .from({ e: viewCollection }) + .orderBy(({ e }) => e._sortMs, sortDescending ? "desc" : "asc"); + + // Client-side filter matching server-side filter for correct pagination + if (showUnreadOnly) { + query = query.where(({ e }) => eq(e.read, false)); + } + if (queryInput.starredOnly) { + query = query.where(({ e }) => eq(e.starred, true)); + } + + return query.select(({ e }) => ({ ...e })); + }, + { + pageSize: queryInput.limit, + getNextPageParam: (lastPage, allPages) => + lastPage.length === queryInput.limit ? allPages.length : undefined, + }, + [filterKey, sortDescending, showUnreadOnly, queryInput.starredOnly] + ); + + // Display stability: merge live entries with previously-seen entries + const stableEntries = useStableEntryList( + liveData ?? [], + viewCollection, + filterKey, + sortDescending ); + // Populate global entries collection from live query results. + // This keeps entries available for SSE state updates, fallback lookups, + // and the detail view overlay. + useEffect(() => { + if (stableEntries.length > 0) { + upsertEntriesInCollection(collections, stableEntries); + } + }, [collections, stableEntries]); + // Compute next/previous entry IDs for keyboard navigation // Also compute how close we are to the pagination boundary const { nextEntryId, previousEntryId, distanceToEnd } = useMemo(() => { - if (!openEntryId || entries.length === 0) { + if (!openEntryId || stableEntries.length === 0) { return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; } - const currentIndex = entries.findIndex((e) => e.id === openEntryId); + const currentIndex = stableEntries.findIndex((e) => e.id === openEntryId); if (currentIndex === -1) { return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; } return { - nextEntryId: currentIndex < entries.length - 1 ? entries[currentIndex + 1].id : undefined, - previousEntryId: currentIndex > 0 ? entries[currentIndex - 1].id : undefined, - distanceToEnd: entries.length - 1 - currentIndex, + nextEntryId: + currentIndex < stableEntries.length - 1 ? stableEntries[currentIndex + 1].id : undefined, + previousEntryId: currentIndex > 0 ? stableEntries[currentIndex - 1].id : undefined, + distanceToEnd: stableEntries.length - 1 - currentIndex, }; - }, [openEntryId, entries]); + }, [openEntryId, stableEntries]); + + // Publish navigation state for swipe gestures in EntryContent + const updateNavigation = useEntryNavigationUpdater(); + useEffect(() => { + updateNavigation({ nextEntryId, previousEntryId }); + }, [updateNavigation, nextEntryId, previousEntryId]); // Trigger pagination when navigating close to the end of loaded entries const prevDistanceToEnd = useRef(distanceToEnd); @@ -167,7 +224,7 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) // Keyboard shortcuts const { selectedEntryId } = useKeyboardShortcuts({ - entries, + entries: stableEntries, onOpenEntry: setOpenEntryId, onClose: () => setOpenEntryId(null), isEntryOpen: !!openEntryId, @@ -175,25 +232,27 @@ export function SuspendingEntryList({ emptyMessage }: SuspendingEntryListProps) enabled: keyboardShortcutsEnabled, onToggleRead: handleToggleRead, onToggleStar: toggleStar, - onRefresh: () => utils.entries.list.invalidate(), + onRefresh: () => collections.invalidateActiveView(), onToggleUnreadOnly: toggleShowUnreadOnly, onNavigateNext: goToNextEntry, onNavigatePrevious: goToPreviousEntry, }); + // Show skeleton while first page loads + if (isLoading && !isReady) { + return ; + } + // External query state for EntryList - const externalQueryState: ExternalQueryState = useMemo( - () => ({ - isLoading: false, // Suspense handles loading - isError: false, // ErrorBoundary handles errors - errorMessage: undefined, - isFetchingNextPage, - hasNextPage: hasNextPage ?? false, - fetchNextPage, - refetch, - }), - [isFetchingNextPage, hasNextPage, fetchNextPage, refetch] - ); + const externalQueryState: ExternalQueryState = { + isLoading: isLoading && !isReady, + isError: false, + errorMessage: undefined, + isFetchingNextPage, + hasNextPage, + fetchNextPage, + refetch: () => collections.invalidateActiveView(), + }; return ( diff --git a/src/components/entries/UnifiedEntriesContent.tsx b/src/components/entries/UnifiedEntriesContent.tsx index 03f58410..c390d1a1 100644 --- a/src/components/entries/UnifiedEntriesContent.tsx +++ b/src/components/entries/UnifiedEntriesContent.tsx @@ -14,22 +14,40 @@ "use client"; -import { Suspense, useMemo, useEffect, useRef } from "react"; +import { useEffect, useMemo, useState } from "react"; import { usePathname } from "next/navigation"; -import { useQueryClient } from "@tanstack/react-query"; -import { EntryPageLayout, TitleSkeleton, TitleText } from "./EntryPageLayout"; +import dynamic from "next/dynamic"; +import { EntryPageLayout, TitleSkeleton } from "./EntryPageLayout"; import { EntryContent } from "./EntryContent"; -import { SuspendingEntryList } from "./SuspendingEntryList"; -import { EntryListFallback } from "./EntryListFallback"; +import { EntryListSkeleton } from "./EntryListSkeleton"; + +// SuspendingEntryList uses useLiveInfiniteQuery (TanStack DB) which calls useSyncExternalStore +// without getServerSnapshot, causing SSR to crash. Disable SSR since the on-demand +// collection is client-only state. +const SuspendingEntryList = dynamic( + () => import("./SuspendingEntryList").then((m) => m.SuspendingEntryList), + { ssr: false, loading: () => } +); +// EntryListTitle uses useLiveQuery for reactive collection reads, which also +// uses useSyncExternalStore without getServerSnapshot. Disable SSR. +const EntryListTitle = dynamic(() => import("./EntryListTitle").then((m) => m.EntryListTitle), { + ssr: false, + loading: () => , +}); import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; import { NotFoundCard } from "@/components/ui/not-found-card"; import { useEntryUrlState } from "@/lib/hooks/useEntryUrlState"; import { useUrlViewPreferences } from "@/lib/hooks/useUrlViewPreferences"; -import { useEntriesListInput } from "@/lib/hooks/useEntriesListInput"; import { type ViewType } from "@/lib/hooks/viewPreferences"; import { trpc } from "@/lib/trpc/client"; -import { findCachedSubscription } from "@/lib/cache/count-cache"; -import { type EntryType } from "@/lib/hooks/useEntryMutations"; +import { useCollections } from "@/lib/collections/context"; +import { upsertSubscriptionsInCollection } from "@/lib/collections/writes"; +import { + createEntryNavigationStore, + EntryNavigationProvider, + useEntryNavigationState, +} from "@/lib/hooks/useEntryNavigation"; +import { type EntryType } from "@/lib/queries/entries-list-input"; /** * Route info derived from the current pathname. @@ -222,116 +240,33 @@ function useRouteInfo(): RouteInfo { }, [pathname]); } -/** - * Title component for subscription pages. - * Uses useSuspenseQuery so it suspends until data is available. - */ -function SubscriptionTitle({ subscriptionId }: { subscriptionId: string }) { - const [subscription] = trpc.subscriptions.get.useSuspenseQuery({ id: subscriptionId }); - if (!subscription) { - // This shouldn't happen with suspense, but handle it gracefully - return Untitled Feed; - } - return ( - {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} - ); -} - -/** - * Title component for tag pages. - * Uses useSuspenseQuery so it suspends until data is available. - */ -function TagTitle({ tagId }: { tagId: string }) { - const [tagsData] = trpc.tags.list.useSuspenseQuery(); - const tag = tagsData?.items.find((t) => t.id === tagId); - return {tag?.name ?? "Unknown Tag"}; -} - -/** - * Title component that handles all route types. - * Static titles render immediately; dynamic titles suspend until data loads. - */ -function EntryListTitle({ routeInfo }: { routeInfo: RouteInfo }) { - // Static title - render immediately - if (routeInfo.title !== null) { - return {routeInfo.title}; - } - - // Subscription title - suspends until subscription data loads - if (routeInfo.subscriptionId) { - return ; - } - - // Tag title - suspends until tags data loads - if (routeInfo.tagId) { - return ; - } - - return All Items; -} - -/** - * Smart title fallback that tries to show cached title instead of skeleton. - * Used as the Suspense fallback for the title slot. - */ -function TitleFallback({ routeInfo }: { routeInfo: RouteInfo }) { - const utils = trpc.useUtils(); - const queryClient = useQueryClient(); - - // Static title - render immediately (shouldn't suspend anyway, but handle it) - if (routeInfo.title !== null) { - return {routeInfo.title}; - } - - // Subscription title from cache - if (routeInfo.subscriptionId) { - const subscription = findCachedSubscription(utils, queryClient, routeInfo.subscriptionId); - if (subscription) { - return ( - {subscription.title ?? subscription.originalTitle ?? "Untitled Feed"} - ); - } - return ; - } - - // Tag title from cache - if (routeInfo.tagId) { - const tagsData = utils.tags.list.getData(); - const tag = tagsData?.items.find((t) => t.id === routeInfo.tagId); - if (tag) { - return {tag.name}; - } - return ; - } - - return All Items; -} - /** * Inner content component that renders based on route. - * Entry content and entry list have independent Suspense boundaries. + * Titles read from TanStack DB collections; entry list handles its own loading. */ function UnifiedEntriesContentInner() { const routeInfo = useRouteInfo(); const { showUnreadOnly } = useUrlViewPreferences(); const { openEntryId, setOpenEntryId, closeEntry } = useEntryUrlState(); - // Get query input based on current URL - shared with SuspendingEntryList - const queryInput = useEntriesListInput(); + // Navigation state published by SuspendingEntryList via useEntryNavigationUpdater + const { nextEntryId, previousEntryId } = useEntryNavigationState(); - // Non-suspending query for navigation - shares cache with SuspendingEntryList - const entriesQuery = trpc.entries.list.useInfiniteQuery(queryInput, { - getNextPageParam: (lastPage) => lastPage.nextCursor, - staleTime: Infinity, - refetchOnWindowFocus: false, - }); + const collections = useCollections(); - // Fetch subscription data for validation + // Fetch subscription data for validation and to populate the collection for title display const subscriptionQuery = trpc.subscriptions.get.useQuery( { id: routeInfo.subscriptionId ?? "" }, { enabled: !!routeInfo.subscriptionId } ); + // Upsert subscription into collection so the title renders from the collection + useEffect(() => { + if (subscriptionQuery.data) { + upsertSubscriptionsInCollection(collections, [subscriptionQuery.data]); + } + }, [collections, subscriptionQuery.data]); + // Fetch tag data for validation and empty message customization const tagsQuery = trpc.tags.list.useQuery(undefined, { enabled: !!routeInfo.tagId, @@ -376,42 +311,6 @@ function UnifiedEntriesContentInner() { return options; }, [routeInfo.filters]); - // Get adjacent entry IDs from query data for navigation - // Also compute distance to end for pagination triggering - const pages = entriesQuery.data?.pages; - const { nextEntryId, previousEntryId, distanceToEnd } = useMemo(() => { - if (!openEntryId || !pages) { - return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; - } - const allEntries = pages.flatMap((page) => page.items); - const currentIndex = allEntries.findIndex((e) => e.id === openEntryId); - if (currentIndex === -1) { - return { nextEntryId: undefined, previousEntryId: undefined, distanceToEnd: Infinity }; - } - return { - nextEntryId: - currentIndex < allEntries.length - 1 ? allEntries[currentIndex + 1].id : undefined, - previousEntryId: currentIndex > 0 ? allEntries[currentIndex - 1].id : undefined, - distanceToEnd: allEntries.length - 1 - currentIndex, - }; - }, [openEntryId, pages]); - - // Trigger pagination when navigating close to the end of loaded entries - // This ensures swipe navigation can continue beyond the initial page - const prevDistanceToEnd = useRef(distanceToEnd); - useEffect(() => { - const PAGINATION_THRESHOLD = 3; - if ( - distanceToEnd <= PAGINATION_THRESHOLD && - distanceToEnd < prevDistanceToEnd.current && - entriesQuery.hasNextPage && - !entriesQuery.isFetchingNextPage - ) { - entriesQuery.fetchNextPage(); - } - prevDistanceToEnd.current = distanceToEnd; - }, [distanceToEnd, entriesQuery]); - // Navigation callbacks - just update URL, React re-renders const handleSwipeNext = useMemo(() => { if (!nextEntryId) return undefined; @@ -444,12 +343,8 @@ function UnifiedEntriesContentInner() { ); } - // Title has its own Suspense boundary with smart fallback that uses cache - const titleSlot = ( - }> - - - ); + // Title reactively reads from collections via useLiveQuery + const titleSlot = ; // Entry content - has its own internal Suspense boundary const entryContentSlot = openEntryId ? ( @@ -464,31 +359,14 @@ function UnifiedEntriesContentInner() { /> ) : null; - // Entry list - has its own Suspense boundary + // Entry list - SuspendingEntryList handles its own loading state; + // the dynamic() import's loading prop covers the chunk load. const entryListSlot = ( - + - - + /> ); return ( @@ -511,14 +389,17 @@ function UnifiedEntriesContentInner() { * to determine what to render. When navigation happens via pushState, usePathname() * updates and this component re-renders with the appropriate content. * - * Note: No outer Suspense needed because UnifiedEntriesContentInner uses only - * non-suspending queries. All suspending queries are inside child components - * with their own Suspense boundaries (title, entry list, entry content). + * Provides EntryNavigationProvider so SuspendingEntryList can publish + * next/previous entry IDs for swipe gesture navigation in EntryContent. */ export function UnifiedEntriesContent() { + const [navigationStore] = useState(createEntryNavigationStore); + return ( - + + + ); } diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 5c941169..ca735592 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -12,23 +12,44 @@ "use client"; import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; -import { handleSubscriptionDeleted } from "@/lib/cache/operations"; +import { useCollections } from "@/lib/collections/context"; +import { handleSubscriptionDeleted, refreshGlobalCounts } from "@/lib/cache/operations"; +import dynamic from "next/dynamic"; import { UnsubscribeDialog } from "@/components/feeds/UnsubscribeDialog"; import { EditSubscriptionDialog } from "@/components/feeds/EditSubscriptionDialog"; import { useSidebarUnreadOnly } from "@/lib/hooks/useSidebarUnreadOnly"; -import { SidebarNav } from "./SidebarNav"; import { TagList } from "./TagList"; import { SidebarUnreadToggle } from "./SidebarUnreadToggle"; +// SidebarNav uses useLiveQuery (TanStack DB) which calls useSyncExternalStore +// without getServerSnapshot, causing SSR to crash. Disable SSR for this component +// since the counts collection is client-only state anyway. +const SidebarNav = dynamic(() => import("./SidebarNav").then((m) => m.SidebarNav), { + ssr: false, + loading: () => , +}); + +/** + * Skeleton placeholder matching SidebarNav's layout (3 nav links). + */ +function SidebarNavSkeleton() { + return ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); +} + interface SidebarProps { onClose?: () => void; } export function Sidebar({ onClose }: SidebarProps) { - const queryClient = useQueryClient(); + const collections = useCollections(); const { sidebarUnreadOnly, toggleSidebarUnreadOnly } = useSidebarUnreadOnly(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; @@ -47,25 +68,25 @@ export function Sidebar({ onClose }: SidebarProps) { onMutate: async (variables) => { // Close dialog immediately for responsive feel setUnsubscribeTarget(null); - // Use centralized cache operation for optimistic removal - handleSubscriptionDeleted(utils, variables.id, queryClient); + // Use centralized cache operation for optimistic removal (dual-write to collections) + handleSubscriptionDeleted(utils, variables.id, collections); }, onError: () => { toast.error("Failed to unsubscribe from feed"); - // On error, invalidate to refetch correct state + // On error, invalidate to refetch correct state. + // Subscription infinite queries will re-populate the subscriptions collection. utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + collections.tags.utils.refetch(); + refreshGlobalCounts(utils, collections); }, }); const handleNavigate = () => { - // Mark entry list queries as stale and refetch active ones. + // Invalidate the view collection so the entry list refetches. // This ensures clicking the current page's link refreshes the list, - // while cross-page navigation also works (new query fetches on mount). - queryClient.invalidateQueries({ - queryKey: [["entries", "list"]], - }); + // while cross-page navigation also works (new collection creates on mount). + collections.invalidateActiveView(); onClose?.(); }; @@ -129,8 +150,11 @@ export function Sidebar({ onClose }: SidebarProps) { currentTagIds={editTarget?.tagIds ?? []} onClose={() => { setEditTarget(null); + // Invalidate subscription queries to refetch with new data. + // Subscription infinite queries will re-populate the subscriptions collection. utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); + collections.tags.utils.refetch(); }} /> diff --git a/src/components/layout/SidebarNav.tsx b/src/components/layout/SidebarNav.tsx index 6db6f728..24b6698b 100644 --- a/src/components/layout/SidebarNav.tsx +++ b/src/components/layout/SidebarNav.tsx @@ -1,14 +1,17 @@ /** * SidebarNav Component * - * Navigation section of the sidebar with streaming unread counts. - * Each count suspends independently, allowing the nav structure to render immediately. + * Navigation section of the sidebar with reactive unread counts. + * Counts are read from the TanStack DB counts collection, which is + * seeded from SSR-prefetched data and updated by mutations/SSE events. */ "use client"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { usePathname } from "next/navigation"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useCollections } from "@/lib/collections/context"; import { trpc } from "@/lib/trpc/client"; import { NavLink } from "@/components/ui/nav-link"; import { ErrorBoundary } from "@/components/ui/ErrorBoundary"; @@ -27,32 +30,16 @@ function CountBadge({ count }: { count: number }) { ); } -/** - * Suspending component that fetches and displays a single count. - * Returns null when count is 0 (no badge shown). - */ -function AllItemsCount() { - const [data] = trpc.entries.count.useSuspenseQuery({}); - return ; -} - -function StarredCount() { - const [data] = trpc.entries.count.useSuspenseQuery({ starredOnly: true }); - return ; -} - -function SavedCount() { - const [data] = trpc.entries.count.useSuspenseQuery({ type: "saved" }); - return ; -} - /** * Best feed nav link with count, only visible if user has scored entries * and the algorithmic feed enabled (both checked server-side by hasScoredEntries). - * Shares the same unread count as All Items. + * Shares the same unread count as All Items from the counts collection. */ function BestNavLink({ isActive, onNavigate }: { isActive: boolean; onNavigate: () => void }) { const [hasScoredData] = trpc.entries.hasScoredEntries.useSuspenseQuery(); + const { counts: countsCollection } = useCollections(); + const { data: allCounts } = useLiveQuery(countsCollection); + const allCount = allCounts.find((c) => c.id === "all")?.unread ?? 0; if (!hasScoredData.hasScoredEntries) return null; @@ -60,11 +47,7 @@ function BestNavLink({ isActive, onNavigate }: { isActive: boolean; onNavigate: - - - } + countElement={} onClick={onNavigate} > Best @@ -73,22 +56,20 @@ function BestNavLink({ isActive, onNavigate }: { isActive: boolean; onNavigate: } /** - * Wraps a count component with ErrorBoundary and Suspense. - * Shows nothing during loading or on error (graceful degradation). - */ -function SuspenseCount({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} - -/** - * Main navigation links with independently streaming unread counts. + * Main navigation links with reactive unread counts from the counts collection. */ export function SidebarNav({ onNavigate }: SidebarNavProps) { const pathname = usePathname(); + const { counts: countsCollection } = useCollections(); + const { data: allCounts } = useLiveQuery(countsCollection); + + const countMap = useMemo(() => { + const map = new Map(); + for (const record of allCounts) { + map.set(record.id, record.unread); + } + return map; + }, [allCounts]); const isActiveLink = (href: string) => pathname === href; @@ -97,11 +78,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > All Items @@ -116,11 +93,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > Starred @@ -129,11 +102,7 @@ export function SidebarNav({ onNavigate }: SidebarNavProps) { - - - } + countElement={} onClick={onNavigate} > Saved diff --git a/src/components/layout/TagList.tsx b/src/components/layout/TagList.tsx index 152db480..c84d5178 100644 --- a/src/components/layout/TagList.tsx +++ b/src/components/layout/TagList.tsx @@ -2,15 +2,18 @@ * TagList Component * * Renders the list of tags with unread counts in the sidebar. - * Uses useSuspenseQuery so it can stream with Suspense boundaries. + * Uses TanStack DB live queries for reactive updates from the tags collection. + * Uncategorized counts come from the counts collection (populated by the tags fetch). */ "use client"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { usePathname } from "next/navigation"; +import dynamic from "next/dynamic"; +import { useLiveSuspenseQuery, useLiveQuery } from "@tanstack/react-db"; import { ClientLink } from "@/components/ui/client-link"; -import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { useExpandedTags } from "@/lib/hooks/useExpandedTags"; import { NavLinkWithIcon } from "@/components/ui/nav-link"; import { ChevronDownIcon, ChevronRightIcon } from "@/components/ui/icon-button"; @@ -32,15 +35,23 @@ interface TagListProps { } /** - * Inner component that suspends on tags.list query. + * Inner component that suspends on tags collection. */ function TagListContent({ onNavigate, onEdit, onUnsubscribe, unreadOnly }: TagListProps) { const pathname = usePathname(); - const [tagsData] = trpc.tags.list.useSuspenseQuery(); + const { tags: tagsCollection, counts: countsCollection } = useCollections(); + const { data: tags } = useLiveSuspenseQuery(tagsCollection); + const { data: allCounts } = useLiveQuery(countsCollection); const { isExpanded, toggleExpanded } = useExpandedTags(); - const tags = tagsData.items; - const uncategorized = tagsData.uncategorized; + // Get uncategorized counts from the counts collection + const uncategorized = useMemo(() => { + const record = allCounts.find((c) => c.id === "uncategorized"); + return { + feedCount: record?.total ?? 0, + unreadCount: record?.unread ?? 0, + }; + }, [allCounts]); // Determine which tag/uncategorized is currently active so we always show it const activeTagId = pathname.startsWith("/tag/") ? pathname.slice("/tag/".length) : null; @@ -167,7 +178,7 @@ function TagListContent({ onNavigate, onEdit, onUnsubscribe, unreadOnly }: TagLi isActive={isActiveLink("/uncategorized")} icon={} label="Uncategorized" - count={uncategorized?.unreadCount ?? 0} + count={uncategorized.unreadCount} onClick={onNavigate} />
@@ -209,6 +220,14 @@ function TagListError() { return

Failed to load feeds

; } +// TagListContent uses useLiveQuery/useLiveSuspenseQuery (TanStack DB) which calls +// useSyncExternalStore without getServerSnapshot, causing SSR to crash. +// Disable SSR since the tags/counts collections are client-only state. +const DynamicTagListContent = dynamic(() => Promise.resolve({ default: TagListContent }), { + ssr: false, + loading: () => , +}); + /** * TagList with built-in Suspense and ErrorBoundary. */ @@ -216,7 +235,7 @@ export function TagList(props: TagListProps) { return ( }> }> - + ); diff --git a/src/components/layout/TagSubscriptionList.tsx b/src/components/layout/TagSubscriptionList.tsx index bdb25e8d..962c1bb4 100644 --- a/src/components/layout/TagSubscriptionList.tsx +++ b/src/components/layout/TagSubscriptionList.tsx @@ -1,15 +1,21 @@ /** * TagSubscriptionList Component * - * Renders subscriptions within a tag section using infinite scrolling. - * Subscriptions are fetched per-tag (or uncategorized) when the section is expanded, - * with more pages loaded automatically as the user scrolls. + * Renders subscriptions within a tag section using a TanStack DB on-demand collection. + * The collection fetches pages from the server as the user scrolls in the sidebar. + * + * Loaded subscriptions are also written into the global subscriptions collection + * for fast lookups and optimistic updates elsewhere in the app. */ "use client"; -import { useEffect, useRef } from "react"; -import { trpc } from "@/lib/trpc/client"; +import { useEffect, useMemo, useRef } from "react"; +import { useLiveInfiniteQuery } from "@tanstack/react-db"; +import { useCollections } from "@/lib/collections/context"; +import { upsertSubscriptionsInCollection } from "@/lib/collections/writes"; +import { useTagSubscriptionsCollection } from "@/lib/hooks/useTagSubscriptionsCollection"; +import type { TagSubscriptionFilters } from "@/lib/collections/subscriptions"; import { SubscriptionItem } from "./SubscriptionItem"; interface TagSubscriptionListProps { @@ -34,6 +40,8 @@ interface TagSubscriptionListProps { unreadOnly: boolean; } +const PAGE_SIZE = 50; + export function TagSubscriptionList({ tagId, uncategorized, @@ -44,17 +52,56 @@ export function TagSubscriptionList({ unreadOnly, }: TagSubscriptionListProps) { const sentinelRef = useRef(null); + const collections = useCollections(); - const subscriptionsQuery = trpc.subscriptions.list.useInfiniteQuery( - { tagId, uncategorized, unreadOnly: unreadOnly || undefined, limit: 50 }, - { - getNextPageParam: (lastPage) => lastPage.nextCursor, - } + // Build filters for the on-demand collection + const filters: TagSubscriptionFilters = useMemo( + () => ({ tagId, uncategorized, unreadOnly: unreadOnly || undefined, limit: PAGE_SIZE }), + [tagId, uncategorized, unreadOnly] ); - const allSubscriptions = subscriptionsQuery.data?.pages.flatMap((p) => p.items) ?? []; + // Create the on-demand collection (recreates on filter change) + const { collection: tagCollection, filterKey } = useTagSubscriptionsCollection(filters); + + // Register the tag subscription collection so write functions can propagate + // unread count changes to it (in addition to the global subscriptions collection) + useEffect(() => { + collections.tagSubscriptionCollections.set(filterKey, tagCollection); + return () => { + collections.tagSubscriptionCollections.delete(filterKey); + }; + }, [collections, filterKey, tagCollection]); - const { hasNextPage, isFetchingNextPage, fetchNextPage } = subscriptionsQuery; + // Live infinite query over the tag subscription collection + // Server sorts alphabetically by title, so we use ID as the orderBy + // (UUIDv7 gives us stable ordering; the server determines the actual sort) + const { + data: subscriptions, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isReady, + } = useLiveInfiniteQuery( + (q) => + q + .from({ s: tagCollection }) + .orderBy(({ s }) => s.id, "asc") + .select(({ s }) => ({ ...s })), + { + pageSize: PAGE_SIZE, + getNextPageParam: (lastPage, allPages) => + lastPage.length === PAGE_SIZE ? allPages.length : undefined, + }, + [filterKey] + ); + + // Populate global subscriptions collection from live query results + useEffect(() => { + if (subscriptions && subscriptions.length > 0) { + upsertSubscriptionsInCollection(collections, subscriptions); + } + }, [collections, subscriptions]); // Infinite scroll: observe sentinel element to load more useEffect(() => { @@ -76,7 +123,7 @@ export function TagSubscriptionList({ return () => observer.disconnect(); }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - if (subscriptionsQuery.isLoading) { + if (isLoading && !isReady) { return (
    {[1, 2].map((i) => ( @@ -88,13 +135,13 @@ export function TagSubscriptionList({ ); } - if (allSubscriptions.length === 0) { + if (!subscriptions || subscriptions.length === 0) { return null; } return (
      - {allSubscriptions.map((sub) => ( + {subscriptions.map((sub) => ( ))} {/* Sentinel for infinite scroll */} - {subscriptionsQuery.hasNextPage && ( -
    ); } diff --git a/src/components/saved/FileUploadButton.tsx b/src/components/saved/FileUploadButton.tsx index 2c6f3af2..34099505 100644 --- a/src/components/saved/FileUploadButton.tsx +++ b/src/components/saved/FileUploadButton.tsx @@ -10,6 +10,8 @@ import { useState, useRef, useCallback, type ChangeEvent, type DragEvent } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { adjustEntriesCountInCollection } from "@/lib/collections/writes"; import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { UploadIcon, DocumentIcon } from "@/components/ui/icon-button"; @@ -66,13 +68,14 @@ export function FileUploadButton({ className = "", onSuccess }: FileUploadButton const [error, setError] = useState(null); const fileInputRef = useRef(null); - const utils = trpc.useUtils(); + const collections = useCollections(); const uploadMutation = trpc.saved.uploadFile.useMutation({ onSuccess: () => { toast.success("File uploaded successfully"); - // Invalidate queries to refresh the saved list and count - utils.entries.list.invalidate({ type: "saved" }); - utils.entries.count.invalidate({ type: "saved" }); + // Invalidate active entry view and update counts in collection + collections.invalidateActiveView(); + adjustEntriesCountInCollection(collections, "saved", 1, 1); + adjustEntriesCountInCollection(collections, "all", 1, 1); onSuccess?.(); handleClose(); }, diff --git a/src/components/settings/OpmlImportExport.tsx b/src/components/settings/OpmlImportExport.tsx index 934d48ba..caeb5da3 100644 --- a/src/components/settings/OpmlImportExport.tsx +++ b/src/components/settings/OpmlImportExport.tsx @@ -14,6 +14,8 @@ import { useState, useRef, useCallback, useEffect } from "react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { refreshGlobalCounts } from "@/lib/cache/operations"; import { Button } from "@/components/ui/button"; import { Alert } from "@/components/ui/alert"; import { UploadIcon, DownloadIcon, SpinnerIcon } from "@/components/ui/icon-button"; @@ -87,6 +89,7 @@ function ImportSection() { const fileInputRef = useRef(null); const utils = trpc.useUtils(); + const collections = useCollections(); // Query to poll for import status when importing const importQuery = trpc.imports.get.useQuery( @@ -122,7 +125,7 @@ function ImportSection() { return baseState; })(); - // Invalidate subscriptions when import completes + // Invalidate subscriptions and refresh counts when import completes const prevCompleted = useRef(false); useEffect(() => { const isCompleted = importState.type === "complete" && baseState.type === "importing"; @@ -130,7 +133,7 @@ function ImportSection() { prevCompleted.current = true; utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + refreshGlobalCounts(utils, collections); } else if (baseState.type !== "importing") { prevCompleted.current = false; } @@ -139,7 +142,8 @@ function ImportSection() { baseState.type, utils.subscriptions.list, utils.tags.list, - utils.entries.count, + utils, + collections, ]); const importMutation = trpc.subscriptions.import.useMutation({ diff --git a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx index e4bfd659..565acde7 100644 --- a/src/components/settings/pages/BrokenFeedsSettingsContent.tsx +++ b/src/components/settings/pages/BrokenFeedsSettingsContent.tsx @@ -8,10 +8,10 @@ "use client"; import { useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; -import { handleSubscriptionDeleted } from "@/lib/cache/operations"; +import { useCollections } from "@/lib/collections/context"; +import { handleSubscriptionDeleted, refreshGlobalCounts } from "@/lib/cache/operations"; import { getFeedDisplayName, formatRelativeTime, formatFutureTime } from "@/lib/format"; import { Button } from "@/components/ui/button"; import { SettingsListContainer } from "@/components/settings/SettingsListContainer"; @@ -38,7 +38,7 @@ interface BrokenFeed { // ============================================================================ export default function BrokenFeedsSettingsContent() { - const queryClient = useQueryClient(); + const collections = useCollections(); const [unsubscribeTarget, setUnsubscribeTarget] = useState<{ id: string; title: string; @@ -50,7 +50,7 @@ export default function BrokenFeedsSettingsContent() { const unsubscribeMutation = trpc.subscriptions.delete.useMutation({ onMutate: (variables) => { // Use centralized cache operation for optimistic removal - handleSubscriptionDeleted(utils, variables.id, queryClient); + handleSubscriptionDeleted(utils, variables.id, collections); }, onSuccess: () => { utils.brokenFeeds.list.invalidate(); @@ -62,7 +62,7 @@ export default function BrokenFeedsSettingsContent() { // On error, invalidate to refetch correct state utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + refreshGlobalCounts(utils, collections); }, }); diff --git a/src/components/subscribe/SubscribeContent.tsx b/src/components/subscribe/SubscribeContent.tsx index 2b2b3823..c6c51902 100644 --- a/src/components/subscribe/SubscribeContent.tsx +++ b/src/components/subscribe/SubscribeContent.tsx @@ -9,9 +9,9 @@ "use client"; import { useState, useCallback } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSubscriptionCreated } from "@/lib/cache/operations"; import { clientPush } from "@/lib/navigation"; import { Button } from "@/components/ui/button"; @@ -36,7 +36,7 @@ type Step = "input" | "discovery" | "preview"; // ============================================================================ export function SubscribeContent() { - const queryClient = useQueryClient(); + const collections = useCollections(); const [url, setUrl] = useState(""); const [urlError, setUrlError] = useState(); const [step, setStep] = useState("input"); @@ -68,7 +68,7 @@ export function SubscribeContent() { const subscribeMutation = trpc.subscriptions.create.useMutation({ onSuccess: (data) => { // Use centralized cache operation for consistent behavior with SSE events - handleSubscriptionCreated(utils, data, queryClient); + handleSubscriptionCreated(utils, data, collections); clientPush("/all"); }, onError: () => { diff --git a/src/lib/cache/count-cache.ts b/src/lib/cache/count-cache.ts index 9330c813..b1968d16 100644 --- a/src/lib/cache/count-cache.ts +++ b/src/lib/cache/count-cache.ts @@ -1,321 +1,11 @@ /** * Count Cache Helpers * - * Functions for updating unread counts on subscriptions and tags. - * These work with the subscriptions.list and tags.list caches. + * Utility functions for computing tag deltas from subscription deltas. + * Uses TanStack DB collections for subscription lookups. */ -import type { QueryClient } from "@tanstack/react-query"; -import type { TRPCClientUtils } from "@/lib/trpc/client"; - -/** - * Full subscription type as returned by subscriptions.list/get. - * Inferred from the tRPC client utils to stay in sync with the router schema. - */ -export type CachedSubscription = NonNullable< - ReturnType ->["items"][number]; - -/** - * Page structure in subscription infinite query cache. - */ -interface CachedSubscriptionPage { - items: CachedSubscription[]; - nextCursor?: string; -} - -/** - * Regular query data structure for subscriptions. - */ -interface SubscriptionListData { - items: CachedSubscription[]; - nextCursor?: string; -} - -/** - * Infinite query data structure for subscriptions. - */ -interface SubscriptionInfiniteData { - pages: CachedSubscriptionPage[]; - pageParams: unknown[]; -} - -/** - * Applies subscription unread count deltas to a list of subscriptions. - */ -function applySubscriptionDeltas( - items: T[], - subscriptionDeltas: Map -): T[] { - return items.map((sub) => { - const delta = subscriptionDeltas.get(sub.id); - if (delta !== undefined) { - return { - ...sub, - unreadCount: Math.max(0, sub.unreadCount + delta), - }; - } - return sub; - }); -} - -/** - * Collects all cached subscriptions from both the unparameterized query - * and all per-tag infinite queries into a single map. - * - * Subscriptions may only exist in one cache or the other depending on - * which views have been loaded (entry view vs sidebar tag views). - */ -function getAllCachedSubscriptions( - utils: TRPCClientUtils, - queryClient?: QueryClient -): Map { - const subscriptionMap = new Map(); - - // Check the unparameterized query (used by UnifiedEntriesContent/EntryContent) - const subscriptionsData = utils.subscriptions.list.getData(); - if (subscriptionsData) { - for (const s of subscriptionsData.items) { - subscriptionMap.set(s.id, s); - } - } - - // Check per-tag infinite queries (used by TagSubscriptionList in sidebar) - if (queryClient) { - forEachCachedSubscription(queryClient, (s) => { - if (!subscriptionMap.has(s.id)) { - subscriptionMap.set(s.id, s); - } - }); - } - - return subscriptionMap; -} - -/** - * Iterates over all subscriptions in cached subscription list queries. - * Handles both regular queries and infinite queries (used by sidebar). - */ -function forEachCachedSubscription( - queryClient: QueryClient, - callback: (subscription: CachedSubscription) => void -): void { - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - for (const [, data] of queries) { - if (!data) continue; - - // Check if it's infinite query format (has pages array) - if ("pages" in data && Array.isArray(data.pages)) { - for (const page of data.pages) { - if (page?.items) { - for (const s of page.items) { - callback(s); - } - } - } - } - // Regular query format (has items directly) - else if ("items" in data && Array.isArray(data.items)) { - for (const s of data.items) { - callback(s); - } - } - } -} - -/** - * Finds a single subscription by ID across all subscription caches. - * - * Checks both the unparameterized subscriptions.list query (populated by entry pages) - * and per-tag infinite queries (populated by sidebar tag sections). - * - * Useful for providing placeholder data when navigating to a subscription page, - * since the subscription may be cached from the sidebar but not from the entry page query. - */ -export function findCachedSubscription( - utils: TRPCClientUtils, - queryClient: QueryClient, - subscriptionId: string -): CachedSubscription | undefined { - // Check the unparameterized query first (cheaper lookup) - const listData = utils.subscriptions.list.getData(); - if (listData) { - const found = listData.items.find((s) => s.id === subscriptionId); - if (found) return found; - } - - // Check per-tag infinite queries - let found: CachedSubscription | undefined; - forEachCachedSubscription(queryClient, (s) => { - if (!found && s.id === subscriptionId) { - found = s; - } - }); - return found; -} - -/** - * Adjusts unread counts for subscriptions in the cache. - * Updates both the unparameterized query (used by UnifiedEntriesContent/EntryContent) - * and all per-tag infinite queries (used by TagSubscriptionList in sidebar). - * - * @param utils - tRPC utils for cache access - * @param subscriptionDeltas - Map of subscriptionId -> count change (+1 for unread, -1 for read) - * @param queryClient - React Query client for updating infinite query caches - */ -export function adjustSubscriptionUnreadCounts( - utils: TRPCClientUtils, - subscriptionDeltas: Map, - queryClient?: QueryClient -): void { - if (subscriptionDeltas.size === 0) return; - - // Update the unparameterized query (used by UnifiedEntriesContent/EntryContent) - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: applySubscriptionDeltas(oldData.items, subscriptionDeltas), - }; - }); - - // Update all per-tag infinite queries (used by TagSubscriptionList in sidebar) - // These have query keys like [["subscriptions", "list"], { input: {...}, type: "infinite" }] - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - for (const [queryKey, data] of infiniteQueries) { - // Only update infinite queries (they have pages array) - if (!data?.pages) continue; - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: applySubscriptionDeltas(page.items, subscriptionDeltas), - })), - }); - } - } -} - -/** - * Adjusts unread counts for tags in the cache, including the uncategorized section. - * - * @param utils - tRPC utils for cache access - * @param tagDeltas - Map of tagId -> count change (+1 for unread, -1 for read) - * @param uncategorizedDelta - Count change for uncategorized subscriptions - */ -export function adjustTagUnreadCounts( - utils: TRPCClientUtils, - tagDeltas: Map, - uncategorizedDelta: number = 0 -): void { - if (tagDeltas.size === 0 && uncategorizedDelta === 0) return; - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: oldData.items.map((tag) => { - const delta = tagDeltas.get(tag.id); - if (delta !== undefined) { - return { - ...tag, - unreadCount: Math.max(0, tag.unreadCount + delta), - }; - } - return tag; - }), - uncategorized: - uncategorizedDelta !== 0 - ? { - ...oldData.uncategorized, - unreadCount: Math.max(0, oldData.uncategorized.unreadCount + uncategorizedDelta), - } - : oldData.uncategorized, - }; - }); -} - -/** - * Adjusts the entries.count cache values. - * - * @param utils - tRPC utils for cache access - * @param filters - The filter params to target (e.g., { starredOnly: true }) - * @param unreadDelta - Change to unread count - * @param totalDelta - Change to total count (default: 0) - */ -export function adjustEntriesCount( - utils: TRPCClientUtils, - filters: Parameters[0], - unreadDelta: number, - totalDelta: number = 0 -): void { - utils.entries.count.setData(filters, (oldData) => { - if (!oldData) return oldData; - return { - total: Math.max(0, oldData.total + totalDelta), - unread: Math.max(0, oldData.unread + unreadDelta), - }; - }); -} - -/** - * Adds a new subscription to the subscriptions.list cache. - * Used when subscription_created SSE event arrives. - * - * @param utils - tRPC utils for cache access - * @param subscription - The new subscription to add - */ -export function addSubscriptionToCache( - utils: TRPCClientUtils, - subscription: CachedSubscription & { - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - originalTitle: string | null; - description: string | null; - siteUrl: string | null; - subscribedAt: Date; - fetchFullContent: boolean; - } -): void { - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - // Check for duplicates (SSE race condition) - if (oldData.items.some((s) => s.id === subscription.id)) { - return oldData; - } - - return { - ...oldData, - items: [...oldData.items, subscription], - }; - }); -} - -/** - * Removes a subscription from the subscriptions.list cache. - * Used for optimistic updates when unsubscribing. - * - * @param utils - tRPC utils for cache access - * @param subscriptionId - ID of the subscription to remove - */ -export function removeSubscriptionFromCache(utils: TRPCClientUtils, subscriptionId: string): void { - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.filter((s) => s.id !== subscriptionId), - }; - }); -} +import type { Collections } from "@/lib/collections"; /** * Result of calculating tag deltas from subscription deltas. @@ -327,28 +17,24 @@ export interface TagDeltaResult { /** * Calculates tag deltas from subscription deltas. - * Uses the cached subscription data to look up which tags each subscription has. - * Also calculates the delta for uncategorized subscriptions (those with no tags). + * Looks up subscription data from the TanStack DB collection to determine + * which tags each subscription belongs to. * - * @param utils - tRPC utils for cache access * @param subscriptionDeltas - Map of subscriptionId -> count change - * @param queryClient - React Query client for searching infinite query caches + * @param collections - TanStack DB collections for subscription lookups * @returns Tag deltas and uncategorized delta */ export function calculateTagDeltasFromSubscriptions( - utils: TRPCClientUtils, subscriptionDeltas: Map, - queryClient?: QueryClient + collections: Collections | null ): TagDeltaResult { const tagDeltas = new Map(); let uncategorizedDelta = 0; - const subscriptionMap = getAllCachedSubscriptions(utils, queryClient); - if (subscriptionMap.size === 0) return { tagDeltas, uncategorizedDelta }; + if (!collections) return { tagDeltas, uncategorizedDelta }; - // Calculate tag deltas and uncategorized delta for (const [subscriptionId, delta] of subscriptionDeltas) { - const subscription = subscriptionMap.get(subscriptionId); + const subscription = collections.subscriptions.get(subscriptionId); if (subscription) { if (subscription.tags.length === 0) { uncategorizedDelta += delta; @@ -363,97 +49,3 @@ export function calculateTagDeltasFromSubscriptions( return { tagDeltas, uncategorizedDelta }; } - -// ============================================================================ -// Tag Cache Direct Updates (for sync) -// ============================================================================ - -/** - * Tag data for sync operations. - */ -export interface SyncTag { - id: string; - name: string; - color: string | null; -} - -/** - * Updates or adds tags in the tags.list cache based on sync data. - * Does not invalidate - directly applies changes. - * - * @param utils - tRPC utils for cache access - * @param createdTags - Tags that are newly created - * @param updatedTags - Tags that have been updated - */ -export function applySyncTagChanges( - utils: TRPCClientUtils, - createdTags: SyncTag[], - updatedTags: SyncTag[] -): void { - if (createdTags.length === 0 && updatedTags.length === 0) return; - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - // Create a map for efficient lookups - const updatedTagsMap = new Map(updatedTags.map((t) => [t.id, t])); - - // Update existing tags - let items = oldData.items.map((tag) => { - const update = updatedTagsMap.get(tag.id); - if (update) { - return { - ...tag, - name: update.name, - color: update.color, - }; - } - return tag; - }); - - // Add newly created tags (with default counts) - for (const newTag of createdTags) { - // Check for duplicates (race condition) - if (!items.some((t) => t.id === newTag.id)) { - items.push({ - id: newTag.id, - name: newTag.name, - color: newTag.color, - feedCount: 0, - unreadCount: 0, - createdAt: new Date(), // Approximate, doesn't need to be exact - }); - } - } - - // Sort by name (matching server behavior) - items = items.sort((a, b) => a.name.localeCompare(b.name)); - - return { - ...oldData, - items, - }; - }); -} - -/** - * Removes tags from the tags.list cache based on sync data. - * Does not invalidate - directly removes from cache. - * - * @param utils - tRPC utils for cache access - * @param removedTagIds - IDs of tags to remove - */ -export function removeSyncTags(utils: TRPCClientUtils, removedTagIds: string[]): void { - if (removedTagIds.length === 0) return; - - const removedSet = new Set(removedTagIds); - - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - return { - ...oldData, - items: oldData.items.filter((tag) => !removedSet.has(tag.id)), - }; - }); -} diff --git a/src/lib/cache/entry-cache.ts b/src/lib/cache/entry-cache.ts deleted file mode 100644 index 84d1710b..00000000 --- a/src/lib/cache/entry-cache.ts +++ /dev/null @@ -1,808 +0,0 @@ -/** - * Entry Cache Helpers - * - * Functions for updating entry state in React Query cache. - * - * Strategy: - * - For individual entry views (entries.get): update directly - * - For entry lists (entries.list): update in place without invalidation - * (entries stay visible until navigation; SuspendingEntryList refetches on pathname change) - * - For counts (subscriptions, tags): update directly via count-cache helpers - */ - -import type { QueryClient } from "@tanstack/react-query"; -import type { TRPCClientUtils } from "@/lib/trpc/client"; - -/** - * Entry data in list cache. - */ -interface CachedListEntry { - id: string; - read: boolean; - starred: boolean; - [key: string]: unknown; -} - -/** - * Page structure in infinite query cache. - */ -interface CachedPage { - items: CachedListEntry[]; - nextCursor?: string; -} - -/** - * Infinite query data structure. - */ -interface InfiniteData { - pages: CachedPage[]; - pageParams: unknown[]; -} - -/** - * Updates entries in all cached entry lists (infinite queries). - * Uses QueryClient.setQueriesData to update all cached queries regardless of filters. - * - * @param queryClient - React Query client for cache access - * @param entryIds - Entry IDs to update - * @param updates - Fields to update (read, starred) - */ -export function updateEntriesInListCache( - queryClient: QueryClient, - entryIds: string[], - updates: Partial<{ read: boolean; starred: boolean; score: number | null; implicitScore: number }> -): void { - const entryIdSet = new Set(entryIds); - - // Update all cached entry list queries (matches any query starting with ['entries', 'list']) - queryClient.setQueriesData({ queryKey: [["entries", "list"]] }, (oldData) => { - if (!oldData?.pages) return oldData; - - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entryIdSet.has(entry.id)) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }; - }); -} - -/** - * Entry context for targeted cache updates. - */ -export interface EntryContext { - id: string; - subscriptionId: string | null; - type: "web" | "email" | "saved"; - starred: boolean; -} - -/** - * Affected scope info for targeted cache updates. - */ -export interface AffectedScope { - tagIds: Set; - hasUncategorized: boolean; -} - -/** - * Updates entries in only the affected cached entry lists. - * Uses the entry context and affected scope to skip unrelated caches. - * - * @param queryClient - React Query client for cache access - * @param entries - Entries with their context - * @param updates - Fields to update (read, starred) - * @param scope - Affected tags and uncategorized flag from server response - */ -export function updateEntriesInAffectedListCaches( - queryClient: QueryClient, - entries: EntryContext[], - updates: Partial<{ read: boolean }>, - scope: AffectedScope -): void { - if (entries.length === 0) return; - - const entryIdSet = new Set(entries.map((e) => e.id)); - const subscriptionIds = new Set(entries.map((e) => e.subscriptionId).filter(Boolean) as string[]); - const entryTypes = new Set(entries.map((e) => e.type)); - const hasStarred = entries.some((e) => e.starred); - - // Get all cached entry list queries - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - // Extract filters from query key - const keyMeta = queryKey[1] as TRPCQueryKey | undefined; - const filters: EntryListFilters = keyMeta?.input ?? {}; - - // Check if this cache could contain any of the affected entries - if (!shouldUpdateEntryListCache(filters, subscriptionIds, entryTypes, hasStarred, scope)) { - continue; - } - - // Update entries in this cache - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entryIdSet.has(entry.id)) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }); - } -} - -/** - * Determines if an entry list cache should be updated based on its filters - * and the affected entries' context. - */ -function shouldUpdateEntryListCache( - filters: EntryListFilters, - subscriptionIds: Set, - entryTypes: Set, - hasStarred: boolean, - scope: AffectedScope -): boolean { - // No filters = All entries view, always update - const hasNoFilters = - !filters.subscriptionId && - !filters.tagId && - !filters.uncategorized && - !filters.starredOnly && - !filters.type; - if (hasNoFilters) return true; - - // Subscription filter: only update if an affected entry is in this subscription - if (filters.subscriptionId) { - if (!subscriptionIds.has(filters.subscriptionId)) return false; - } - - // Tag filter: only update if this tag was affected - if (filters.tagId) { - if (!scope.tagIds.has(filters.tagId)) return false; - } - - // Uncategorized filter: only update if uncategorized entries were affected - if (filters.uncategorized) { - if (!scope.hasUncategorized) return false; - } - - // Starred filter: only update if any affected entry is starred - if (filters.starredOnly) { - if (!hasStarred) return false; - } - - // Type filter: only update if an affected entry has this type - if (filters.type) { - if (!entryTypes.has(filters.type)) return false; - } - - return true; -} - -/** - * Updates read status for entries in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Does NOT invalidate/refetch - entries stay visible until navigation. - * - * Note: Call adjustSubscriptionUnreadCounts and adjustTagUnreadCounts separately - * for count updates - those update directly without invalidation. - * - * @param utils - tRPC utils for cache access - * @param entryIds - Entry IDs to update - * @param read - New read status - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntriesReadStatus( - utils: TRPCClientUtils, - entryIds: string[], - read: boolean, - queryClient?: QueryClient -): void { - // Update individual entries.get caches - these are keyed by entry ID - for (const entryId of entryIds) { - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, read }, - }; - }); - } - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, entryIds, { read }); - } -} - -/** - * Updates starred status for an entry in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Does NOT invalidate/refetch - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param starred - New starred status - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryStarredStatus( - utils: TRPCClientUtils, - entryId: string, - starred: boolean, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, starred }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, [entryId], { starred }); - } -} - -/** - * Entry metadata that can be updated from SSE events. - */ -export interface EntryMetadataUpdate { - title?: string | null; - author?: string | null; - summary?: string | null; - url?: string | null; - publishedAt?: Date | null; -} - -/** - * Updates entry metadata in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * Used when entry content changes (e.g., feed refetch, saved article refresh). - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param metadata - New metadata values - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryMetadataInCache( - utils: TRPCClientUtils, - entryId: string, - metadata: EntryMetadataUpdate, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { - ...oldData.entry, - ...(metadata.title !== undefined && { title: metadata.title }), - ...(metadata.author !== undefined && { author: metadata.author }), - ...(metadata.summary !== undefined && { summary: metadata.summary }), - ...(metadata.url !== undefined && { url: metadata.url }), - ...(metadata.publishedAt !== undefined && { publishedAt: metadata.publishedAt }), - }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - const updates: Partial = {}; - if (metadata.title !== undefined) updates.title = metadata.title; - if (metadata.author !== undefined) updates.author = metadata.author; - if (metadata.summary !== undefined) updates.summary = metadata.summary; - if (metadata.url !== undefined) updates.url = metadata.url; - if (metadata.publishedAt !== undefined) updates.publishedAt = metadata.publishedAt; - - if (Object.keys(updates).length > 0) { - queryClient.setQueriesData({ queryKey: [["entries", "list"]] }, (oldData) => { - if (!oldData?.pages) return oldData; - - return { - ...oldData, - pages: oldData.pages.map((page) => ({ - ...page, - items: page.items.map((entry) => { - if (entry.id === entryId) { - return { ...entry, ...updates }; - } - return entry; - }), - })), - }; - }); - } - } -} - -/** - * Updates score fields for an entry in caches. - * Updates both entries.get (single entry) and entries.list (all lists) caches. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID to update - * @param score - New explicit score (null to clear) - * @param implicitScore - New implicit score - * @param queryClient - React Query client (optional, needed for list cache updates) - */ -export function updateEntryScoreInCache( - utils: TRPCClientUtils, - entryId: string, - score: number | null, - implicitScore: number, - queryClient?: QueryClient -): void { - // Update entries.get cache - utils.entries.get.setData({ id: entryId }, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - entry: { ...oldData.entry, score, implicitScore }, - }; - }); - - // Update entries in all cached list queries (if queryClient provided) - if (queryClient) { - updateEntriesInListCache(queryClient, [entryId], { score, implicitScore }); - } -} - -/** - * Entry data from the list (lightweight, no content). - */ -export interface EntryListItem { - id: string; - feedId: string; - subscriptionId: string | null; - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - author: string | null; - summary: string | null; - publishedAt: Date | null; - fetchedAt: Date; - updatedAt: Date; - read: boolean; - starred: boolean; - feedTitle: string | null; - score: number | null; - implicitScore: number; -} - -/** - * Finds an entry in the cached entry lists by ID. - * Searches through all cached infinite query pages. - * - * @param queryClient - React Query client for cache access - * @param entryId - Entry ID to find - * @returns The entry if found, undefined otherwise - */ -export function findEntryInListCache( - queryClient: QueryClient, - entryId: string -): EntryListItem | undefined { - // Get all cached entry list queries - const queries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - for (const [, data] of queries) { - if (!data?.pages) continue; - for (const page of data.pages) { - const entry = page.items.find((e) => e.id === entryId); - if (entry) { - // The cache contains full EntryListItem data, but TypeScript only sees CachedListEntry - return entry as unknown as EntryListItem; - } - } - } - - return undefined; -} - -/** - * Filter options for entry list queries. - */ -export interface EntryListFilters { - subscriptionId?: string; - tagId?: string; - uncategorized?: boolean; - unreadOnly?: boolean; - starredOnly?: boolean; - sortOrder?: "newest" | "oldest"; - type?: "web" | "email" | "saved"; -} - -/** - * Subscription info needed for tag filtering. - */ -interface SubscriptionInfo { - id: string; - tags: Array<{ id: string }>; -} - -/** - * Data format for regular queries. - */ -interface SubscriptionListData { - items: SubscriptionInfo[]; - nextCursor?: string; -} - -/** - * Data format for infinite queries. - */ -interface SubscriptionInfiniteData { - pages: SubscriptionListData[]; - pageParams: unknown[]; -} - -/** - * Looks up subscriptions from the cache for tag/uncategorized filtering. - * Handles both regular queries and infinite queries (used by sidebar). - * Returns undefined if not cached. - */ -function getSubscriptionsFromCache(queryClient: QueryClient): SubscriptionInfo[] | undefined { - // Look up subscriptions.list cache - handles both query and infinite query formats - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - const allSubscriptions: SubscriptionInfo[] = []; - const seenIds = new Set(); - - for (const [, data] of queries) { - if (!data) continue; - - // Check if it's infinite query format (has pages array) - if ("pages" in data && Array.isArray(data.pages)) { - for (const page of data.pages) { - if (page?.items) { - for (const sub of page.items) { - if (!seenIds.has(sub.id)) { - seenIds.add(sub.id); - allSubscriptions.push(sub); - } - } - } - } - } - // Regular query format (has items directly) - else if ("items" in data && Array.isArray(data.items)) { - for (const sub of data.items) { - if (!seenIds.has(sub.id)) { - seenIds.add(sub.id); - allSubscriptions.push(sub); - } - } - } - } - - return allSubscriptions.length > 0 ? allSubscriptions : undefined; -} - -/** - * Query key structure for tRPC infinite queries. - * The key is [["entries", "list"], { input: {...}, type: "infinite" }] - */ -interface TRPCQueryKey { - input?: EntryListFilters & { limit?: number; cursor?: string }; - type?: string; -} - -/** - * Checks if a parent query's filters are compatible for use as placeholder data. - * A parent is compatible if it's a superset of the requested filters. - * - * @param parentFilters - Filters from the parent query - * @param requestedFilters - Filters being requested - * @returns true if parent can provide placeholder data for the request - */ -function areFiltersCompatible( - parentFilters: EntryListFilters, - requestedFilters: EntryListFilters -): boolean { - // Sort order must match (we can't reorder entries client-side) - const parentSort = parentFilters.sortOrder ?? "newest"; - const requestedSort = requestedFilters.sortOrder ?? "newest"; - if (parentSort !== requestedSort) return false; - - // If parent has starredOnly=true, we can only use it for starred requests - if (parentFilters.starredOnly && !requestedFilters.starredOnly) return false; - - // If parent has unreadOnly=true, we can only use it for unread requests - if (parentFilters.unreadOnly && !requestedFilters.unreadOnly) return false; - - // If parent has a type filter, it must match (or be omitted) - if (parentFilters.type && parentFilters.type !== requestedFilters.type) return false; - - // If parent has subscriptionId, it must match - if ( - parentFilters.subscriptionId && - parentFilters.subscriptionId !== requestedFilters.subscriptionId - ) - return false; - - // If parent has tagId, it must match - if (parentFilters.tagId && parentFilters.tagId !== requestedFilters.tagId) return false; - - // If parent has uncategorized, it must match - if (parentFilters.uncategorized && !requestedFilters.uncategorized) return false; - - return true; -} - -/** - * Filters entries from a parent list to match the requested filters. - * - * @param entries - Entries from the parent list - * @param filters - Requested filters to apply - * @param subscriptions - Subscription data for tag filtering - * @returns Filtered entries - */ -function filterEntries( - entries: CachedListEntry[], - filters: EntryListFilters, - subscriptions?: SubscriptionInfo[] -): CachedListEntry[] { - let result = entries; - - // Filter by subscriptionId - if (filters.subscriptionId) { - result = result.filter((e) => e.subscriptionId === filters.subscriptionId); - } - - // Filter by tagId (need subscriptions data to know which subscriptions have this tag) - if (filters.tagId && subscriptions) { - const subscriptionIdsInTag = new Set( - subscriptions - .filter((sub) => sub.tags.some((tag) => tag.id === filters.tagId)) - .map((sub) => sub.id) - ); - result = result.filter( - (e) => e.subscriptionId && subscriptionIdsInTag.has(e.subscriptionId as string) - ); - } - - // Filter by uncategorized (subscriptions with no tags) - if (filters.uncategorized && subscriptions) { - const uncategorizedSubscriptionIds = new Set( - subscriptions.filter((sub) => sub.tags.length === 0).map((sub) => sub.id) - ); - result = result.filter( - (e) => e.subscriptionId && uncategorizedSubscriptionIds.has(e.subscriptionId as string) - ); - } - - // Filter by starredOnly - if (filters.starredOnly) { - result = result.filter((e) => e.starred); - } - - // Filter by unreadOnly - if (filters.unreadOnly) { - result = result.filter((e) => !e.read); - } - - // Filter by type - if (filters.type) { - result = result.filter((e) => e.type === filters.type); - } - - return result; -} - -/** - * Entry list item structure for placeholder data. - * Matches the schema returned by entries.list tRPC procedure. - */ -interface EntryListItemForPlaceholder { - id: string; - subscriptionId: string | null; - feedId: string; - type: "web" | "email" | "saved"; - url: string | null; - title: string | null; - author: string | null; - summary: string | null; - publishedAt: Date | null; - fetchedAt: Date; - updatedAt: Date; - read: boolean; - starred: boolean; - feedTitle: string | null; - siteName: string | null; - score: number | null; - implicitScore: number; - predictedScore: number | null; -} - -/** - * Page structure for typed placeholder data. - */ -interface TypedPage { - items: EntryListItemForPlaceholder[]; - nextCursor?: string; -} - -/** - * Typed infinite data for placeholder. - */ -interface TypedInfiniteData { - pages: TypedPage[]; - pageParams: (string | undefined)[]; -} - -/** - * Finds a cached query matching specific filters. - */ -function findCachedQuery( - queries: [readonly unknown[], InfiniteData | undefined][], - matchFilters: (parentFilters: EntryListFilters) => boolean -): InfiniteData | undefined { - for (const [queryKey, data] of queries) { - if (!data?.pages?.length) continue; - - const keyMeta = queryKey[1] as TRPCQueryKey | undefined; - const parentFilters: EntryListFilters = keyMeta?.input ?? {}; - - if (matchFilters(parentFilters)) { - return data; - } - } - return undefined; -} - -/** - * Finds a cached query with preference for exact unreadOnly/starredOnly match. - * First tries exact match, then falls back to any compatible query. - */ -function findCachedQueryWithPreference( - queries: [readonly unknown[], InfiniteData | undefined][], - baseMatch: (pf: EntryListFilters) => boolean, - filters: EntryListFilters -): InfiniteData | undefined { - // First try exact match (same unreadOnly/starredOnly) - let result = findCachedQuery( - queries, - (pf) => - baseMatch(pf) && - !!pf.unreadOnly === !!filters.unreadOnly && - !!pf.starredOnly === !!filters.starredOnly && - areFiltersCompatible(pf, filters) - ); - - // Fall back to any compatible match - if (!result) { - result = findCachedQuery(queries, (pf) => baseMatch(pf) && areFiltersCompatible(pf, filters)); - } - - return result; -} - -/** - * Checks if two filter sets are exactly equal (for self-cache lookup). - */ -function filtersEqual(a: EntryListFilters, b: EntryListFilters): boolean { - return ( - a.subscriptionId === b.subscriptionId && - a.tagId === b.tagId && - !!a.uncategorized === !!b.uncategorized && - a.type === b.type && - !!a.unreadOnly === !!b.unreadOnly && - !!a.starredOnly === !!b.starredOnly && - (a.sortOrder ?? "newest") === (b.sortOrder ?? "newest") - ); -} - -/** - * Finds placeholder data from a parent list cache that can be used while the actual query loads. - * Walks up the hierarchy looking for cached data: - * - * 1. Self-cache: exact same query already cached (e.g., "All" returning to "All") - * 2. For subscriptions: tag list (if subscription is in a tag) → "All" list - * 3. For tags/starred/other: "All" list - * - * Within each level, prefers exact unreadOnly/starredOnly match, then falls back to compatible. - * - * For tag/uncategorized filtering, automatically looks up subscriptions from the cache. - * - * @param queryClient - React Query client for cache access - * @param filters - Requested filters for the entry list - * @returns Placeholder data in infinite query format, or undefined if no suitable parent found - */ -export function findParentListPlaceholderData( - queryClient: QueryClient, - filters: EntryListFilters -): TypedInfiniteData | undefined { - // Look up subscriptions from cache for tag/uncategorized filtering - // Note: subscriptionId filtering doesn't need the subscriptions cache - it filters directly by entry.subscriptionId - const needsSubscriptions = filters.tagId || filters.uncategorized; - const subscriptions = needsSubscriptions ? getSubscriptionsFromCache(queryClient) : undefined; - - // If we need subscriptions for filtering but can't find them, show skeleton - // rather than showing incorrect/unfiltered data - if (needsSubscriptions && !subscriptions) { - return undefined; - } - const queries = queryClient.getQueriesData({ - queryKey: [["entries", "list"]], - }); - - // 1. Check for exact match first (self-cache) - // This handles "All" using its own cache when navigating back, etc. - const exactMatch = findCachedQuery(queries, (pf) => filtersEqual(pf, filters)); - if (exactMatch) { - // Return cached data directly, no filtering needed - return { - pages: exactMatch.pages.map((p) => ({ - items: p.items as unknown as EntryListItemForPlaceholder[], - nextCursor: p.nextCursor, - })), - pageParams: exactMatch.pageParams as (string | undefined)[], - }; - } - - let parentData: InfiniteData | undefined; - - // 2. For subscription pages, try the subscription's tag list - if (filters.subscriptionId && subscriptions) { - const subscription = subscriptions.find((s) => s.id === filters.subscriptionId); - const tagIds = subscription?.tags.map((t) => t.id) ?? []; - - for (const tagId of tagIds) { - parentData = findCachedQueryWithPreference( - queries, - (pf) => pf.tagId === tagId && !pf.subscriptionId, - filters - ); - if (parentData) break; - } - } - - // 3. Fall back to "All" list (no subscription/tag/uncategorized filters) - if (!parentData) { - parentData = findCachedQueryWithPreference( - queries, - (pf) => !pf.subscriptionId && !pf.tagId && !pf.uncategorized, - filters - ); - } - - if (!parentData) return undefined; - - // Filter the parent's entries to match the requested filters - const allEntries = parentData.pages.flatMap((page) => page.items); - const filteredEntries = filterEntries(allEntries, filters, subscriptions); - - // No matching entries found - if (filteredEntries.length === 0) return undefined; - - // Return in infinite query format (single page, no cursor for placeholder) - // Cast is safe because the cache contains the full entry data - return { - pages: [ - { items: filteredEntries as unknown as EntryListItemForPlaceholder[], nextCursor: undefined }, - ], - pageParams: [undefined], - }; -} diff --git a/src/lib/cache/event-handlers.ts b/src/lib/cache/event-handlers.ts index 9e626a18..42ac645e 100644 --- a/src/lib/cache/event-handlers.ts +++ b/src/lib/cache/event-handlers.ts @@ -4,13 +4,27 @@ * Provides unified event handling for both SSE and sync endpoints. * Both SSE real-time events and sync polling use the same event types, * so we can share the cache update logic between them. + * + * State updates flow through TanStack DB collections for sidebar/list views. + * React Query entries.get is still updated for the detail view. */ -import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; +import type { Collections } from "@/lib/collections"; import { handleSubscriptionCreated, handleSubscriptionDeleted, handleNewEntry } from "./operations"; -import { updateEntriesInListCache, updateEntryMetadataInCache } from "./entry-cache"; -import { applySyncTagChanges, removeSyncTags } from "./count-cache"; +import { + addTagToCollection, + updateTagInCollection, + removeTagFromCollection, + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryMetadataInCollection, + adjustSubscriptionUnreadInCollection, + adjustTagUnreadInCollection, + adjustUncategorizedUnreadInCollection, + adjustEntriesCountInCollection, +} from "@/lib/collections/writes"; +import { calculateTagDeltasFromSubscriptions } from "./count-cache"; // Re-export SyncEvent type from the shared schema (single source of truth) import type { SyncEvent } from "@/lib/events/schemas"; @@ -21,51 +35,123 @@ export type { SyncEvent } from "@/lib/events/schemas"; // ============================================================================ /** - * Handles a sync event by updating the appropriate caches. + * Handles a sync event by updating the appropriate caches and collections. * * This is the unified event handler used by both SSE and sync endpoints. - * It dispatches to the appropriate cache update functions based on event type. + * It dispatches to the appropriate update functions based on event type. * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache updates + * @param utils - tRPC utils for entries.get cache and invalidation * @param event - The event to handle + * @param collections - TanStack DB collections for state updates */ export function handleSyncEvent( utils: TRPCClientUtils, - queryClient: QueryClient, - event: SyncEvent + event: SyncEvent, + collections?: Collections | null ): void { switch (event.type) { case "new_entry": - // Update unread counts without invalidating entries.list - if (event.feedType) { - handleNewEntry(utils, event.subscriptionId, event.feedType, queryClient); - } + // Update unread counts in collections + handleNewEntry(utils, event.subscriptionId, event.feedType, collections); + // Invalidate view collection so the new entry appears in the list + collections?.invalidateActiveView(); break; - case "entry_updated": - // Update entry metadata directly in caches - updateEntryMetadataInCache( - utils, - event.entryId, - { - title: event.metadata.title, - author: event.metadata.author, - summary: event.metadata.summary, - url: event.metadata.url, - publishedAt: event.metadata.publishedAt ? new Date(event.metadata.publishedAt) : null, - }, - queryClient - ); + case "entry_updated": { + // Update entry metadata in entries.get cache (detail view) + const metadata = { + title: event.metadata.title, + author: event.metadata.author, + summary: event.metadata.summary, + url: event.metadata.url, + publishedAt: event.metadata.publishedAt ? new Date(event.metadata.publishedAt) : null, + }; + utils.entries.get.setData({ id: event.entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + ...(metadata.title !== undefined && { title: metadata.title }), + ...(metadata.author !== undefined && { author: metadata.author }), + ...(metadata.summary !== undefined && { summary: metadata.summary }), + ...(metadata.url !== undefined && { url: metadata.url }), + ...(metadata.publishedAt !== undefined && { publishedAt: metadata.publishedAt }), + }, + }; + }); + // Update entries collection (list view, via reactive useLiveQuery) + updateEntryMetadataInCollection(collections ?? null, event.entryId, metadata); break; + } - case "entry_state_changed": - // Update read/starred state in cache - updateEntriesInListCache(queryClient, [event.entryId], { - read: event.read, - starred: event.starred, + case "entry_state_changed": { + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: event.entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { ...oldData.entry, read: event.read, starred: event.starred }, + }; }); + // Update entries collection (list view, via reactive useLiveQuery) + updateEntryReadInCollection(collections ?? null, [event.entryId], event.read); + updateEntryStarredInCollection(collections ?? null, event.entryId, event.starred); + + // Update counts based on state deltas (only when previous state is available, + // i.e. from SSE events; sync polling events don't include previous state) + const readChanged = event.previousRead !== undefined && event.read !== event.previousRead; + const starredChanged = + event.previousStarred !== undefined && event.starred !== event.previousStarred; + + if (readChanged) { + // delta is -1 when marking read (fewer unread), +1 when marking unread + const unreadDelta = event.read ? -1 : 1; + + // Update global "all" unread count + adjustEntriesCountInCollection(collections ?? null, "all", 0, unreadDelta); + + // Update per-subscription and tag/uncategorized unread counts. + // + // TRADEOFF (see #623): Subscriptions are loaded on-demand when users + // expand tag sections in the sidebar. If the subscription isn't in + // the collection yet, calculateTagDeltasFromSubscriptions will return + // zero deltas, so tag/uncategorized/per-subscription counts won't be + // adjusted here. This is acceptable per Goal 4 ("Everything works even + // if we don't have per-subscription data"): + // - Global "all" count (above) is always correct. + // - Tag/uncategorized counts may drift temporarily. + // - When tag sections are expanded, the tag subscriptions collection + // refetches from the server (staleTime is finite), which corrects + // the counts. + if (event.subscriptionId) { + const subscriptionDeltas = new Map(); + subscriptionDeltas.set(event.subscriptionId, unreadDelta); + adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); + + const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( + subscriptionDeltas, + collections ?? null + ); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); + } + } + + if (starredChanged) { + // Adjust starred total and unread counts + const starredTotalDelta = event.starred ? 1 : -1; + // If the entry is unread, it also affects the starred unread count + const starredUnreadDelta = !event.read ? starredTotalDelta : 0; + adjustEntriesCountInCollection( + collections ?? null, + "starred", + starredTotalDelta, + starredUnreadDelta + ); + } break; + } case "subscription_created": { const { subscription, feed } = event; @@ -81,10 +167,11 @@ export function handleSyncEvent( siteUrl: feed.siteUrl, subscribedAt: new Date(subscription.subscribedAt), unreadCount: subscription.unreadCount, + totalCount: subscription.totalCount, tags: subscription.tags, fetchFullContent: false, }, - queryClient + collections ); break; } @@ -92,26 +179,24 @@ export function handleSyncEvent( case "subscription_deleted": // Check if already removed (optimistic update from same tab) { - const currentData = utils.subscriptions.list.getData(); - const alreadyRemoved = - currentData && !currentData.items.some((s) => s.id === event.subscriptionId); + const alreadyRemoved = collections && !collections.subscriptions.has(event.subscriptionId); if (!alreadyRemoved) { - handleSubscriptionDeleted(utils, event.subscriptionId, queryClient); + handleSubscriptionDeleted(utils, event.subscriptionId, collections); } } break; case "tag_created": - applySyncTagChanges(utils, [event.tag], []); + addTagToCollection(collections ?? null, event.tag); break; case "tag_updated": - applySyncTagChanges(utils, [], [event.tag]); + updateTagInCollection(collections ?? null, event.tag); break; case "tag_deleted": - removeSyncTags(utils, [event.tagId]); + removeTagFromCollection(collections ?? null, event.tagId); break; case "import_progress": diff --git a/src/lib/cache/operations.ts b/src/lib/cache/operations.ts index a93e01f0..21d08f98 100644 --- a/src/lib/cache/operations.ts +++ b/src/lib/cache/operations.ts @@ -1,47 +1,28 @@ /** * Cache Operations * - * Higher-level functions for cache updates that handle all the interactions - * between different caches. These are the primary API for mutations and SSE - * handlers - they don't need to know which low-level caches to update. - * - * Operations look up entry state from cache to handle interactions correctly: - * - Starring an unread entry affects the starred unread count - * - Marking a starred entry read affects the starred unread count + * Higher-level functions for subscription lifecycle and count updates. + * All state updates flow through TanStack DB collections. */ -import type { QueryClient } from "@tanstack/react-query"; import type { TRPCClientUtils } from "@/lib/trpc/client"; +import type { Collections } from "@/lib/collections"; +import { calculateTagDeltasFromSubscriptions } from "./count-cache"; import { - updateEntriesReadStatus, - updateEntryStarredStatus, - updateEntryScoreInCache, -} from "./entry-cache"; -import { - adjustSubscriptionUnreadCounts, - adjustTagUnreadCounts, - adjustEntriesCount, - calculateTagDeltasFromSubscriptions, - addSubscriptionToCache, - removeSubscriptionFromCache, - findCachedSubscription, -} from "./count-cache"; - -/** - * Entry type (matches feed type schema). - */ -export type EntryType = "web" | "email" | "saved"; - -/** - * Entry with context, as returned by markRead mutation. - * Includes all state needed for cache updates. - */ -export interface EntryWithContext { - id: string; - subscriptionId: string | null; - starred: boolean; - type: EntryType; -} + adjustSubscriptionUnreadInCollection, + adjustTagUnreadInCollection, + adjustUncategorizedUnreadInCollection, + addSubscriptionToCollection, + removeSubscriptionFromCollection, + setSubscriptionUnreadInCollection, + setTagUnreadInCollection, + setBulkSubscriptionUnreadInCollection, + adjustTagFeedCountInCollection, + adjustUncategorizedFeedCountInCollection, + setEntriesCountInCollection, + adjustEntriesCountInCollection, + setUncategorizedUnreadInCollection, +} from "@/lib/collections/writes"; /** * Subscription data for adding to cache. @@ -56,488 +37,129 @@ export interface SubscriptionData { siteUrl: string | null; subscribedAt: Date; unreadCount: number; + totalCount: number; tags: Array<{ id: string; name: string; color: string | null }>; fetchFullContent: boolean; } -// ============================================================================ -// Private Helpers (shared logic to avoid duplication) -// ============================================================================ - -/** - * Updates subscription and tag unread counts based on deltas. - * Shared logic used by multiple operations to ensure consistent behavior. - * - * @param utils - tRPC utils for cache access - * @param subscriptionDeltas - Map of subscriptionId -> unread count delta - * @param queryClient - React Query client for updating infinite query caches - */ -function updateSubscriptionAndTagCounts( - utils: TRPCClientUtils, - subscriptionDeltas: Map, - queryClient?: QueryClient -): void { - adjustSubscriptionUnreadCounts(utils, subscriptionDeltas, queryClient); - const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( - utils, - subscriptionDeltas, - queryClient - ); - adjustTagUnreadCounts(utils, tagDeltas, uncategorizedDelta); -} - -/** - * Query key input type for subscriptions.list. - */ -interface SubscriptionListInput { - tagId?: string; - uncategorized?: boolean; - query?: string; - unreadOnly?: boolean; - cursor?: string; - limit?: number; -} - -/** - * Invalidates subscription list queries for specific tags. - * More targeted than invalidating all subscription lists. - * - * @param queryClient - React Query client - * @param tagIds - Tag IDs to invalidate queries for - * @param includeUncategorized - Whether to also invalidate the uncategorized query - */ -function invalidateSubscriptionListsForTags( - queryClient: QueryClient, - tagIds: string[], - includeUncategorized: boolean -): void { - const tagIdSet = new Set(tagIds); - - // Get all subscription list queries - const queries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey] of queries) { - // Query key structure: [["subscriptions", "list"], { input: {...}, type: "query"|"infinite" }] - const keyData = queryKey[1] as { input?: SubscriptionListInput; type?: string } | undefined; - const input = keyData?.input; - - // Always invalidate the unparameterized query (used by entry content pages) - if (!input) { - queryClient.invalidateQueries({ queryKey }); - continue; - } - - // Invalidate if this query is for one of the affected tags - if (input.tagId && tagIdSet.has(input.tagId)) { - queryClient.invalidateQueries({ queryKey }); - continue; - } - - // Invalidate uncategorized query if needed - if (includeUncategorized && input.uncategorized === true) { - queryClient.invalidateQueries({ queryKey }); - } - } -} - -/** - * Page structure in subscription infinite query cache. - */ -interface CachedSubscriptionPage { - items: Array<{ id: string; [key: string]: unknown }>; - nextCursor?: string; -} - -/** - * Infinite query data structure for subscriptions. - */ -interface SubscriptionInfiniteData { - pages: CachedSubscriptionPage[]; - pageParams: unknown[]; -} - -/** - * Removes a subscription from all infinite query caches. - * Used when unsubscribing to immediately remove from sidebar lists. - * - * @param queryClient - React Query client - * @param subscriptionId - ID of the subscription to remove - */ -function removeSubscriptionFromInfiniteQueries( - queryClient: QueryClient, - subscriptionId: string -): void { - const infiniteQueries = queryClient.getQueriesData({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - // Only update infinite queries (they have pages array) - if (!data?.pages) continue; - - // Check if any page contains this subscription - const hasSubscription = data.pages.some((page) => - page.items.some((s) => s.id === subscriptionId) - ); - - if (hasSubscription) { - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.filter((s) => s.id !== subscriptionId), - })), - }); - } - } -} - -/** - * Handles entries being marked as read or unread. - * - * Updates: - * - entries.get cache for each entry - * - entries.list cache (updates in place, no refetch) - * - subscriptions.list unread counts - * - tags.list unread counts - * - entries.count({ starredOnly: true }) for starred entries - * - entries.count({ type: "saved" }) for saved entries - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * SuspendingEntryList refetches on pathname change, so lists update on next navigation. - * - * @param utils - tRPC utils for cache access - * @param entries - Entries with their context (subscriptionId, starred, type) - * @param read - New read status - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntriesMarkedRead( - utils: TRPCClientUtils, - entries: EntryWithContext[], - read: boolean, - queryClient?: QueryClient -): void { - if (entries.length === 0) return; - - // 1. Update entry read status in entries.get cache and entries.list cache - updateEntriesReadStatus( - utils, - entries.map((e) => e.id), - read, - queryClient - ); - - // 2. Calculate subscription deltas - // Marking read: -1, marking unread: +1 - const delta = read ? -1 : 1; - const subscriptionDeltas = new Map(); - - for (const entry of entries) { - if (entry.subscriptionId) { - const current = subscriptionDeltas.get(entry.subscriptionId) ?? 0; - subscriptionDeltas.set(entry.subscriptionId, current + delta); - } - } - - // 3. Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient); - - // 4. Update All Articles unread count - adjustEntriesCount(utils, {}, delta * entries.length); - - // 5. Update starred unread count - only for entries that are starred - const starredCount = entries.filter((e) => e.starred).length; - if (starredCount > 0) { - adjustEntriesCount(utils, { starredOnly: true }, delta * starredCount); - } - - // 6. Update saved unread count - only for saved entries - const savedCount = entries.filter((e) => e.type === "saved").length; - if (savedCount > 0) { - adjustEntriesCount(utils, { type: "saved" }, delta * savedCount); - } -} - -/** - * Handles an entry being starred. - * - * Updates: - * - entries.get cache - * - entries.list cache (updates in place, no refetch) - * - entries.count({ starredOnly: true }) - total +1, unread +1 if entry is unread - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID being starred - * @param read - Whether the entry is read (from server response) - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryStarred( - utils: TRPCClientUtils, - entryId: string, - read: boolean, - queryClient?: QueryClient -): void { - // 1. Update entry starred status - updateEntryStarredStatus(utils, entryId, true, queryClient); - - // 2. Update starred count - // Total always +1, unread +1 only if entry is unread - adjustEntriesCount(utils, { starredOnly: true }, read ? 0 : 1, 1); -} - -/** - * Handles an entry being unstarred. - * - * Updates: - * - entries.get cache - * - entries.list cache (updates in place, no refetch) - * - entries.count({ starredOnly: true }) - total -1, unread -1 if entry is unread - * - * Note: Does NOT invalidate entries.list - entries stay visible until navigation. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID being unstarred - * @param read - Whether the entry is read (from server response) - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryUnstarred( - utils: TRPCClientUtils, - entryId: string, - read: boolean, - queryClient?: QueryClient -): void { - // 1. Update entry starred status - updateEntryStarredStatus(utils, entryId, false, queryClient); - - // 2. Update starred count - // Total always -1, unread -1 only if entry was unread - adjustEntriesCount(utils, { starredOnly: true }, read ? 0 : -1, -1); -} - -/** - * Handles an entry's score being changed. - * - * Updates: - * - entries.get cache (score, implicitScore) - * - entries.list cache (score, implicitScore) - * - * Score changes don't affect unread counts, subscription counts, or tag counts. - * - * @param utils - tRPC utils for cache access - * @param entryId - Entry ID whose score changed - * @param score - New explicit score (null if cleared) - * @param implicitScore - New implicit score - * @param queryClient - React Query client (optional, for list cache updates) - */ -export function handleEntryScoreChanged( - utils: TRPCClientUtils, - entryId: string, - score: number | null, - implicitScore: number, - queryClient?: QueryClient -): void { - updateEntryScoreInCache(utils, entryId, score, implicitScore, queryClient); -} - /** * Handles a new subscription being created. * - * Updates: - * - subscriptions.list (add subscription to unparameterized cache) - * - subscriptions.list per-tag infinite queries (only affected tags, or uncategorized if no tags) - * - tags.list (direct update of feedCount and unreadCount) - * - entries.count (direct update) - * - * @param utils - tRPC utils for cache access - * @param subscription - The new subscription data - * @param queryClient - React Query client for targeted invalidations + * Updates TanStack DB collections: + * - subscriptions collection (add subscription) + * - tags/uncategorized counts (feedCount + unreadCount) + * - entries counts ("all") */ export function handleSubscriptionCreated( utils: TRPCClientUtils, subscription: SubscriptionData, - queryClient?: QueryClient + collections?: Collections | null ): void { - addSubscriptionToCache(utils, subscription); + // Add to TanStack DB subscriptions collection + addSubscriptionToCollection(collections ?? null, subscription); - // Invalidate only the affected subscription list queries - // - The unparameterized query (no input) for entry content pages - // - Per-tag queries for tags the subscription belongs to - // - Uncategorized query if subscription has no tags - if (queryClient) { - invalidateSubscriptionListsForTags( - queryClient, - subscription.tags.map((t) => t.id), - subscription.tags.length === 0 - ); + // Update tag/uncategorized feedCount and unreadCount in collections + if (subscription.tags.length === 0) { + adjustUncategorizedFeedCountInCollection(collections ?? null, 1); + adjustUncategorizedUnreadInCollection(collections ?? null, subscription.unreadCount); } else { - // Fallback: invalidate all subscription lists - utils.subscriptions.list.invalidate(); - } - - // Directly update tags.list with feedCount and unreadCount changes - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - if (subscription.tags.length === 0) { - // Uncategorized subscription - return { - ...oldData, - uncategorized: { - feedCount: oldData.uncategorized.feedCount + 1, - unreadCount: oldData.uncategorized.unreadCount + subscription.unreadCount, - }, - }; + for (const tag of subscription.tags) { + adjustTagFeedCountInCollection(collections ?? null, tag.id, 1); + const tagDeltas = new Map([[tag.id, subscription.unreadCount]]); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); } + } - // Update feedCount and unreadCount for each tag the subscription belongs to - const tagIds = new Set(subscription.tags.map((t) => t.id)); - return { - ...oldData, - items: oldData.items.map((tag) => { - if (tagIds.has(tag.id)) { - return { - ...tag, - feedCount: tag.feedCount + 1, - unreadCount: tag.unreadCount + subscription.unreadCount, - }; - } - return tag; - }), - }; - }); - - // Directly update entries.count for All Articles - adjustEntriesCount(utils, {}, subscription.unreadCount, subscription.unreadCount); + // Update entry counts + adjustEntriesCountInCollection( + collections ?? null, + "all", + subscription.totalCount, + subscription.unreadCount + ); } /** * Handles a subscription being deleted. * - * Updates: - * - subscriptions.list (remove subscription from caches) - * - subscriptions.list per-tag infinite queries (only affected tags, or uncategorized) - * - entries.list (invalidated - entries may be filtered out) - * - tags.list (direct update of feedCount and unreadCount if subscription found in cache) - * - entries.count (direct update if subscription found in cache) - * - * @param utils - tRPC utils for cache access - * @param subscriptionId - ID of the deleted subscription - * @param queryClient - React Query client for targeted invalidations + * Updates TanStack DB collections: + * - subscriptions collection (remove subscription) + * - tags/uncategorized counts (feedCount + unreadCount) + * - entries counts ("all") */ export function handleSubscriptionDeleted( utils: TRPCClientUtils, subscriptionId: string, - queryClient?: QueryClient + collections?: Collections | null ): void { - // Look up subscription data before removing from cache - // This lets us do targeted updates instead of broad invalidations - const subscription = queryClient - ? findCachedSubscription(utils, queryClient, subscriptionId) - : undefined; - - // Remove from all subscription caches - removeSubscriptionFromCache(utils, subscriptionId); - if (queryClient) { - removeSubscriptionFromInfiniteQueries(queryClient, subscriptionId); - } + // Look up subscription data before removing from collection + const subscription = collections?.subscriptions.get(subscriptionId); - if (subscription && queryClient) { - // Targeted invalidations using subscription data - invalidateSubscriptionListsForTags( - queryClient, - subscription.tags.map((t) => t.id), - subscription.tags.length === 0 - ); - - // Directly update tags.list feedCount and unreadCount - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - - if (subscription.tags.length === 0) { - // Uncategorized subscription - return { - ...oldData, - uncategorized: { - feedCount: Math.max(0, oldData.uncategorized.feedCount - 1), - unreadCount: Math.max(0, oldData.uncategorized.unreadCount - subscription.unreadCount), - }, - }; + if (subscription) { + // Targeted updates using subscription data + if (subscription.tags.length === 0) { + adjustUncategorizedFeedCountInCollection(collections ?? null, -1); + adjustUncategorizedUnreadInCollection(collections ?? null, -subscription.unreadCount); + } else { + for (const tag of subscription.tags) { + adjustTagFeedCountInCollection(collections ?? null, tag.id, -1); + const tagDeltas = new Map([[tag.id, -subscription.unreadCount]]); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); } + } - // Update feedCount and unreadCount for each tag - const tagIds = new Set(subscription.tags.map((t) => t.id)); - return { - ...oldData, - items: oldData.items.map((tag) => { - if (tagIds.has(tag.id)) { - return { - ...tag, - feedCount: Math.max(0, tag.feedCount - 1), - unreadCount: Math.max(0, tag.unreadCount - subscription.unreadCount), - }; - } - return tag; - }), - }; - }); - - // Directly update entries.count for All Articles - adjustEntriesCount(utils, {}, -subscription.unreadCount, -subscription.unreadCount); - } else { - // Fallback: invalidate broadly when we don't have subscription data - utils.subscriptions.list.invalidate(); - utils.tags.list.invalidate(); - utils.entries.count.invalidate(); + // Update entry counts + adjustEntriesCountInCollection( + collections ?? null, + "all", + -subscription.totalCount, + -subscription.unreadCount + ); } - // Always invalidate entries.list - entries from this subscription should be filtered out - utils.entries.list.invalidate(); + // Remove from collection + removeSubscriptionFromCollection(collections ?? null, subscriptionId); + + // Invalidate active entry view - entries from this subscription should be filtered out + collections?.invalidateActiveView(); } /** * Handles a new entry being created in a subscription. * - * Updates: - * - subscriptions.list unread counts (+1) - * - tags.list unread counts (+1) - * - entries.count({ type: "saved" }) if entry is saved - * - * Does NOT invalidate entries.list - new entries appear on next navigation. - * Note: We don't have full entry data from SSE events, so we can't update - * entries.get or entries.list caches. That's OK - entries will be fetched - * when user navigates to that view (SuspendingEntryList refetches on pathname change). - * - * @param utils - tRPC utils for cache access - * @param subscriptionId - Subscription the entry belongs to - * @param feedType - Type of feed (web, email, saved) - * @param queryClient - React Query client for updating infinite query caches + * Updates TanStack DB collections: + * - subscription unread count (+1) + * - tag/uncategorized unread counts (+1) + * - entries counts ("all" +1, "saved" +1 if saved) */ export function handleNewEntry( utils: TRPCClientUtils, subscriptionId: string | null, feedType: "web" | "email" | "saved", - queryClient?: QueryClient + collections?: Collections | null ): void { - // Update subscription and tag unread counts (only for non-saved entries) + // Update per-subscription and tag/uncategorized unread counts. + // Same tradeoff as entry_state_changed: if the subscription isn't loaded + // in the collection, tag deltas will be zero and counts may drift until + // the tag section is expanded and refetched (see #623). if (subscriptionId) { - // New entries are always unread (read: false, starred: false) const subscriptionDeltas = new Map(); - subscriptionDeltas.set(subscriptionId, 1); // +1 unread + subscriptionDeltas.set(subscriptionId, 1); - // Update subscription and tag unread counts (including per-tag infinite queries) - updateSubscriptionAndTagCounts(utils, subscriptionDeltas, queryClient); - } + adjustSubscriptionUnreadInCollection(collections ?? null, subscriptionDeltas); - // Update All Articles unread count (+1 unread, +1 total) - adjustEntriesCount(utils, {}, 1, 1); + const { tagDeltas, uncategorizedDelta } = calculateTagDeltasFromSubscriptions( + subscriptionDeltas, + collections ?? null + ); + adjustTagUnreadInCollection(collections ?? null, tagDeltas); + adjustUncategorizedUnreadInCollection(collections ?? null, uncategorizedDelta); + } - // Update saved unread count if it's a saved entry + // Update entry counts + adjustEntriesCountInCollection(collections ?? null, "all", 1, 1); if (feedType === "saved") { - adjustEntriesCount(utils, { type: "saved" }, 1, 1); + adjustEntriesCountInCollection(collections ?? null, "saved", 1, 1); } } @@ -572,330 +194,77 @@ export interface BulkUnreadCounts { /** * Sets absolute counts from server response. * Used by single-entry mutations (star, unstar) that return UnreadCounts. - * - * @param utils - tRPC utils for cache access - * @param counts - Absolute counts from server - * @param queryClient - React Query client for updating infinite query caches */ -export function setCounts( - utils: TRPCClientUtils, - counts: UnreadCounts, - queryClient?: QueryClient -): void { - // Set global counts - utils.entries.count.setData({}, counts.all); - utils.entries.count.setData({ starredOnly: true }, counts.starred); - +export function setCounts(collections: Collections | null, counts: UnreadCounts): void { + // Set global counts in collection + setEntriesCountInCollection(collections, "all", counts.all.total, counts.all.unread); + setEntriesCountInCollection(collections, "starred", counts.starred.total, counts.starred.unread); if (counts.saved) { - utils.entries.count.setData({ type: "saved" }, counts.saved); + setEntriesCountInCollection(collections, "saved", counts.saved.total, counts.saved.unread); } // Set subscription unread count if (counts.subscription) { - setSubscriptionUnreadCount( - utils, + setSubscriptionUnreadInCollection( + collections, counts.subscription.id, - counts.subscription.unread, - queryClient + counts.subscription.unread ); } // Set tag unread counts if (counts.tags) { for (const tag of counts.tags) { - setTagUnreadCount(utils, tag.id, tag.unread); + setTagUnreadInCollection(collections, tag.id, tag.unread); } } // Set uncategorized count if (counts.uncategorized) { - setUncategorizedUnreadCount(utils, counts.uncategorized.unread); + setUncategorizedUnreadInCollection(collections, counts.uncategorized.unread); } } /** * Sets absolute counts from bulk mutation response. * Used by markRead mutation that returns BulkUnreadCounts. - * - * @param utils - tRPC utils for cache access - * @param counts - Absolute counts from server - * @param queryClient - React Query client for updating infinite query caches */ -export function setBulkCounts( - utils: TRPCClientUtils, - counts: BulkUnreadCounts, - queryClient?: QueryClient -): void { - // Set global counts - utils.entries.count.setData({}, counts.all); - utils.entries.count.setData({ starredOnly: true }, counts.starred); - utils.entries.count.setData({ type: "saved" }, counts.saved); +export function setBulkCounts(collections: Collections | null, counts: BulkUnreadCounts): void { + // Set global counts in collection + setEntriesCountInCollection(collections, "all", counts.all.total, counts.all.unread); + setEntriesCountInCollection(collections, "starred", counts.starred.total, counts.starred.unread); + setEntriesCountInCollection(collections, "saved", counts.saved.total, counts.saved.unread); - // Build subscription updates map for efficient batch update + // Set subscription unread counts const subscriptionUpdates = new Map(counts.subscriptions.map((s) => [s.id, s.unread])); - - // Build set of affected tag IDs to only update those caches - const affectedTagIds = new Set(counts.tags.map((t) => t.id)); - - // Batch update all subscription unread counts - setBulkSubscriptionUnreadCounts( - utils, - subscriptionUpdates, - affectedTagIds, - counts.uncategorized !== undefined, - queryClient - ); + setBulkSubscriptionUnreadInCollection(collections, subscriptionUpdates); // Set tag unread counts for (const tag of counts.tags) { - setTagUnreadCount(utils, tag.id, tag.unread); + setTagUnreadInCollection(collections, tag.id, tag.unread); } // Set uncategorized count if (counts.uncategorized) { - setUncategorizedUnreadCount(utils, counts.uncategorized.unread); + setUncategorizedUnreadInCollection(collections, counts.uncategorized.unread); } } /** - * Sets unread counts for multiple subscriptions, only updating affected tag caches. - * - * Instead of scanning ALL cached tag queries for each subscription, this function - * uses the known affected tag IDs to only iterate through relevant caches. - * - * @param utils - tRPC utils for cache access - * @param subscriptionUpdates - Map of subscriptionId -> new unread count - * @param affectedTagIds - Set of tag IDs that were affected (only these caches need updating) - * @param hasUncategorized - Whether uncategorized subscriptions were affected - * @param queryClient - React Query client for updating infinite query caches + * Fetches fresh global counts from the server and writes to the counts collection. + * Used after operations that don't return counts (markAllRead, OPML import, error recovery). */ -function setBulkSubscriptionUnreadCounts( +export async function refreshGlobalCounts( utils: TRPCClientUtils, - subscriptionUpdates: Map, - affectedTagIds: Set, - hasUncategorized: boolean, - queryClient?: QueryClient -): void { - if (subscriptionUpdates.size === 0) return; - - // Update in unparameterized subscriptions.list cache - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((sub) => { - const newUnread = subscriptionUpdates.get(sub.id); - return newUnread !== undefined ? { ...sub, unreadCount: newUnread } : sub; - }), - }; - }); - - // Update only the affected per-tag infinite query caches - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData<{ - pages: Array<{ items: Array<{ id: string; unreadCount: number; [key: string]: unknown }> }>; - pageParams: unknown[]; - }>({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - // Check if this query is for an affected tag - const keyData = queryKey[1] as { input?: SubscriptionListInput } | undefined; - const input = keyData?.input; - - // Skip queries that aren't for affected tags or uncategorized - if (input) { - const isAffectedTag = input.tagId && affectedTagIds.has(input.tagId); - const isAffectedUncategorized = hasUncategorized && input.uncategorized === true; - if (!isAffectedTag && !isAffectedUncategorized) { - continue; - } - } - - // Update subscriptions in this cache - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((s) => { - const newUnread = subscriptionUpdates.get(s.id); - return newUnread !== undefined ? { ...s, unreadCount: newUnread } : s; - }), - })), - }); - } - } -} - -/** - * Sets the unread count for a specific subscription. - * Used by single-entry mutations (setCounts) where we don't know the affected tags. - */ -function setSubscriptionUnreadCount( - utils: TRPCClientUtils, - subscriptionId: string, - unread: number, - queryClient?: QueryClient -): void { - // Update in unparameterized subscriptions.list cache - utils.subscriptions.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((sub) => - sub.id === subscriptionId ? { ...sub, unreadCount: unread } : sub - ), - }; - }); - - // Update in per-tag infinite query caches - // Note: For single-entry mutations, we still need to scan all caches - // since we don't have the affected tag IDs. This is acceptable because - // single-entry mutations (star/unstar) are less frequent than markRead. - if (queryClient) { - const infiniteQueries = queryClient.getQueriesData<{ - pages: Array<{ items: Array<{ id: string; unreadCount: number; [key: string]: unknown }> }>; - pageParams: unknown[]; - }>({ - queryKey: [["subscriptions", "list"]], - }); - - for (const [queryKey, data] of infiniteQueries) { - if (!data?.pages) continue; - - const hasSubscription = data.pages.some((page) => - page.items.some((s) => s.id === subscriptionId) - ); - - if (hasSubscription) { - queryClient.setQueryData(queryKey, { - ...data, - pages: data.pages.map((page) => ({ - ...page, - items: page.items.map((s) => - s.id === subscriptionId ? { ...s, unreadCount: unread } : s - ), - })), - }); - } - } - } -} - -/** - * Sets the unread count for a specific tag. - */ -function setTagUnreadCount(utils: TRPCClientUtils, tagId: string, unread: number): void { - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - items: oldData.items.map((tag) => (tag.id === tagId ? { ...tag, unreadCount: unread } : tag)), - }; - }); -} - -/** - * Sets the uncategorized unread count. - */ -function setUncategorizedUnreadCount(utils: TRPCClientUtils, unread: number): void { - utils.tags.list.setData(undefined, (oldData) => { - if (!oldData) return oldData; - return { - ...oldData, - uncategorized: { - ...oldData.uncategorized, - unreadCount: unread, - }, - }; - }); -} - -// ============================================================================ -// Optimistic Update Helpers -// ============================================================================ - -/** - * Context returned by optimistic read update for rollback. - */ -export interface OptimisticReadContext { - previousEntries: Map; - entryIds: string[]; -} - -/** - * Prepares and applies an optimistic read status update. - * Should be called in onMutate. Returns context needed for rollback in onError. - * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache access - * @param entryIds - Entry IDs to update - * @param read - New read status - * @returns Context for rollback - */ -export async function applyOptimisticReadUpdate( - utils: TRPCClientUtils, - queryClient: QueryClient, - entryIds: string[], - read: boolean -): Promise { - // We intentionally don't cancel any queries here. - // - Cancelling entries.get would abort content fetches, leaving only placeholder data - // - Cancelling entries.list would disrupt scrolling/loading while marking entries read - // - If a fetch completes with stale read status, onSuccess will correct it immediately - // - The race condition window is small (between onMutate and onSuccess) - - // Snapshot the previous state for rollback - const previousEntries = new Map(); - for (const entryId of entryIds) { - const data = utils.entries.get.getData({ id: entryId }); - previousEntries.set(entryId, data?.entry ? { read: data.entry.read } : undefined); - } - - // Optimistically update the cache immediately - updateEntriesReadStatus(utils, entryIds, read, queryClient); - - return { previousEntries, entryIds }; -} - -/** - * Context returned by optimistic starred update for rollback. - */ -export interface OptimisticStarredContext { - entryId: string; - wasStarred: boolean; -} - -/** - * Prepares and applies an optimistic starred status update. - * Should be called in onMutate. Returns context needed for rollback in onError. - * - * @param utils - tRPC utils for cache access - * @param queryClient - React Query client for cache access - * @param entryId - Entry ID to update - * @param starred - New starred status - * @returns Context for rollback - */ -export async function applyOptimisticStarredUpdate( - utils: TRPCClientUtils, - queryClient: QueryClient, - entryId: string, - starred: boolean -): Promise { - // We intentionally don't cancel any queries here. - // - Cancelling entries.get would abort content fetches, leaving only placeholder data - // - Cancelling entries.list would disrupt scrolling/loading - // - If a fetch completes with stale starred status, onSuccess will correct it immediately - - // Snapshot previous state for rollback - const previousEntry = utils.entries.get.getData({ id: entryId }); - const wasStarred = previousEntry?.entry?.starred ?? !starred; - - // Optimistically update the cache - updateEntryStarredStatus(utils, entryId, starred, queryClient); - - return { entryId, wasStarred }; + collections: Collections | null +): Promise { + if (!collections) return; + const [all, starred, saved] = await Promise.all([ + utils.entries.count.fetch({}), + utils.entries.count.fetch({ starredOnly: true }), + utils.entries.count.fetch({ type: "saved" }), + ]); + setEntriesCountInCollection(collections, "all", all.total, all.unread); + setEntriesCountInCollection(collections, "starred", starred.total, starred.unread); + setEntriesCountInCollection(collections, "saved", saved.total, saved.unread); } diff --git a/src/lib/collections/context.tsx b/src/lib/collections/context.tsx new file mode 100644 index 00000000..a967e1ba --- /dev/null +++ b/src/lib/collections/context.tsx @@ -0,0 +1,32 @@ +/** + * Collections React Context + * + * Provides TanStack DB collections to React components via context. + * Collections are created once in the TRPCProvider and shared across + * the component tree. + * + * Usage: + * const { subscriptions, tags, entries, counts } = useCollections(); + */ + +"use client"; + +import { createContext, useContext } from "react"; +import type { Collections } from "./index"; + +const CollectionsContext = createContext(null); + +/** + * Hook to access TanStack DB collections from any component. + * + * @throws Error if used outside of CollectionsProvider + */ +export function useCollections(): Collections { + const collections = useContext(CollectionsContext); + if (!collections) { + throw new Error("useCollections must be used within a CollectionsProvider"); + } + return collections; +} + +export const CollectionsProvider = CollectionsContext.Provider; diff --git a/src/lib/collections/counts.ts b/src/lib/collections/counts.ts new file mode 100644 index 00000000..7d58be8a --- /dev/null +++ b/src/lib/collections/counts.ts @@ -0,0 +1,32 @@ +/** + * Counts Collection + * + * Local-only collection for entry counts (total/unread). + * Updated from server responses after mutations and SSE events. + * Not synced to any backend - purely derived client-side state. + */ + +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import type { CountRecord } from "./types"; + +/** + * Creates the counts collection as a local-only store. + * + * Count records are keyed by string identifiers: + * - "all" - All entries count + * - "starred" - Starred entries count + * - "saved" - Saved articles count + * + * These are populated from server responses (entries.count, mutation results) + * and updated optimistically during mutations. + */ +export function createCountsCollection() { + return createCollection( + localOnlyCollectionOptions({ + id: "counts", + getKey: (item: CountRecord) => item.id, + }) + ); +} + +export type CountsCollection = ReturnType; diff --git a/src/lib/collections/entries.ts b/src/lib/collections/entries.ts new file mode 100644 index 00000000..48a828e4 --- /dev/null +++ b/src/lib/collections/entries.ts @@ -0,0 +1,213 @@ +/** + * Entries Collections + * + * Two collection types: + * + * 1. **Global entries collection** (local-only): Singleton for SSE state updates, + * fallback lookups, and detail view overlays. Populated from view collection + * results and SSE events. + * + * 2. **View entries collection** (on-demand, query-backed): Per-view/filter + * collection that drives the entry list UI. Created per route/filter set, + * fetches pages from the server on demand as the user scrolls. + * Uses `useLiveInfiniteQuery` for rendering. + */ + +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient, InfiniteData } from "@tanstack/react-query"; +import type { EntryListItem } from "./types"; +import type { EntryType } from "@/lib/queries/entries-list-input"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Entry list item with computed sort key for client-side ordering */ +export interface SortedEntryListItem extends EntryListItem { + _sortMs: number; +} + +/** Server response from entries.list */ +interface EntriesListResponse { + items: EntryListItem[]; + nextCursor?: string; +} + +/** Filter params baked into a view collection */ +export interface EntriesViewFilters { + subscriptionId?: string; + tagId?: string; + uncategorized?: boolean; + unreadOnly: boolean; + starredOnly?: boolean; + sortOrder: "newest" | "oldest"; + type?: EntryType; + limit: number; +} + +// --------------------------------------------------------------------------- +// Global entries collection (local-only) +// --------------------------------------------------------------------------- + +/** + * Creates the global entries collection as a local-only store. + * + * Used for: + * - SSE `entry_state_changed` / `entry_updated` writes + * - `EntryContentFallback` lookups + * - Cross-view state that persists across route changes + */ +export function createEntriesCollection() { + return createCollection( + localOnlyCollectionOptions({ + id: "entries", + getKey: (item: EntryListItem) => item.id, + }) + ); +} + +export type EntriesCollection = ReturnType; + +// --------------------------------------------------------------------------- +// View entries collection (on-demand, query-backed) +// --------------------------------------------------------------------------- + +/** + * Generates a stable string key for a filter combination. + * Used as the collection ID suffix and for cache key matching. + */ +export function stableFilterKey(filters: EntriesViewFilters): string { + return JSON.stringify([ + filters.subscriptionId ?? null, + filters.tagId ?? null, + filters.uncategorized ?? null, + filters.unreadOnly, + filters.starredOnly ?? null, + filters.sortOrder, + filters.type ?? null, + filters.limit, + ]); +} + +/** + * Checks the React Query cache for SSR-prefetched entries.list data. + * + * The server prefetches via `trpc.entries.list.prefetchInfinite(input)` which + * stores data under a key where `cursor` and `direction` are stripped by tRPC: + * [["entries", "list"], { input: { ...inputWithoutCursorAndDirection }, type: "infinite" }] + * + * We look this up to avoid a redundant first-page fetch. + */ +function checkSSRPrefetchCache( + queryClient: QueryClient, + filters: EntriesViewFilters +): EntriesListResponse | null { + // Build the tRPC infinite query key format. + // tRPC strips `cursor` and `direction` from infinite query keys + // (see getQueryKeyInternal in @trpc/react-query), so we must NOT include them. + const input = { + subscriptionId: filters.subscriptionId, + tagId: filters.tagId, + uncategorized: filters.uncategorized, + unreadOnly: filters.unreadOnly, + starredOnly: filters.starredOnly, + sortOrder: filters.sortOrder, + type: filters.type, + limit: filters.limit, + }; + const tRPCKey = [["entries", "list"], { input, type: "infinite" }]; + const data = queryClient.getQueryData>(tRPCKey); + if (data?.pages?.[0]) { + return data.pages[0]; + } + return null; +} + +/** + * Creates an on-demand entries collection for a specific view/filter set. + * + * The collection fetches pages from the server via cursor-based pagination, + * bridging TanStack DB's offset-based `loadSubset` to our cursor API. + * + * @param queryClient - The shared QueryClient instance + * @param fetchEntries - Function to fetch a page of entries from the server + * @param filters - The filter parameters for this view + */ +export function createViewEntriesCollection( + queryClient: QueryClient, + fetchEntries: (params: { + subscriptionId?: string; + tagId?: string; + uncategorized?: boolean; + unreadOnly: boolean; + starredOnly?: boolean; + sortOrder: "newest" | "oldest"; + type?: EntryType; + cursor?: string; + limit: number; + }) => Promise, + filters: EntriesViewFilters +) { + // Maps offset → nextCursor for bridging offset-based loading to cursor-based API + const cursorByOffset = new Map(); + + return createCollection( + queryCollectionOptions({ + id: `entries-view-${stableFilterKey(filters)}`, + syncMode: "on-demand" as const, + queryKey: ["entries-view", stableFilterKey(filters)], + queryClient, + getKey: (item: SortedEntryListItem) => item.id, + staleTime: Infinity, + queryFn: async (ctx) => { + const opts = ctx.meta?.loadSubsetOptions as { offset?: number; limit?: number } | undefined; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? filters.limit; + + // For the first page, check SSR-prefetched React Query cache + if (offset === 0) { + const prefetched = checkSSRPrefetchCache(queryClient, filters); + if (prefetched) { + if (prefetched.nextCursor) { + cursorByOffset.set(prefetched.items.length, prefetched.nextCursor); + } + return prefetched; + } + } + + // Map offset to cursor for subsequent pages + const cursor = offset > 0 ? cursorByOffset.get(offset) : undefined; + if (offset > 0 && !cursor) { + // No cursor for this offset — shouldn't happen with sequential loading + return { items: [] }; + } + + const result = await fetchEntries({ + subscriptionId: filters.subscriptionId, + tagId: filters.tagId, + uncategorized: filters.uncategorized, + unreadOnly: filters.unreadOnly, + starredOnly: filters.starredOnly, + sortOrder: filters.sortOrder, + type: filters.type, + cursor, + limit, + }); + + if (result.nextCursor) { + cursorByOffset.set(offset + result.items.length, result.nextCursor); + } + + return result; + }, + select: (data: EntriesListResponse): SortedEntryListItem[] => + data.items.map((item) => ({ + ...item, + _sortMs: new Date(item.publishedAt ?? item.fetchedAt).getTime(), + })), + }) + ); +} + +export type ViewEntriesCollection = ReturnType; diff --git a/src/lib/collections/index.ts b/src/lib/collections/index.ts new file mode 100644 index 00000000..502a97dc --- /dev/null +++ b/src/lib/collections/index.ts @@ -0,0 +1,274 @@ +/** + * TanStack DB Collections + * + * Central module for creating and accessing all collections. + * Collections are created once per browser session (tied to the QueryClient lifecycle). + * + * Architecture: + * Server ──(tRPC)──> TanStack Query ──> TanStack DB Collections + * │ + * SSE events ──────────────────────> Collection writes + * │ + * Live Queries + * (differential dataflow) + * │ + * ▼ + * Components + * (useLiveSuspenseQuery) + */ + +import type { QueryClient, QueryCacheNotifyEvent } from "@tanstack/react-query"; +import { + createSubscriptionsCollection, + type SubscriptionsCollection, + type TagSubscriptionsCollection, +} from "./subscriptions"; +import { + createTagsCollection, + type TagsCollection, + TRPC_TAGS_LIST_KEY, + type TagsListResponse, +} from "./tags"; +import { + createEntriesCollection, + type EntriesCollection, + type ViewEntriesCollection, +} from "./entries"; +import { createCountsCollection, type CountsCollection } from "./counts"; +import type { CountRecord, TagItem, UncategorizedCounts } from "./types"; + +/** + * tRPC query key prefix for entries.count queries. + * Full key format: [["entries", "count"], { input: {...}, type: "query" }] + */ +const ENTRIES_COUNT_KEY_PATH = ["entries", "count"] as const; + +export type { + Subscription, + EntryListItem, + TagItem, + CountRecord, + UncategorizedCounts, +} from "./types"; +export type { SubscriptionsCollection, TagSubscriptionsCollection } from "./subscriptions"; +export type { TagsCollection } from "./tags"; +export type { EntriesCollection } from "./entries"; +export type { ViewEntriesCollection } from "./entries"; +export type { CountsCollection } from "./counts"; + +/** + * All collections grouped together for convenient access. + * + * `activeViewCollection` is set by the SuspendingEntryList component to register + * the current view's on-demand collection. Mutations and SSE handlers write to it + * so changes propagate to the live query (in addition to the global entries collection). + */ +export interface Collections { + subscriptions: SubscriptionsCollection; + tags: TagsCollection; + entries: EntriesCollection; + counts: CountsCollection; + /** Active per-tag subscription collections, keyed by filter key. + * Registered by TagSubscriptionList, used by writes to propagate unread count changes. */ + tagSubscriptionCollections: Map; + /** The active on-demand view collection, set by useViewEntriesCollection */ + activeViewCollection: ViewEntriesCollection | null; + /** + * Invalidate the active view collection's query cache to trigger a refetch. + * Set by useViewEntriesCollection; no-op when no view collection is active. + * Call this instead of utils.entries.list.invalidate() to refresh the entry list. + */ + invalidateActiveView: () => void; +} + +/** + * Fetch functions for populating query-backed collections. + * These are provided by the TRPCProvider which has access to the tRPC client. + */ +export interface CollectionFetchers { + fetchTagsAndUncategorized: () => Promise<{ + items: TagItem[]; + uncategorized: UncategorizedCounts; + }>; +} + +/** + * Result of creating collections, including a cleanup function + * for unsubscribing from query cache listeners. + */ +export interface CreateCollectionsResult { + collections: Collections; + /** Unsubscribe from query cache listeners. Call when collections are destroyed. */ + cleanup: () => void; +} + +/** + * Check if a query key matches the tRPC tags.list key. + */ +function isTagsListKey(queryKey: readonly unknown[]): boolean { + if (queryKey.length < 2) return false; + const path = queryKey[0]; + const meta = queryKey[1] as { type?: string } | undefined; + return ( + Array.isArray(path) && + path.length === 2 && + path[0] === TRPC_TAGS_LIST_KEY[0][0] && + path[1] === TRPC_TAGS_LIST_KEY[0][1] && + meta?.type === TRPC_TAGS_LIST_KEY[1].type + ); +} + +/** + * Check if a query key matches an entries.count key. + */ +function isEntriesCountKey(queryKey: readonly unknown[]): boolean { + if (queryKey.length < 2) return false; + const path = queryKey[0]; + const meta = queryKey[1] as { type?: string } | undefined; + return ( + Array.isArray(path) && + path.length === 2 && + path[0] === ENTRIES_COUNT_KEY_PATH[0] && + path[1] === ENTRIES_COUNT_KEY_PATH[1] && + meta?.type === "query" + ); +} + +/** + * Determine the count key ("all", "starred", or "saved") from entries.count input. + * Returns null if the input doesn't match a known count category. + */ +function getCountKeyFromInput(input: Record | undefined): string | null { + if (!input || Object.keys(input).length === 0) { + return "all"; + } + if (input.starredOnly === true) { + return "starred"; + } + if (input.type === "saved") { + return "saved"; + } + return null; +} + +/** + * Write entry counts from an entries.count response to the counts collection. + */ +function syncEntryCount( + counts: CountsCollection, + countKey: string, + data: { total: number; unread: number } +): void { + const existing = counts.get(countKey); + if (existing) { + counts.update(countKey, (draft: CountRecord) => { + draft.total = data.total; + draft.unread = data.unread; + }); + } else { + counts.insert({ id: countKey, total: data.total, unread: data.unread }); + } +} + +/** + * Write uncategorized counts from a tags.list response to the counts collection. + */ +function syncUncategorizedCounts(counts: CountsCollection, data: TagsListResponse): void { + const existing = counts.get("uncategorized"); + if (existing) { + counts.update("uncategorized", (draft: CountRecord) => { + draft.total = data.uncategorized.feedCount; + draft.unread = data.uncategorized.unreadCount; + }); + } else { + counts.insert({ + id: "uncategorized", + total: data.uncategorized.feedCount, + unread: data.uncategorized.unreadCount, + }); + } +} + +/** + * Creates all TanStack DB collections. + * + * Called once in the TRPCProvider when the QueryClient is available. + * The fetcher functions bridge tRPC with the collection queryFn interface. + * + * Sets up a query cache subscription to sync counts from server responses: + * - Uncategorized counts from tags.list responses + * - Entry counts (all/starred/saved) from entries.count responses + * + * This subscription handles both eager seeding (from SSR-prefetched data) + * and async updates (when queries resolve after initialization). + * + * @param queryClient - The shared QueryClient instance + * @param fetchers - Functions to fetch data from the tRPC API + * @returns Collections and a cleanup function to unsubscribe cache listeners + */ +export function createCollections( + queryClient: QueryClient, + fetchers: CollectionFetchers +): CreateCollectionsResult { + const counts = createCountsCollection(); + + const collections: Collections = { + subscriptions: createSubscriptionsCollection(), + tags: createTagsCollection(queryClient, fetchers.fetchTagsAndUncategorized), + entries: createEntriesCollection(), + counts, + tagSubscriptionCollections: new Map(), + activeViewCollection: null, + invalidateActiveView: () => {}, + }; + + // Seed uncategorized counts from SSR-prefetched tags.list data + const prefetchedTagsData = queryClient.getQueryData( + TRPC_TAGS_LIST_KEY as unknown as readonly unknown[] + ); + if (prefetchedTagsData) { + syncUncategorizedCounts(counts, prefetchedTagsData); + } + + // Seed entry counts from SSR-prefetched entries.count queries + const countQueries = queryClient.getQueriesData<{ total: number; unread: number }>({ + queryKey: [ENTRIES_COUNT_KEY_PATH.slice()], + }); + for (const [queryKey, data] of countQueries) { + if (!data) continue; + const keyMeta = queryKey[1] as { input?: Record } | undefined; + const countKey = getCountKeyFromInput(keyMeta?.input); + if (countKey) { + syncEntryCount(counts, countKey, data); + } + } + + // Subscribe to query cache updates to sync counts whenever data changes + const unsubscribe = queryClient.getQueryCache().subscribe((event: QueryCacheNotifyEvent) => { + if (event.type === "updated" && event.action.type === "success") { + // Sync uncategorized counts from tags.list + if (isTagsListKey(event.query.queryKey)) { + const data = event.query.state.data as TagsListResponse | undefined; + if (data) { + syncUncategorizedCounts(counts, data); + } + } + + // Sync entry counts from entries.count + if (isEntriesCountKey(event.query.queryKey)) { + const data = event.query.state.data as { total: number; unread: number } | undefined; + if (data) { + const keyMeta = event.query.queryKey[1] as + | { input?: Record } + | undefined; + const countKey = getCountKeyFromInput(keyMeta?.input); + if (countKey) { + syncEntryCount(counts, countKey, data); + } + } + } + } + }); + + return { collections, cleanup: unsubscribe }; +} diff --git a/src/lib/collections/subscriptions.ts b/src/lib/collections/subscriptions.ts new file mode 100644 index 00000000..5fe41c4f --- /dev/null +++ b/src/lib/collections/subscriptions.ts @@ -0,0 +1,138 @@ +/** + * Subscriptions Collections + * + * Two collection types: + * + * 1. **Global subscriptions collection** (local-only): Singleton for SSE state updates, + * fast lookups by ID, and optimistic unread count updates. + * + * 2. **Tag subscriptions collection** (on-demand, query-backed): Per-tag/uncategorized + * collection that drives the sidebar subscription list UI. Created per tag section, + * fetches pages from the server on demand as the user scrolls. + */ + +import { createCollection, localOnlyCollectionOptions } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; +import type { Subscription } from "./types"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Server response from subscriptions.list */ +interface SubscriptionsListResponse { + items: Subscription[]; + nextCursor?: string; +} + +/** Filter params baked into a tag subscription collection */ +export interface TagSubscriptionFilters { + tagId?: string; + uncategorized?: boolean; + unreadOnly?: boolean; + limit: number; +} + +// --------------------------------------------------------------------------- +// Global subscriptions collection (local-only) +// --------------------------------------------------------------------------- + +/** + * Creates the global subscriptions collection as a local-only store. + * + * Used for: + * - Fast synchronous lookups by ID (collection.get(id)) + * - Optimistic unread count updates + * - SSE subscription_created/deleted events + */ +export function createSubscriptionsCollection() { + return createCollection( + localOnlyCollectionOptions({ + id: "subscriptions", + getKey: (item: Subscription) => item.id, + }) + ); +} + +export type SubscriptionsCollection = ReturnType; + +// --------------------------------------------------------------------------- +// Tag subscriptions collection (on-demand, query-backed) +// --------------------------------------------------------------------------- + +/** + * Generates a stable string key for a tag subscription filter combination. + */ +export function stableTagFilterKey(filters: TagSubscriptionFilters): string { + return JSON.stringify([ + filters.tagId ?? null, + filters.uncategorized ?? null, + filters.unreadOnly ?? null, + filters.limit, + ]); +} + +/** + * Creates an on-demand subscriptions collection for a specific tag/uncategorized section. + * + * Same offset-to-cursor bridge pattern as entries view collections. + * + * @param queryClient - The shared QueryClient instance + * @param fetchSubscriptions - Function to fetch a page from the server + * @param filters - The filter parameters for this section + */ +export function createTagSubscriptionsCollection( + queryClient: QueryClient, + fetchSubscriptions: (params: { + tagId?: string; + uncategorized?: boolean; + unreadOnly?: boolean; + cursor?: string; + limit: number; + }) => Promise, + filters: TagSubscriptionFilters +) { + const cursorByOffset = new Map(); + + return createCollection( + queryCollectionOptions({ + id: `subscriptions-tag-${stableTagFilterKey(filters)}`, + syncMode: "on-demand" as const, + queryKey: ["subscriptions-tag", stableTagFilterKey(filters)], + queryClient, + getKey: (item: Subscription) => item.id, + // Use a finite staleTime so expanding a tag section after SSE events + // have drifted the counts will trigger a refetch that corrects them (#623). + staleTime: 60_000, + queryFn: async (ctx) => { + const opts = ctx.meta?.loadSubsetOptions as { offset?: number; limit?: number } | undefined; + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? filters.limit; + + // Map offset to cursor for subsequent pages + const cursor = offset > 0 ? cursorByOffset.get(offset) : undefined; + if (offset > 0 && !cursor) { + return { items: [] }; + } + + const result = await fetchSubscriptions({ + tagId: filters.tagId, + uncategorized: filters.uncategorized, + unreadOnly: filters.unreadOnly, + cursor, + limit, + }); + + if (result.nextCursor) { + cursorByOffset.set(offset + result.items.length, result.nextCursor); + } + + return result; + }, + select: (data: SubscriptionsListResponse): Subscription[] => data.items, + }) + ); +} + +export type TagSubscriptionsCollection = ReturnType; diff --git a/src/lib/collections/tags.ts b/src/lib/collections/tags.ts new file mode 100644 index 00000000..c8e0d2b9 --- /dev/null +++ b/src/lib/collections/tags.ts @@ -0,0 +1,64 @@ +/** + * Tags Collection + * + * Eager-synced collection of user tags. + * Small dataset (<100 items), needed everywhere (sidebar, filtering). + * Loads all tags upfront via a single query. + * + * Uses the tRPC tags.list query key directly so it shares the same cache + * entry as the SSR prefetch, avoiding a duplicate network fetch on load. + * + * The tags.list API also returns uncategorized counts. These are synced + * to the counts collection via a query cache subscription set up in + * createCollections() (see index.ts), keeping `select` a pure transform. + */ + +import { createCollection } from "@tanstack/react-db"; +import { queryCollectionOptions } from "@tanstack/query-db-collection"; +import type { QueryClient } from "@tanstack/react-query"; +import type { TagItem, UncategorizedCounts } from "./types"; + +/** Shape of the tRPC tags.list response */ +export interface TagsListResponse { + items: TagItem[]; + uncategorized: UncategorizedCounts; +} + +/** + * tRPC query key for tags.list (no input). + * + * Format: [path, { type }] where path is the split procedure path. + * See @trpc/react-query getQueryKeyInternal for the key generation logic. + */ +export const TRPC_TAGS_LIST_KEY = [["tags", "list"], { type: "query" }] as const; + +/** + * Creates the tags collection backed by TanStack Query. + * + * Uses `select` to extract the `items` array from the tags.list response. + * This is a pure transformation with no side effects. + * + * @param queryClient - The shared QueryClient instance + * @param fetchTagsAndUncategorized - Function to fetch tags + uncategorized counts from the API + */ +export function createTagsCollection( + queryClient: QueryClient, + fetchTagsAndUncategorized: () => Promise +) { + return createCollection( + queryCollectionOptions({ + id: "tags", + // Use the tRPC query key so the collection shares the SSR-prefetched cache entry + queryKey: TRPC_TAGS_LIST_KEY as unknown as readonly unknown[], + queryFn: async () => { + return await fetchTagsAndUncategorized(); + }, + // Pure transformation: extract items array from the { items, uncategorized } response + select: (data: TagsListResponse) => data.items, + queryClient, + getKey: (item: TagItem) => item.id, + }) + ); +} + +export type TagsCollection = ReturnType; diff --git a/src/lib/collections/types.ts b/src/lib/collections/types.ts new file mode 100644 index 00000000..87d5c03b --- /dev/null +++ b/src/lib/collections/types.ts @@ -0,0 +1,62 @@ +/** + * Collection Types + * + * TypeScript types for TanStack DB collections, inferred from tRPC router outputs. + * These types represent the normalized data stored in each collection. + */ + +import type { inferRouterOutputs } from "@trpc/server"; +import type { AppRouter } from "@/server/trpc/root"; + +type RouterOutputs = inferRouterOutputs; + +// --------------------------------------------------------------------------- +// Subscriptions +// --------------------------------------------------------------------------- + +/** + * A single subscription as returned by subscriptions.list or subscriptions.get. + * This is the primary user-facing identifier for a feed. + */ +export type Subscription = RouterOutputs["subscriptions"]["list"]["items"][number]; + +// --------------------------------------------------------------------------- +// Entries +// --------------------------------------------------------------------------- + +/** + * An entry list item as returned by entries.list. + * Does not include full content (contentOriginal/contentCleaned). + */ +export type EntryListItem = RouterOutputs["entries"]["list"]["items"][number]; + +// --------------------------------------------------------------------------- +// Tags +// --------------------------------------------------------------------------- + +/** + * A tag as returned by tags.list. + */ +export type TagItem = RouterOutputs["tags"]["list"]["items"][number]; + +/** + * Uncategorized subscription counts from tags.list. + */ +export type UncategorizedCounts = RouterOutputs["tags"]["list"]["uncategorized"]; + +// --------------------------------------------------------------------------- +// Counts +// --------------------------------------------------------------------------- + +/** + * Entry counts (total + unread) for a specific filter combination. + * Stored in the local-only counts collection keyed by a string identifier. + */ +export interface CountRecord { + /** Unique key for this count (e.g., "all", "starred", "saved") */ + id: string; + /** Total entries matching this filter */ + total: number; + /** Unread entries matching this filter */ + unread: number; +} diff --git a/src/lib/collections/writes.ts b/src/lib/collections/writes.ts new file mode 100644 index 00000000..9e7ee959 --- /dev/null +++ b/src/lib/collections/writes.ts @@ -0,0 +1,603 @@ +/** + * Collection Write Utilities + * + * Functions that update TanStack DB collections for client-side state management. + * Each function accepts `Collections | null` and no-ops when null. + * + * Local-only collections (subscriptions, entries, counts) use direct mutation + * methods: collection.insert(), collection.update(), collection.delete(). + * + * Query-backed collections (tags) use collection.utils.writeInsert/writeUpdate/writeDelete + * which write directly to the synced data store. + */ + +import type { Collections } from "./index"; +import type { Subscription, TagItem, EntryListItem } from "./types"; + +// ============================================================================ +// Subscription Collection Writes (local-only) +// ============================================================================ + +/** + * Adjusts subscription unread counts by delta values. + * Used by handleEntriesMarkedRead, handleNewEntry, etc. + */ +export function adjustSubscriptionUnreadInCollection( + collections: Collections | null, + subscriptionDeltas: Map +): void { + if (!collections || subscriptionDeltas.size === 0) return; + + for (const [id, delta] of subscriptionDeltas) { + const current = collections.subscriptions.get(id); + if (current) { + collections.subscriptions.update(id, (draft) => { + draft.unreadCount = Math.max(0, current.unreadCount + delta); + }); + } + } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const updates: Array<{ id: string; unreadCount: number }> = []; + for (const [id, delta] of subscriptionDeltas) { + const current = tagCol.get(id); + if (current) { + updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + } + } + if (updates.length > 0) { + tagCol.utils.writeUpdate(updates); + } + } +} + +/** + * Sets the absolute unread count for a single subscription. + * Used by setCounts when server returns authoritative counts. + */ +export function setSubscriptionUnreadInCollection( + collections: Collections | null, + subscriptionId: string, + unread: number +): void { + if (!collections) return; + + const current = collections.subscriptions.get(subscriptionId); + if (current) { + collections.subscriptions.update(subscriptionId, (draft) => { + draft.unreadCount = unread; + }); + } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (tagCol.get(subscriptionId)) { + tagCol.utils.writeUpdate({ id: subscriptionId, unreadCount: unread }); + } + } +} + +/** + * Sets absolute unread counts for multiple subscriptions. + * Used by setBulkCounts after markRead mutation. + */ +export function setBulkSubscriptionUnreadInCollection( + collections: Collections | null, + updates: Map +): void { + if (!collections || updates.size === 0) return; + + for (const [id, unread] of updates) { + const current = collections.subscriptions.get(id); + if (current) { + collections.subscriptions.update(id, (draft) => { + draft.unreadCount = unread; + }); + } + } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const tagUpdates: Array<{ id: string; unreadCount: number }> = []; + for (const [id, unread] of updates) { + if (tagCol.get(id)) { + tagUpdates.push({ id, unreadCount: unread }); + } + } + if (tagUpdates.length > 0) { + tagCol.utils.writeUpdate(tagUpdates); + } + } +} + +/** + * Adds a new subscription to the collection. + * Used by handleSubscriptionCreated (SSE/sync events). + */ +export function addSubscriptionToCollection( + collections: Collections | null, + subscription: Subscription +): void { + if (!collections) return; + + // Check for duplicates (SSE race condition) + if (collections.subscriptions.has(subscription.id)) return; + + collections.subscriptions.insert(subscription); + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (!tagCol.has(subscription.id)) { + tagCol.utils.writeInsert(subscription); + } + } +} + +/** + * Removes a subscription from the collection. + * Used by handleSubscriptionDeleted. + */ +export function removeSubscriptionFromCollection( + collections: Collections | null, + subscriptionId: string +): void { + if (!collections) return; + + if (collections.subscriptions.has(subscriptionId)) { + collections.subscriptions.delete(subscriptionId); + } + + // Propagate to per-tag subscription collections + for (const [, tagCol] of collections.tagSubscriptionCollections) { + if (tagCol.has(subscriptionId)) { + tagCol.utils.writeDelete(subscriptionId); + } + } +} + +/** + * Upserts subscriptions into the collection from infinite query pages. + * Called by TagSubscriptionList as pages load, so the collection accumulates + * subscriptions for fast lookups and optimistic updates. + */ +export function upsertSubscriptionsInCollection( + collections: Collections | null, + subscriptions: Subscription[] +): void { + if (!collections || subscriptions.length === 0) return; + + for (const sub of subscriptions) { + if (collections.subscriptions.has(sub.id)) { + collections.subscriptions.update(sub.id, (draft) => { + Object.assign(draft, sub); + }); + } else { + collections.subscriptions.insert(sub); + } + } +} + +/** + * Zeroes out unread counts for subscriptions matching markAllRead filters. + * + * - No filter: all subscriptions set to 0 + * - subscriptionId: only that subscription + * - tagId: all subscriptions whose `tags` array contains that tag + */ +export function zeroSubscriptionUnreadForMarkAllRead( + collections: Collections | null, + filters: { + subscriptionId?: string; + tagId?: string; + } +): void { + if (!collections) return; + + // Track which subscription IDs are zeroed so we can propagate to tag collections + const zeroedIds: string[] = []; + + if (filters.subscriptionId) { + // Single subscription + const current = collections.subscriptions.get(filters.subscriptionId); + if (current) { + collections.subscriptions.update(filters.subscriptionId, (draft) => { + draft.unreadCount = 0; + }); + zeroedIds.push(filters.subscriptionId); + } + } else if (filters.tagId) { + // All subscriptions with this tag + collections.subscriptions.forEach((sub) => { + if (sub.tags.some((t) => t.id === filters.tagId) && sub.unreadCount > 0) { + collections.subscriptions.update(sub.id, (draft) => { + draft.unreadCount = 0; + }); + zeroedIds.push(sub.id); + } + }); + } else { + // No filter: zero out all subscriptions + collections.subscriptions.forEach((sub) => { + if (sub.unreadCount > 0) { + collections.subscriptions.update(sub.id, (draft) => { + draft.unreadCount = 0; + }); + zeroedIds.push(sub.id); + } + }); + } + + // Propagate to per-tag subscription collections + if (zeroedIds.length > 0) { + for (const [, tagCol] of collections.tagSubscriptionCollections) { + const updates: Array<{ id: string; unreadCount: number }> = []; + for (const id of zeroedIds) { + if (tagCol.get(id)) { + updates.push({ id, unreadCount: 0 }); + } + } + if (updates.length > 0) { + tagCol.utils.writeUpdate(updates); + } + } + } +} + +// ============================================================================ +// Tag Collection Writes (query-backed) +// ============================================================================ + +/** + * Adjusts tag unread counts by delta values. + * Used by handleEntriesMarkedRead, handleNewEntry, etc. + * + * Note: uncategorized counts are stored in the counts collection + * (populated by the tags.list fetch) and updated via adjustUncategorizedUnreadInCollection. + */ +export function adjustTagUnreadInCollection( + collections: Collections | null, + tagDeltas: Map +): void { + if (!collections || tagDeltas.size === 0) return; + + const updates: Array & { id: string }> = []; + for (const [id, delta] of tagDeltas) { + const current = collections.tags.get(id); + if (current) { + updates.push({ id, unreadCount: Math.max(0, current.unreadCount + delta) }); + } + } + if (updates.length > 0) { + collections.tags.utils.writeUpdate(updates); + } +} + +/** + * Sets the absolute unread count for a single tag. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setTagUnreadInCollection( + collections: Collections | null, + tagId: string, + unread: number +): void { + if (!collections) return; + + const current = collections.tags.get(tagId); + if (current) { + collections.tags.utils.writeUpdate({ id: tagId, unreadCount: unread }); + } +} + +/** + * Adjusts the feedCount for a tag (used when subscriptions are created/deleted). + */ +export function adjustTagFeedCountInCollection( + collections: Collections | null, + tagId: string, + delta: number +): void { + if (!collections) return; + + const current = collections.tags.get(tagId); + if (current) { + collections.tags.utils.writeUpdate({ + id: tagId, + feedCount: Math.max(0, current.feedCount + delta), + }); + } +} + +/** + * Adds a new tag to the collection. + * Used by SSE tag_created events. + */ +export function addTagToCollection( + collections: Collections | null, + tag: { id: string; name: string; color: string | null } +): void { + if (!collections) return; + + if (collections.tags.has(tag.id)) return; + + collections.tags.utils.writeInsert({ + id: tag.id, + name: tag.name, + color: tag.color, + feedCount: 0, + unreadCount: 0, + createdAt: new Date(), + } as TagItem); +} + +/** + * Updates a tag in the collection. + * Used by SSE tag_updated events. + */ +export function updateTagInCollection( + collections: Collections | null, + tag: { id: string; name: string; color: string | null } +): void { + if (!collections) return; + + if (collections.tags.has(tag.id)) { + collections.tags.utils.writeUpdate({ + id: tag.id, + name: tag.name, + color: tag.color, + }); + } +} + +/** + * Removes a tag from the collection. + * Used by SSE tag_deleted events. + */ +export function removeTagFromCollection(collections: Collections | null, tagId: string): void { + if (!collections) return; + + if (collections.tags.has(tagId)) { + collections.tags.utils.writeDelete(tagId); + } +} + +// ============================================================================ +// Entry Count Writes (via Counts Collection, local-only) +// ============================================================================ + +/** + * Sets absolute entry counts from server response. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setEntriesCountInCollection( + collections: Collections | null, + key: "all" | "starred" | "saved", + total: number, + unread: number +): void { + if (!collections) return; + + if (collections.counts.has(key)) { + collections.counts.update(key, (draft) => { + draft.total = total; + draft.unread = unread; + }); + } else { + collections.counts.insert({ id: key, total, unread }); + } +} + +/** + * Adjusts entry counts by delta values. + * Used by handleNewEntry, handleSubscriptionCreated/Deleted. + */ +export function adjustEntriesCountInCollection( + collections: Collections | null, + key: "all" | "starred" | "saved", + totalDelta: number, + unreadDelta: number +): void { + if (!collections || (totalDelta === 0 && unreadDelta === 0)) return; + + const current = collections.counts.get(key); + if (current) { + collections.counts.update(key, (draft) => { + draft.total = Math.max(0, current.total + totalDelta); + draft.unread = Math.max(0, current.unread + unreadDelta); + }); + } +} + +/** + * Sets the uncategorized unread count to an absolute value. + * Used by setCounts/setBulkCounts when server returns authoritative counts. + */ +export function setUncategorizedUnreadInCollection( + collections: Collections | null, + unread: number +): void { + if (!collections) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.update("uncategorized", (draft) => { + draft.unread = unread; + }); + } +} + +// ============================================================================ +// Uncategorized Count Delta Writes (via Counts Collection) +// ============================================================================ + +/** + * Adjusts the uncategorized unread count by a delta value. + * Stored in the counts collection under the "uncategorized" key. + */ +export function adjustUncategorizedUnreadInCollection( + collections: Collections | null, + delta: number +): void { + if (!collections || delta === 0) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.update("uncategorized", (draft) => { + draft.unread = Math.max(0, current.unread + delta); + }); + } +} + +/** + * Adjusts the uncategorized feed count by a delta value. + * Used when subscriptions with no tags are created or deleted. + */ +export function adjustUncategorizedFeedCountInCollection( + collections: Collections | null, + delta: number +): void { + if (!collections || delta === 0) return; + + const current = collections.counts.get("uncategorized"); + if (current) { + collections.counts.update("uncategorized", (draft) => { + draft.total = Math.max(0, current.total + delta); + }); + } +} + +// ============================================================================ +// Entry Collection Writes (global local-only + active view collection) +// ============================================================================ + +/** + * Updates the read status for entries in both the global entries collection + * and the active view collection (if any). + * + * Global collection: local-only, uses collection.update() + * View collection: query-backed, uses collection.utils.writeUpdate() + */ +export function updateEntryReadInCollection( + collections: Collections | null, + entryIds: string[], + read: boolean +): void { + if (!collections || entryIds.length === 0) return; + + for (const id of entryIds) { + if (collections.entries.has(id)) { + collections.entries.update(id, (draft) => { + draft.read = read; + }); + } + } + + // Also update the active view collection so useLiveInfiniteQuery picks up changes + const viewCol = collections.activeViewCollection; + if (viewCol) { + const updates = entryIds.filter((id) => viewCol.has(id)).map((id) => ({ id, read })); + if (updates.length > 0) { + viewCol.utils.writeUpdate(updates); + } + } +} + +/** + * Updates the starred status for an entry in both the global entries collection + * and the active view collection (if any). + */ +export function updateEntryStarredInCollection( + collections: Collections | null, + entryId: string, + starred: boolean +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.update(entryId, (draft) => { + draft.starred = starred; + }); + } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, starred }); + } +} + +/** + * Updates the score fields for an entry in both the global entries collection + * and the active view collection (if any). + */ +export function updateEntryScoreInCollection( + collections: Collections | null, + entryId: string, + score: number | null, + implicitScore: number +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.update(entryId, (draft) => { + draft.score = score; + draft.implicitScore = implicitScore; + }); + } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, score, implicitScore }); + } +} + +/** + * Updates entry metadata (title, author, summary, url, publishedAt) in both + * the global entries collection and the active view collection (if any). + * Used for SSE entry_updated events. + */ +export function updateEntryMetadataInCollection( + collections: Collections | null, + entryId: string, + metadata: Partial +): void { + if (!collections) return; + + if (collections.entries.has(entryId)) { + collections.entries.update(entryId, (draft) => { + Object.assign(draft, metadata); + }); + } + + // Also update the active view collection + const viewCol = collections.activeViewCollection; + if (viewCol?.has(entryId)) { + viewCol.utils.writeUpdate({ id: entryId, ...metadata }); + } +} + +/** + * Upserts entries from tRPC infinite query pages into the collection. + * Called as pages load to populate the local-only entries collection. + * + * Uses insert for new entries and update for existing ones (e.g., when + * a refetch returns entries that were already loaded from a previous page). + */ +export function upsertEntriesInCollection( + collections: Collections | null, + entries: EntryListItem[] +): void { + if (!collections || entries.length === 0) return; + + for (const entry of entries) { + if (collections.entries.has(entry.id)) { + collections.entries.update(entry.id, (draft) => { + Object.assign(draft, entry); + }); + } else { + collections.entries.insert(entry); + } + } +} diff --git a/src/lib/events/schemas.ts b/src/lib/events/schemas.ts index 230b7951..11a6db01 100644 --- a/src/lib/events/schemas.ts +++ b/src/lib/events/schemas.ts @@ -47,6 +47,7 @@ export const subscriptionCreatedDataSchema = z.object({ customTitle: z.string().nullable(), subscribedAt: z.string(), unreadCount: z.number(), + totalCount: z.number(), tags: z.array(syncTagSchema), }); @@ -92,7 +93,7 @@ export const newEntryEventSchema = z.object({ entryId: z.string(), timestamp: timestampWithDefault, updatedAt: z.string(), - feedType: z.enum(["web", "email", "saved"]).optional(), + feedType: z.enum(["web", "email", "saved"]), }); export const entryUpdatedEventSchema = z.object({ @@ -109,6 +110,12 @@ export const entryStateChangedEventSchema = z.object({ entryId: z.string(), read: z.boolean(), starred: z.boolean(), + /** Subscription ID for count delta computation (null for saved/orphaned entries) */ + subscriptionId: z.string().nullable().optional(), + /** Previous read state before this change (absent in sync polling events) */ + previousRead: z.boolean().optional(), + /** Previous starred state before this change (absent in sync polling events) */ + previousStarred: z.boolean().optional(), timestamp: timestampWithDefault, updatedAt: z.string(), }); @@ -213,12 +220,29 @@ export const syncEventSchema = z.union([ importCompletedEventSchema, ]); +// ============================================================================ +// Inferred Types +// ============================================================================ + /** - * The inferred TypeScript type for sync events. - * Use this instead of manually maintaining interface types. + * Union type for all sync events, inferred from the Zod schema. */ export type SyncEvent = z.infer; +/** + * Individual event types for consumers that need to narrow on specific events. + */ +export type NewEntryEvent = z.infer; +export type EntryUpdatedEvent = z.infer; +export type EntryStateChangedEvent = z.infer; +export type SubscriptionCreatedEvent = z.infer; +export type SubscriptionDeletedEvent = z.infer; +export type TagCreatedEvent = z.infer; +export type TagUpdatedEvent = z.infer; +export type TagDeletedEvent = z.infer; +export type ImportProgressEvent = z.infer; +export type ImportCompletedEvent = z.infer; + // ============================================================================ // Server-Only Event Schema (without defaults/transforms) // ============================================================================ diff --git a/src/lib/hooks/types.ts b/src/lib/hooks/types.ts index cc250c3e..2f9913f4 100644 --- a/src/lib/hooks/types.ts +++ b/src/lib/hooks/types.ts @@ -4,7 +4,7 @@ * Common type definitions used across multiple hooks. */ -import { type EntryType } from "./useEntryMutations"; +import { type EntryType } from "@/lib/queries/entries-list-input"; /** * Entry data for list display and navigation. diff --git a/src/lib/hooks/useEntriesListInput.ts b/src/lib/hooks/useEntriesListInput.ts index 232e7c51..cd5f86df 100644 --- a/src/lib/hooks/useEntriesListInput.ts +++ b/src/lib/hooks/useEntriesListInput.ts @@ -10,8 +10,11 @@ import { useMemo } from "react"; import { usePathname } from "next/navigation"; import { useUrlViewPreferences } from "./useUrlViewPreferences"; -import { buildEntriesListInput, type EntriesListInput } from "@/lib/queries/entries-list-input"; -import { type EntryType } from "./useEntryMutations"; +import { + buildEntriesListInput, + type EntriesListInput, + type EntryType, +} from "@/lib/queries/entries-list-input"; /** * Route filters derived from the current pathname. diff --git a/src/lib/hooks/useEntryMutations.ts b/src/lib/hooks/useEntryMutations.ts index 3217b270..ebc79e86 100644 --- a/src/lib/hooks/useEntryMutations.ts +++ b/src/lib/hooks/useEntryMutations.ts @@ -20,27 +20,19 @@ "use client"; import { useCallback, useMemo, useRef } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; +import { setCounts, setBulkCounts, refreshGlobalCounts } from "@/lib/cache/operations"; import { - handleEntryScoreChanged, - setCounts, - setBulkCounts, - applyOptimisticReadUpdate, - applyOptimisticStarredUpdate, -} from "@/lib/cache/operations"; -import { - updateEntriesReadStatus, - updateEntryStarredStatus, - updateEntriesInListCache, - updateEntriesInAffectedListCaches, -} from "@/lib/cache/entry-cache"; + updateEntryReadInCollection, + updateEntryStarredInCollection, + updateEntryScoreInCollection, + zeroSubscriptionUnreadForMarkAllRead, +} from "@/lib/collections/writes"; +import { type EntryType } from "@/lib/queries/entries-list-input"; -/** - * Entry type for routing. - */ -export type EntryType = "web" | "email" | "saved"; +export type { EntryType }; /** * Options for the markAllRead mutation. @@ -158,7 +150,7 @@ interface EntryMutationTracking { */ export function useEntryMutations(): UseEntryMutationsResult { const utils = trpc.useUtils(); - const queryClient = useQueryClient(); + const collections = useCollections(); // Track pending mutations per entry for timestamp-based state merging const entryTracking = useRef(new Map()); @@ -249,6 +241,7 @@ export function useEntryMutations(): UseEntryMutationsResult { /** * Apply winning state to cache if it's newer than current cache state. + * Updates entries.get (detail view) and collection (list view). */ const applyWinningStateToCache = (entryId: string, winningState: MutationResultState) => { // Get current cache state to compare timestamps @@ -261,15 +254,29 @@ export function useEntryMutations(): UseEntryMutationsResult { return; } - // Update the cache with winning state - updateEntriesReadStatus(utils, [entryId], winningState.read); - updateEntryStarredStatus(utils, entryId, winningState.starred, queryClient); - handleEntryScoreChanged( - utils, + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + read: winningState.read, + starred: winningState.starred, + score: winningState.score, + implicitScore: winningState.implicitScore, + }, + }; + }); + + // Update entries collection (list view, via reactive useLiveQuery) + updateEntryReadInCollection(collections, [entryId], winningState.read); + updateEntryStarredInCollection(collections, entryId, winningState.starred); + updateEntryScoreInCollection( + collections, entryId, winningState.score, - winningState.implicitScore, - queryClient + winningState.implicitScore ); }; @@ -280,23 +287,34 @@ export function useEntryMutations(): UseEntryMutationsResult { onMutate: async (variables) => { const entryIds = variables.entries.map((e) => e.id); - const optimisticContext = await applyOptimisticReadUpdate( - utils, - queryClient, - entryIds, - variables.read - ); + // Snapshot previous state for rollback + const previousEntries = new Map(); + for (const entryId of entryIds) { + const data = utils.entries.get.getData({ id: entryId }); + previousEntries.set(entryId, data?.entry ? { read: data.entry.read } : undefined); + } + + // Optimistically update entries.get cache (detail view) + for (const entryId of entryIds) { + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, read: variables.read } }; + }); + } + + // Optimistic update in entries collection (list view, via reactive useLiveQuery) + updateEntryReadInCollection(collections, entryIds, variables.read); // Start tracking for each entry for (const entryId of entryIds) { - const prevEntry = optimisticContext.previousEntries.get(entryId); + const prevEntry = previousEntries.get(entryId); const originalRead = prevEntry?.read ?? false; const cachedData = utils.entries.get.getData({ id: entryId }); const originalStarred = cachedData?.entry?.starred ?? false; startTracking(entryId, originalRead, originalStarred); } - return optimisticContext; + return { previousEntries, entryIds }; }, onSuccess: (data) => { @@ -317,21 +335,8 @@ export function useEntryMutations(): UseEntryMutationsResult { } } - // Update list caches with read status from server response - const scope = { - tagIds: new Set(data.counts.tags.map((t) => t.id)), - hasUncategorized: data.counts.uncategorized !== undefined, - }; - // All entries in a markRead batch have the same read value - updateEntriesInAffectedListCaches( - queryClient, - data.entries, - { read: data.entries[0]?.read ?? false }, - scope - ); - // Update counts (always apply, not dependent on timestamp) - setBulkCounts(utils, data.counts, queryClient); + setBulkCounts(collections, data.counts); }, onError: (error, variables) => { @@ -346,9 +351,12 @@ export function useEntryMutations(): UseEntryMutationsResult { // Some mutations succeeded, apply winning state applyWinningStateToCache(entryId, result.winningState); } else { - // All mutations failed, rollback to original state from tracking - // (not from context, which may have captured intermediate state) - updateEntriesReadStatus(utils, [entryId], result.originalRead, queryClient); + // All mutations failed, rollback to original state + utils.entries.get.setData({ id: entryId }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, read: result.originalRead } }; + }); + updateEntryReadInCollection(collections, [entryId], result.originalRead); } } } @@ -357,24 +365,22 @@ export function useEntryMutations(): UseEntryMutationsResult { }, }); - // markAllRead mutation - invalidates caches based on what could be affected + // markAllRead mutation - invalidates caches and refreshes counts const markAllReadMutation = trpc.entries.markAllRead.useMutation({ onSuccess: (_data, variables) => { - utils.entries.list.invalidate(); + collections.invalidateActiveView(); utils.subscriptions.list.invalidate(); utils.tags.list.invalidate(); + collections.tags.utils.refetch(); - // All Articles count is always affected - utils.entries.count.invalidate({}); - - // Starred count is always affected since starred entries can exist in any view - utils.entries.count.invalidate({ starredOnly: true }); + // Zero out unread counts for affected subscriptions in the local collection + zeroSubscriptionUnreadForMarkAllRead(collections, { + subscriptionId: variables.subscriptionId, + tagId: variables.tagId, + }); - // Invalidate saved count if saved entries could be affected - // (either type: "saved" was set, or no type filter means all including saved) - if (variables.type === "saved" || !variables.type) { - utils.entries.count.invalidate({ type: "saved" }); - } + // markAllRead doesn't return counts, so fetch fresh counts from server + refreshGlobalCounts(utils, collections); }, onError: () => { toast.error("Failed to mark all as read"); @@ -386,20 +392,26 @@ export function useEntryMutations(): UseEntryMutationsResult { const setStarredMutation = trpc.entries.setStarred.useMutation({ // Optimistic update: immediately show the entry with new starred status onMutate: async (variables) => { - const optimisticContext = await applyOptimisticStarredUpdate( - utils, - queryClient, - variables.id, - variables.starred - ); + // Snapshot previous state for rollback + const previousEntry = utils.entries.get.getData({ id: variables.id }); + const wasStarred = previousEntry?.entry?.starred ?? !variables.starred; + + // Optimistically update entries.get cache (detail view) + utils.entries.get.setData({ id: variables.id }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, starred: variables.starred } }; + }); + + // Optimistic update in entries collection (list view, via reactive useLiveQuery) + updateEntryStarredInCollection(collections, variables.id, variables.starred); // Start tracking - const originalStarred = optimisticContext.wasStarred; + const originalStarred = wasStarred; const cachedData = utils.entries.get.getData({ id: variables.id }); const originalRead = cachedData?.entry?.read ?? false; startTracking(variables.id, originalRead, originalStarred); - return optimisticContext; + return { entryId: variables.id, wasStarred }; }, onSuccess: (data) => { @@ -417,11 +429,8 @@ export function useEntryMutations(): UseEntryMutationsResult { applyWinningStateToCache(data.entry.id, winningState); } - // Update list cache with starred status - updateEntriesInListCache(queryClient, [data.entry.id], { starred: data.entry.starred }); - // Update counts (always apply) - setCounts(utils, data.counts, queryClient); + setCounts(collections, data.counts); }, onError: (error, variables) => { @@ -431,9 +440,12 @@ export function useEntryMutations(): UseEntryMutationsResult { if (result.winningState) { applyWinningStateToCache(variables.id, result.winningState); } else { - // All mutations failed, rollback to original state from tracking - // (not from context, which may have captured intermediate state) - updateEntryStarredStatus(utils, variables.id, result.originalStarred, queryClient); + // All mutations failed, rollback to original state + utils.entries.get.setData({ id: variables.id }, (oldData) => { + if (!oldData) return oldData; + return { ...oldData, entry: { ...oldData.entry, starred: result.originalStarred } }; + }); + updateEntryStarredInCollection(collections, variables.id, result.originalStarred); } } toast.error(variables.starred ? "Failed to star entry" : "Failed to unstar entry"); @@ -443,12 +455,25 @@ export function useEntryMutations(): UseEntryMutationsResult { // setScore mutation - updates score cache only (no count changes) const setScoreMutation = trpc.entries.setScore.useMutation({ onSuccess: (data) => { - handleEntryScoreChanged( - utils, + // Update entries.get cache (detail view) + utils.entries.get.setData({ id: data.entry.id }, (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + entry: { + ...oldData.entry, + score: data.entry.score, + implicitScore: data.entry.implicitScore, + }, + }; + }); + + // Update entries collection (list view) + updateEntryScoreInCollection( + collections, data.entry.id, data.entry.score, - data.entry.implicitScore, - queryClient + data.entry.implicitScore ); }, onError: () => { diff --git a/src/lib/hooks/useEntryNavigation.ts b/src/lib/hooks/useEntryNavigation.ts new file mode 100644 index 00000000..61c563d2 --- /dev/null +++ b/src/lib/hooks/useEntryNavigation.ts @@ -0,0 +1,94 @@ +/** + * Entry Navigation Context + * + * Shares entry list navigation state (next/previous entry IDs) between + * SuspendingEntryList (which knows the entry ordering) and + * UnifiedEntriesContent (which needs it for swipe gesture navigation + * in the entry content view). + * + * Uses a ref + callback pattern to avoid unnecessary re-renders: + * - SuspendingEntryList writes to the ref whenever entries change + * - UnifiedEntriesContent subscribes via onChange callback + */ + +"use client"; + +import { createContext, useContext, useCallback, useSyncExternalStore } from "react"; + +interface EntryNavigationState { + nextEntryId: string | undefined; + previousEntryId: string | undefined; +} + +interface EntryNavigationStore { + /** Get current navigation state */ + getSnapshot: () => EntryNavigationState; + /** Subscribe to state changes */ + subscribe: (listener: () => void) => () => void; + /** Update navigation state (called by SuspendingEntryList) */ + update: (state: EntryNavigationState) => void; +} + +const defaultState: EntryNavigationState = { + nextEntryId: undefined, + previousEntryId: undefined, +}; + +export function createEntryNavigationStore(): EntryNavigationStore { + let state = defaultState; + const listeners = new Set<() => void>(); + + return { + getSnapshot: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + update: (newState) => { + // Only notify if state actually changed + if ( + state.nextEntryId !== newState.nextEntryId || + state.previousEntryId !== newState.previousEntryId + ) { + state = newState; + for (const listener of listeners) { + listener(); + } + } + }, + }; +} + +const EntryNavigationContext = createContext(null); + +export const EntryNavigationProvider = EntryNavigationContext.Provider; + +/** + * Subscribe to entry navigation state changes. + * Used by UnifiedEntriesContentInner for swipe gesture navigation. + */ +// No-op store used when context is not provided (avoids conditional hook call) +const noopStore: EntryNavigationStore = { + getSnapshot: () => defaultState, + subscribe: () => () => {}, + update: () => {}, +}; + +export function useEntryNavigationState(): EntryNavigationState { + const store = useContext(EntryNavigationContext) ?? noopStore; + return useSyncExternalStore(store.subscribe, store.getSnapshot, () => defaultState); +} + +/** + * Get the navigation state updater. + * Used by SuspendingEntryList to publish navigation state. + */ +export function useEntryNavigationUpdater(): (state: EntryNavigationState) => void { + const store = useContext(EntryNavigationContext); + return useCallback( + (state: EntryNavigationState) => { + store?.update(state); + }, + [store] + ); +} diff --git a/src/lib/hooks/useKeyboardShortcuts.ts b/src/lib/hooks/useKeyboardShortcuts.ts index 70cf7825..cf9455fd 100644 --- a/src/lib/hooks/useKeyboardShortcuts.ts +++ b/src/lib/hooks/useKeyboardShortcuts.ts @@ -23,7 +23,7 @@ import { useState, useCallback, useEffect, useRef, useMemo } from "react"; import { useHotkeys } from "react-hotkeys-hook"; -import { type EntryType } from "./useEntryMutations"; +import { type EntryType } from "@/lib/queries/entries-list-input"; import { clientPush } from "@/lib/navigation"; /** @@ -168,7 +168,7 @@ export interface UseKeyboardShortcutsResult { * isEntryOpen: !!openEntryId, * onToggleRead: (id, read) => markReadMutation.mutate({ ids: [id], read: !read }), * onToggleStar: (id, starred) => starred ? unstarMutation.mutate({ id }) : starMutation.mutate({ id }), - * onRefresh: () => utils.entries.list.invalidate(), + * onRefresh: () => collections.invalidateActiveView(), * }); * * return ( diff --git a/src/lib/hooks/useRealtimeUpdates.ts b/src/lib/hooks/useRealtimeUpdates.ts index 8a72b00c..1159d64d 100644 --- a/src/lib/hooks/useRealtimeUpdates.ts +++ b/src/lib/hooks/useRealtimeUpdates.ts @@ -7,7 +7,6 @@ * - Primary: Server-Sent Events (SSE) via Redis pub/sub * - Fallback: Polling sync endpoint when SSE is unavailable (Redis down) * - Automatic catch-up sync after SSE reconnection - * - Exponential backoff for reconnection attempts * - React Query cache invalidation * - Granular cursor tracking for each entity type (entries, subscriptions, tags, etc.) */ @@ -15,8 +14,8 @@ "use client"; import { useEffect, useRef, useCallback, useState } from "react"; -import { useQueryClient } from "@tanstack/react-query"; import { trpc } from "@/lib/trpc/client"; +import { useCollections } from "@/lib/collections/context"; import { handleSyncEvent } from "@/lib/cache/event-handlers"; import { syncEventSchema, type SyncEvent } from "@/lib/events/schemas"; @@ -69,21 +68,6 @@ export interface UseRealtimeUpdatesResult { // Constants // ============================================================================ -/** - * Maximum reconnection delay in milliseconds (30 seconds). - */ -const MAX_RECONNECT_DELAY_MS = 30_000; - -/** - * Initial reconnection delay in milliseconds (1 second). - */ -const INITIAL_RECONNECT_DELAY_MS = 1_000; - -/** - * Backoff multiplier for exponential backoff. - */ -const BACKOFF_MULTIPLIER = 2; - /** * Polling interval when in fallback mode (30 seconds). */ @@ -103,6 +87,10 @@ const SSE_RETRY_INTERVAL_MS = 60_000; * Uses the shared Zod schema for validation, which strips extra server fields * (userId, feedId) and applies defaults for optional fields like timestamp. * Returns null if the data is invalid or doesn't match a known event type. + * + * Uses the shared Zod schema for validation, replacing ~290 lines of manual + * typeof checks. The schema handles defaults (e.g., timestamp) and + * passthrough of extra server fields (e.g., userId, feedId). */ function parseEventData(data: string): SyncEvent | null { try { @@ -141,22 +129,21 @@ function parseEventData(data: string): SyncEvent | null { */ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpdatesResult { const utils = trpc.useUtils(); - const queryClient = useQueryClient(); + const collections = useCollections(); - // Connection status state + // Connection status state - isPolling is derived from connectionStatus === "polling" const [connectionStatus, setConnectionStatus] = useState("disconnected"); - const [isPollingMode, setIsPollingMode] = useState(false); // Refs to persist across renders const eventSourceRef = useRef(null); - const reconnectTimeoutRef = useRef | null>(null); const pollIntervalRef = useRef | null>(null); const sseRetryTimeoutRef = useRef | null>(null); - const reconnectDelayRef = useRef(INITIAL_RECONNECT_DELAY_MS); const isManuallyClosedRef = useRef(false); const shouldConnectRef = useRef(false); // Initialize with server-provided cursors (granular tracking per entity type) const cursorsRef = useRef(initialCursors); + // Ref for performSync to allow self-referencing without violating react-hooks/immutability + const performSyncRef = useRef<(() => Promise) | undefined>(undefined); // State to trigger reconnection const [reconnectTrigger, setReconnectTrigger] = useState(0); @@ -170,14 +157,9 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda const isAuthenticated = userQuery.isSuccess && userQuery.data?.user; /** - * Cleans up all connections and intervals. + * Cleans up all connections, intervals, and resets polling state. */ const cleanup = useCallback(() => { - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - reconnectTimeoutRef.current = null; - } - if (pollIntervalRef.current) { clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; @@ -243,10 +225,10 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // Update the appropriate cursor based on event type updateCursorForEvent(data); - // Delegate to shared handler for cache updates - handleSyncEvent(utils, queryClient, data); + // Delegate to shared handler for cache/collection updates + handleSyncEvent(utils, data, collections); }, - [utils, queryClient, updateCursorForEvent] + [utils, updateCursorForEvent, collections] ); /** @@ -270,13 +252,13 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda // Process each event through the shared handler (same as SSE path) for (const event of result.events) { updateCursorForEvent(event); - handleSyncEvent(utils, queryClient, event); + handleSyncEvent(utils, event, collections); } // If there are more events, schedule another sync soon if (result.hasMore) { // Use setTimeout to avoid blocking - the next poll or manual sync will pick up more - setTimeout(() => performSync(), 100); + setTimeout(() => performSyncRef.current?.(), 100); } return cursorsRef.current; @@ -284,7 +266,12 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda console.error("Sync failed:", error); return null; } - }, [utils, queryClient, updateCursorForEvent]); + }, [utils, updateCursorForEvent, collections]); + + // Keep ref in sync so self-referencing setTimeout always calls the latest version + useEffect(() => { + performSyncRef.current = performSync; + }, [performSync]); /** * Starts polling mode when SSE is unavailable. @@ -294,7 +281,6 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return; // Already polling } - setIsPollingMode(true); setConnectionStatus("polling"); // cursorsRef already initialized with server-provided cursors @@ -316,43 +302,19 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda clearInterval(pollIntervalRef.current); pollIntervalRef.current = null; } - setIsPollingMode(false); - }, []); - - /** - * Schedules a reconnection attempt with exponential backoff. - */ - const scheduleReconnect = useCallback((connectFn: () => void) => { - if (isManuallyClosedRef.current || !shouldConnectRef.current) return; - - if (reconnectTimeoutRef.current) { - clearTimeout(reconnectTimeoutRef.current); - } - - const delay = reconnectDelayRef.current; - - reconnectTimeoutRef.current = setTimeout(() => { - reconnectDelayRef.current = Math.min( - reconnectDelayRef.current * BACKOFF_MULTIPLIER, - MAX_RECONNECT_DELAY_MS - ); - connectFn(); - }, delay); }, []); /** * Manual reconnection function exposed to consumers. */ const reconnect = useCallback(() => { - reconnectDelayRef.current = INITIAL_RECONNECT_DELAY_MS; isManuallyClosedRef.current = false; shouldConnectRef.current = true; cleanup(); - stopPolling(); setReconnectTrigger((prev) => prev + 1); - }, [cleanup, stopPolling]); + }, [cleanup]); // Effect to manage SSE connection based on authentication useEffect(() => { @@ -361,7 +323,6 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda if (!isAuthenticated) { isManuallyClosedRef.current = true; cleanup(); - stopPolling(); return; } @@ -374,111 +335,72 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda cleanup(); isManuallyClosedRef.current = false; - const createConnection = async () => { + const createConnection = () => { if (!shouldConnectRef.current || isManuallyClosedRef.current) { return; } setConnectionStatus("connecting"); - try { - // First, try a fetch to check if SSE is available - // This handles the 503 case where Redis is down - const response = await fetch("/api/v1/events", { - method: "GET", - credentials: "include", - headers: { - Accept: "text/event-stream", - }, - }); - - // If we get a 503, switch to polling mode - if (response.status === 503) { - console.log("SSE unavailable (503), switching to polling mode"); + // Create EventSource directly - no probe fetch needed. + // If the server returns an error (e.g. 503 when Redis is down), + // EventSource will fire onerror with readyState CLOSED. + const eventSource = new EventSource("/api/v1/events", { + withCredentials: true, + }); + + eventSourceRef.current = eventSource; + + eventSource.onopen = () => { + setConnectionStatus("connected"); + + // Stop polling if we were in polling mode + stopPolling(); + + // Clear SSE retry timeout + if (sseRetryTimeoutRef.current) { + clearTimeout(sseRetryTimeoutRef.current); + sseRetryTimeoutRef.current = null; + } + + // Perform a catch-up sync to get any changes we might have missed + // The cursorsRef is already initialized, so performSync will work correctly + performSync(); + }; + + // Handle named events + eventSource.addEventListener("connected", handleEvent); // Initial cursor from server + eventSource.addEventListener("new_entry", handleEvent); + eventSource.addEventListener("entry_updated", handleEvent); + eventSource.addEventListener("entry_state_changed", handleEvent); + eventSource.addEventListener("subscription_created", handleEvent); + eventSource.addEventListener("subscription_deleted", handleEvent); + eventSource.addEventListener("tag_created", handleEvent); + eventSource.addEventListener("tag_updated", handleEvent); + eventSource.addEventListener("tag_deleted", handleEvent); + eventSource.addEventListener("import_progress", handleEvent); + eventSource.addEventListener("import_completed", handleEvent); + + eventSource.onerror = () => { + if (eventSourceRef.current?.readyState === EventSource.CLOSED) { + // Connection failed or server closed it (e.g. 503, network error). + // Switch to polling as fallback and schedule an SSE retry. + setConnectionStatus("error"); + cleanup(); startPolling(); - // Schedule periodic SSE retry + // Periodically retry SSE to recover from transient issues sseRetryTimeoutRef.current = setTimeout(() => { if (shouldConnectRef.current && !isManuallyClosedRef.current) { stopPolling(); createConnection(); } }, SSE_RETRY_INTERVAL_MS); - - return; - } - - // If not OK, treat as error - if (!response.ok) { - throw new Error(`SSE request failed with status ${response.status}`); + } else { + // readyState is CONNECTING - EventSource is auto-reconnecting + setConnectionStatus("connecting"); } - - // Close the fetch response since we'll use EventSource - // The fetch was just to check availability - response.body?.cancel(); - - // Create EventSource connection - const eventSource = new EventSource("/api/v1/events", { - withCredentials: true, - }); - - eventSourceRef.current = eventSource; - - eventSource.onopen = () => { - setConnectionStatus("connected"); - reconnectDelayRef.current = INITIAL_RECONNECT_DELAY_MS; - - // Stop polling if we were in polling mode - stopPolling(); - - // Clear SSE retry timeout - if (sseRetryTimeoutRef.current) { - clearTimeout(sseRetryTimeoutRef.current); - sseRetryTimeoutRef.current = null; - } - - // Perform a catch-up sync to get any changes we might have missed - // The cursorsRef is already initialized, so performSync will work correctly - performSync(); - }; - - // Handle named events - eventSource.addEventListener("connected", handleEvent); // Initial cursor from server - eventSource.addEventListener("new_entry", handleEvent); - eventSource.addEventListener("entry_updated", handleEvent); - eventSource.addEventListener("entry_state_changed", handleEvent); - eventSource.addEventListener("subscription_created", handleEvent); - eventSource.addEventListener("subscription_deleted", handleEvent); - eventSource.addEventListener("tag_created", handleEvent); - eventSource.addEventListener("tag_updated", handleEvent); - eventSource.addEventListener("tag_deleted", handleEvent); - eventSource.addEventListener("import_progress", handleEvent); - eventSource.addEventListener("import_completed", handleEvent); - - eventSource.onerror = () => { - if (eventSourceRef.current?.readyState === EventSource.CLOSED) { - setConnectionStatus("error"); - cleanup(); - scheduleReconnect(createConnection); - } else { - setConnectionStatus("connecting"); - } - }; - } catch (error) { - console.error("Failed to create SSE connection:", error); - setConnectionStatus("error"); - - // On connection failure, try polling as fallback - startPolling(); - - // Schedule SSE retry - sseRetryTimeoutRef.current = setTimeout(() => { - if (shouldConnectRef.current && !isManuallyClosedRef.current) { - stopPolling(); - createConnection(); - } - }, SSE_RETRY_INTERVAL_MS); - } + }; }; createConnection(); @@ -486,14 +408,12 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return () => { isManuallyClosedRef.current = true; cleanup(); - stopPolling(); }; }, [ isAuthenticated, reconnectTrigger, cleanup, handleEvent, - scheduleReconnect, startPolling, stopPolling, performSync, @@ -508,7 +428,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda eventSourceRef.current?.readyState !== EventSource.OPEN ) { // If in polling mode, do an immediate sync - if (isPollingMode) { + if (connectionStatus === "polling") { performSync(); } else { reconnect(); @@ -521,7 +441,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return () => { document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [isAuthenticated, isPollingMode, performSync, reconnect]); + }, [isAuthenticated, connectionStatus, performSync, reconnect]); // Derive the effective status const effectiveStatus: ConnectionStatus = isAuthenticated ? connectionStatus : "disconnected"; @@ -529,7 +449,7 @@ export function useRealtimeUpdates(initialCursors: SyncCursors): UseRealtimeUpda return { status: effectiveStatus, isConnected: effectiveStatus === "connected" || effectiveStatus === "polling", - isPolling: isPollingMode, + isPolling: effectiveStatus === "polling", reconnect, }; } diff --git a/src/lib/hooks/useStableEntryList.ts b/src/lib/hooks/useStableEntryList.ts new file mode 100644 index 00000000..31b4dbc3 --- /dev/null +++ b/src/lib/hooks/useStableEntryList.ts @@ -0,0 +1,151 @@ +/** + * useStableEntryList Hook + * + * Provides display stability for entry lists: entries that were visible + * when the view loaded won't disappear when their state changes. + * + * Problem: If the user is viewing "unread only" and marks an entry as read, + * the live query's `where` clause filters it out and it vanishes. + * + * Solution: Track which entries have been rendered ("seen"). When an entry + * drops out of the live query results, look it up in the view collection + * (which still has it) and merge it back into the display list. + * + * The seen set resets on navigation (when filterKey changes = new collection). + * + * Implementation: Uses an external store (useSyncExternalStore) to track + * seen IDs, with a microtask-based recording mechanism to comply with + * React 19's strict rules against side effects during render. + */ + +"use client"; + +import { useMemo, useSyncExternalStore } from "react"; +import type { SortedEntryListItem, ViewEntriesCollection } from "@/lib/collections/entries"; + +/** + * External store that tracks seen entry IDs. + * All mutations happen outside render (via microtask or subscribe callback). + */ +function createSeenIdsStore() { + let seenIds = new Set(); + let pendingIds: string[] | null = null; + const listeners = new Set<() => void>(); + + function notify() { + for (const listener of listeners) { + listener(); + } + } + + return { + getSnapshot: () => seenIds, + subscribe: (callback: () => void) => { + listeners.add(callback); + // Flush any pending IDs when a subscriber is added + if (pendingIds) { + const ids = pendingIds; + pendingIds = null; + let changed = false; + for (const id of ids) { + if (!seenIds.has(id)) { + changed = true; + break; + } + } + if (changed) { + seenIds = new Set(seenIds); + for (const id of ids) { + seenIds.add(id); + } + // Schedule notification to avoid sync issues + queueMicrotask(notify); + } + } + return () => listeners.delete(callback); + }, + /** Schedule IDs to be recorded after render */ + scheduleSeen: (ids: string[]) => { + pendingIds = ids; + queueMicrotask(() => { + if (!pendingIds) return; + const toRecord = pendingIds; + pendingIds = null; + let changed = false; + for (const id of toRecord) { + if (!seenIds.has(id)) { + changed = true; + break; + } + } + if (changed) { + seenIds = new Set(seenIds); + for (const id of toRecord) { + seenIds.add(id); + } + notify(); + } + }); + }, + reset: () => { + pendingIds = null; + if (seenIds.size > 0) { + seenIds = new Set(); + notify(); + } + }, + }; +} + +/** + * Merges live query results with previously-seen entries to prevent + * entries from disappearing mid-session. + * + * @param liveEntries - Entries from useLiveInfiniteQuery (filtered by where clause) + * @param viewCollection - The view collection (has ALL fetched entries, even state-changed ones) + * @param filterKey - Changes on navigation, resetting the seen set + * @param sortDescending - Whether to sort newest first (true) or oldest first (false) + */ +export function useStableEntryList( + liveEntries: SortedEntryListItem[], + viewCollection: ViewEntriesCollection, + filterKey: string, + sortDescending: boolean +): SortedEntryListItem[] { + // Stable store per component instance — reset + recreate when filterKey changes + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate store on filter change + const store = useMemo(() => createSeenIdsStore(), [filterKey]); + + // Subscribe to seen IDs changes + const seenIds = useSyncExternalStore(store.subscribe, store.getSnapshot, () => new Set()); + + // Build the stable entry list by merging live entries with previously-seen ones + const stableEntries = useMemo(() => { + const liveIds = new Set(liveEntries.map((e) => e.id)); + const result: SortedEntryListItem[] = [...liveEntries]; + + // Add back previously-seen entries that no longer match the filter + for (const id of seenIds) { + if (!liveIds.has(id)) { + const entry = viewCollection.get(id) as SortedEntryListItem | undefined; + if (entry) { + result.push(entry); + } + } + } + + // Re-sort to maintain consistent ordering + if (sortDescending) { + result.sort((a, b) => b._sortMs - a._sortMs || b.id.localeCompare(a.id)); + } else { + result.sort((a, b) => a._sortMs - b._sortMs || a.id.localeCompare(b.id)); + } + + return result; + }, [liveEntries, viewCollection, seenIds, sortDescending]); + + // Schedule recording of current entries as seen (runs after render via microtask) + store.scheduleSeen(stableEntries.map((e) => e.id)); + + return stableEntries; +} diff --git a/src/lib/hooks/useTagSubscriptionsCollection.ts b/src/lib/hooks/useTagSubscriptionsCollection.ts new file mode 100644 index 00000000..70e93996 --- /dev/null +++ b/src/lib/hooks/useTagSubscriptionsCollection.ts @@ -0,0 +1,46 @@ +/** + * useTagSubscriptionsCollection Hook + * + * Creates and manages a per-tag on-demand subscriptions collection. + * The collection fetches pages from the server as the user scrolls + * in the sidebar tag section. + * + * A new collection is created when the tag/uncategorized filter changes. + */ + +"use client"; + +import { useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useVanillaClient } from "@/lib/trpc/vanilla-client"; +import { + createTagSubscriptionsCollection, + stableTagFilterKey, + type TagSubscriptionFilters, +} from "@/lib/collections/subscriptions"; + +/** + * Creates an on-demand subscriptions collection for a tag section. + * + * @param filters - Tag/uncategorized filter and page limit + * @returns The on-demand collection instance + */ +export function useTagSubscriptionsCollection(filters: TagSubscriptionFilters) { + const queryClient = useQueryClient(); + const vanillaClient = useVanillaClient(); + + const filterKey = useMemo(() => stableTagFilterKey(filters), [filters]); + + const collection = useMemo( + () => + createTagSubscriptionsCollection( + queryClient, + (params) => vanillaClient.subscriptions.list.query(params), + filters + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate on filter change + [filterKey, queryClient, vanillaClient] + ); + + return { collection, filterKey }; +} diff --git a/src/lib/hooks/useViewEntriesCollection.ts b/src/lib/hooks/useViewEntriesCollection.ts new file mode 100644 index 00000000..0b35969e --- /dev/null +++ b/src/lib/hooks/useViewEntriesCollection.ts @@ -0,0 +1,74 @@ +/** + * useViewEntriesCollection Hook + * + * Creates and manages a per-view on-demand entries collection. + * The collection fetches pages from the server as the user scrolls, + * bridging TanStack DB's offset-based loading to our cursor-based API. + * + * A new collection is created when the filter set changes (route navigation). + * The old collection is cleaned up when its subscriber count drops to 0. + * + * Also registers the collection as `activeViewCollection` on the Collections + * object so mutations and SSE handlers can write to it. + */ + +"use client"; + +import { useEffect, useMemo } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useVanillaClient } from "@/lib/trpc/vanilla-client"; +import { useCollections } from "@/lib/collections/context"; +import { + createViewEntriesCollection, + stableFilterKey, + type EntriesViewFilters, +} from "@/lib/collections/entries"; + +/** + * Creates an on-demand entries collection for the current view filters. + * Recreates the collection when filters change (new route/filter combination). + * + * Registers the collection as `activeViewCollection` on the shared Collections + * object so mutations and SSE event handlers can propagate state changes. + * + * @param filters - The active filter set for this view + * @returns The on-demand collection instance and its filter key + */ +export function useViewEntriesCollection(filters: EntriesViewFilters) { + const queryClient = useQueryClient(); + const vanillaClient = useVanillaClient(); + const collections = useCollections(); + + // Stable key for memoization — only recreate collection when filters actually change + const filterKey = useMemo(() => stableFilterKey(filters), [filters]); + + const collection = useMemo( + () => + createViewEntriesCollection( + queryClient, + (params) => vanillaClient.entries.list.query(params), + filters + ), + // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: recreate on filter change + [filterKey, queryClient, vanillaClient] + ); + + // Register as active view collection for mutations/SSE writes, + // and set invalidateActiveView to invalidate this collection's backing queries + useEffect(() => { + collections.activeViewCollection = collection; + const queryKey = ["entries-view", filterKey]; + collections.invalidateActiveView = () => { + queryClient.invalidateQueries({ queryKey }); + }; + return () => { + // Only clear if we're still the active one (avoid race with new mount) + if (collections.activeViewCollection === collection) { + collections.activeViewCollection = null; + collections.invalidateActiveView = () => {}; + } + }; + }, [collections, collection, queryClient, filterKey]); + + return { collection, filterKey }; +} diff --git a/src/lib/trpc/provider.tsx b/src/lib/trpc/provider.tsx index 658e2f14..e5a69940 100644 --- a/src/lib/trpc/provider.tsx +++ b/src/lib/trpc/provider.tsx @@ -2,6 +2,7 @@ * tRPC Provider * * Wraps the application with React Query and tRPC providers. + * Also initializes TanStack DB collections for normalized client-side state. * This must be used at the root of the app for tRPC hooks to work. */ @@ -9,10 +10,14 @@ import { useState, useEffect, type ReactNode } from "react"; import { QueryClientProvider } from "@tanstack/react-query"; -import { httpBatchLink, TRPCClientError } from "@trpc/client"; +import { createTRPCClient, httpBatchLink, TRPCClientError } from "@trpc/client"; import superjson from "superjson"; import { trpc } from "./client"; import { getQueryClient } from "./query-client"; +import type { AppRouter } from "@/server/trpc/root"; +import { createCollections } from "@/lib/collections"; +import { CollectionsProvider } from "@/lib/collections/context"; +import { VanillaClientProvider, type VanillaClient } from "./vanilla-client"; /** * Check if an error is a tRPC UNAUTHORIZED error indicating invalid session. @@ -81,9 +86,28 @@ interface TRPCProviderProps { children: ReactNode; } +/** + * Creates the shared httpBatchLink configuration. + * Used by both the React tRPC client and the vanilla tRPC client. + */ +function createBatchLink() { + return httpBatchLink({ + url: `${getBaseUrl()}/api/trpc`, + transformer: superjson, + // Include credentials for cookie-based auth + fetch(url, options) { + return fetch(url, { + ...options, + credentials: "include", + }); + }, + }); +} + /** * TRPC Provider component. * Wrap your app with this to enable tRPC hooks. + * Also initializes TanStack DB collections for normalized client-side state. * * @example * ```tsx @@ -131,27 +155,45 @@ export function TRPCProvider({ children }: TRPCProviderProps) { }; }, [queryClient]); + // Create the React tRPC client (for hooks) const [trpcClient] = useState(() => trpc.createClient({ - links: [ - httpBatchLink({ - url: `${getBaseUrl()}/api/trpc`, - transformer: superjson, - // Include credentials for cookie-based auth - fetch(url, options) { - return fetch(url, { - ...options, - credentials: "include", - }); - }, - }), - ], + links: [createBatchLink()], + }) + ); + + // Create a vanilla tRPC client (for collection queryFn calls) + // Exposed via VanillaClientProvider for use by on-demand collection hooks + const [vanillaClient] = useState(() => + createTRPCClient({ + links: [createBatchLink()], }) ); + // Initialize TanStack DB collections and query cache subscription. + // Both are stored together so the cleanup function is accessible for teardown. + // Entry counts and uncategorized counts are seeded from prefetched data and + // kept in sync via query cache subscriptions inside createCollections. + const [{ collections, cleanup: collectionsCleanup }] = useState(() => + createCollections(queryClient, { + fetchTagsAndUncategorized: () => vanillaClient.tags.list.query(), + }) + ); + + // Clean up the query cache subscription when the provider unmounts + useEffect(() => { + return () => { + collectionsCleanup(); + }; + }, [collectionsCleanup]); + return ( - {children} + + + {children} + + ); } diff --git a/src/lib/trpc/vanilla-client.ts b/src/lib/trpc/vanilla-client.ts new file mode 100644 index 00000000..d260e4a4 --- /dev/null +++ b/src/lib/trpc/vanilla-client.ts @@ -0,0 +1,31 @@ +/** + * Vanilla tRPC Client Context + * + * Provides the vanilla (non-React) tRPC client to components that need + * to make tRPC calls outside of React hooks — specifically for + * TanStack DB collection queryFn functions. + */ + +"use client"; + +import { createContext, useContext } from "react"; +import type { createTRPCClient } from "@trpc/client"; +import type { AppRouter } from "@/server/trpc/root"; + +export type VanillaClient = ReturnType>; + +const VanillaClientContext = createContext(null); + +/** + * Access the vanilla tRPC client from within components. + * Must be used inside TRPCProvider. + */ +export function useVanillaClient(): VanillaClient { + const client = useContext(VanillaClientContext); + if (!client) { + throw new Error("useVanillaClient must be used within TRPCProvider"); + } + return client; +} + +export const VanillaClientProvider = VanillaClientContext.Provider; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index f0407c3a..7823b995 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -609,7 +609,7 @@ export const userEntries = pgTable( // Timestamps for idempotent updates - tracks when each field was last set // Used for conditional updates: only apply if incoming timestamp is newer - readChangedAt: timestamp("read_changed_at", { withTimezone: true }).notNull().defaultNow(), + readChangedAt: timestamp("read_changed_at", { withTimezone: true }), starredChangedAt: timestamp("starred_changed_at", { withTimezone: true }) .notNull() .defaultNow(), @@ -703,7 +703,7 @@ export const visibleEntries = pgView("visible_entries", { predictedScore: real("predicted_score"), // ML-predicted score, nullable predictionConfidence: real("prediction_confidence"), // confidence of prediction, nullable unsubscribeUrl: text("unsubscribe_url"), // extracted from email HTML body - readChangedAt: timestamp("read_changed_at", { withTimezone: true }).notNull(), + readChangedAt: timestamp("read_changed_at", { withTimezone: true }), }).existing(); // ============================================================================ diff --git a/src/server/email/process-inbound.ts b/src/server/email/process-inbound.ts index df84eed2..07041db2 100644 --- a/src/server/email/process-inbound.ts +++ b/src/server/email/process-inbound.ts @@ -341,6 +341,7 @@ export async function processInboundEmail(email: InboundEmail): Promise; } @@ -170,6 +171,12 @@ export interface EntryStateChangedEvent { entryId: string; read: boolean; starred: boolean; + /** Subscription ID for count delta computation (null for saved/orphaned entries) */ + subscriptionId: string | null; + /** Previous read state before this change, for computing count deltas */ + previousRead: boolean; + /** Previous starred state before this change, for computing count deltas */ + previousStarred: boolean; timestamp: string; /** Database updated_at for cursor tracking (entries cursor) */ updatedAt: string; @@ -326,7 +333,7 @@ export async function publishNewEntry( feedId: string, entryId: string, updatedAt: Date, - feedType?: "web" | "email" | "saved" + feedType: "web" | "email" | "saved" ): Promise { const event: NewEntryEvent = { type: "new_entry", @@ -545,6 +552,9 @@ export async function publishImportCompleted( * @param read - Current read state * @param starred - Current starred state * @param updatedAt - The database updated_at timestamp for cursor tracking + * @param subscriptionId - The subscription ID (null for saved/orphaned entries) + * @param previousRead - The read state before this change + * @param previousStarred - The starred state before this change * @returns The number of subscribers that received the message (0 if Redis unavailable) */ export async function publishEntryStateChanged( @@ -552,7 +562,10 @@ export async function publishEntryStateChanged( entryId: string, read: boolean, starred: boolean, - updatedAt: Date + updatedAt: Date, + subscriptionId: string | null, + previousRead: boolean, + previousStarred: boolean ): Promise { const client = getPublisherClient(); if (!client) { @@ -564,6 +577,9 @@ export async function publishEntryStateChanged( entryId, read, starred, + subscriptionId, + previousRead, + previousStarred, timestamp: new Date().toISOString(), updatedAt: updatedAt.toISOString(), }; @@ -785,6 +801,7 @@ export function parseUserEvent(message: string): UserEvent | null { (sub.customTitle !== null && typeof sub.customTitle !== "string") || typeof sub.subscribedAt !== "string" || typeof sub.unreadCount !== "number" || + typeof sub.totalCount !== "number" || !Array.isArray(sub.tags) ) { return null; @@ -815,6 +832,7 @@ export function parseUserEvent(message: string): UserEvent | null { customTitle: sub.customTitle as string | null, subscribedAt: sub.subscribedAt, unreadCount: sub.unreadCount, + totalCount: sub.totalCount as number, tags: sub.tags as Array<{ id: string; name: string; color: string | null }>, }, feed: { @@ -903,7 +921,9 @@ export function parseUserEvent(message: string): UserEvent | null { typeof event.read === "boolean" && typeof event.starred === "boolean" && typeof event.timestamp === "string" && - typeof event.updatedAt === "string" + typeof event.updatedAt === "string" && + typeof event.previousRead === "boolean" && + typeof event.previousStarred === "boolean" ) { return { type: "entry_state_changed", @@ -911,6 +931,9 @@ export function parseUserEvent(message: string): UserEvent | null { entryId: event.entryId, read: event.read, starred: event.starred, + subscriptionId: typeof event.subscriptionId === "string" ? event.subscriptionId : null, + previousRead: event.previousRead, + previousStarred: event.previousStarred, timestamp: event.timestamp, updatedAt: event.updatedAt, }; diff --git a/src/server/services/entries.ts b/src/server/services/entries.ts index 59a18d1a..ed57ff9d 100644 --- a/src/server/services/entries.ts +++ b/src/server/services/entries.ts @@ -390,6 +390,12 @@ export async function listEntries( ? visibleEntries.readChangedAt : sql`COALESCE(${visibleEntries.publishedAt}, ${visibleEntries.fetchedAt})`; + // When sorting by readChanged, only show entries the user has explicitly + // toggled read/unread. readChangedAt is NULL for entries never interacted with. + if (params.sortBy === "readChanged") { + conditions.push(sql`${visibleEntries.readChangedAt} IS NOT NULL`); + } + // Cursor condition if (params.cursor) { const { ts, id } = decodeCursor(params.cursor); @@ -450,7 +456,7 @@ export async function listEntries( const lastEntry = resultEntries[resultEntries.length - 1]; const lastTs = params.sortBy === "readChanged" - ? lastEntry.readChangedAt + ? lastEntry.readChangedAt! // non-null: filtered by IS NOT NULL above : (lastEntry.publishedAt ?? lastEntry.fetchedAt); nextCursor = encodeCursor(lastTs, lastEntry.id); } @@ -651,7 +657,7 @@ export async function markEntriesRead( and( eq(userEntries.userId, userId), inArray(userEntries.entryId, entryIds), - lte(userEntries.readChangedAt, changedAt) + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})` ) ); diff --git a/src/server/services/subscriptions.ts b/src/server/services/subscriptions.ts index 9502962d..b112359e 100644 --- a/src/server/services/subscriptions.ts +++ b/src/server/services/subscriptions.ts @@ -29,6 +29,7 @@ export interface Subscription { siteUrl: string | null; subscribedAt: Date; unreadCount: number; + totalCount: number; tags: Tag[]; fetchFullContent: boolean; } @@ -57,6 +58,20 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { .groupBy(entries.feedId) .as("unread_counts"); + // Subquery to get total entry counts per feed (all user_entries, not just unread) + const totalCountsSubquery = db + .select({ + feedId: entries.feedId, + totalCount: sql`count(*)::int`.as("total_count"), + }) + .from(entries) + .innerJoin( + userEntries, + and(eq(userEntries.entryId, entries.id), eq(userEntries.userId, userId)) + ) + .groupBy(entries.feedId) + .as("total_counts"); + return db .select({ // From user_feeds view - subscription fields @@ -73,6 +88,8 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { siteUrl: userFeeds.siteUrl, // Unread count from subquery unreadCount: sql`COALESCE(${unreadCountsSubquery.unreadCount}, 0)`, + // Total entry count from subquery + totalCount: sql`COALESCE(${totalCountsSubquery.totalCount}, 0)`, // Tags aggregated as JSON array tags: sql>` COALESCE( @@ -85,6 +102,7 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { }) .from(userFeeds) .leftJoin(unreadCountsSubquery, eq(unreadCountsSubquery.feedId, userFeeds.feedId)) + .leftJoin(totalCountsSubquery, eq(totalCountsSubquery.feedId, userFeeds.feedId)) .leftJoin(subscriptionTags, eq(subscriptionTags.subscriptionId, userFeeds.id)) .leftJoin(tags, eq(tags.id, subscriptionTags.tagId)) .groupBy( @@ -98,7 +116,8 @@ export function buildSubscriptionBaseQuery(db: typeof dbType, userId: string) { userFeeds.originalTitle, userFeeds.description, userFeeds.siteUrl, - unreadCountsSubquery.unreadCount + unreadCountsSubquery.unreadCount, + totalCountsSubquery.totalCount ); } @@ -121,6 +140,7 @@ export function formatSubscriptionRow(row: SubscriptionQueryRow): Subscription { siteUrl: row.siteUrl, subscribedAt: row.subscribedAt, unreadCount: row.unreadCount, + totalCount: row.totalCount, tags: row.tags, fetchFullContent: row.fetchFullContent, }; diff --git a/src/server/trpc/routers/entries.ts b/src/server/trpc/routers/entries.ts index 52a13535..8af7b4d5 100644 --- a/src/server/trpc/routers/entries.ts +++ b/src/server/trpc/routers/entries.ts @@ -356,6 +356,7 @@ async function updateEntryStarred( changedAt: Date = new Date() ): Promise<{ id: string; + subscriptionId: string | null; read: boolean; starred: boolean; updatedAt: Date; @@ -390,6 +391,7 @@ async function updateEntryStarred( const result = await ctx.db .select({ id: visibleEntries.id, + subscriptionId: visibleEntries.subscriptionId, read: visibleEntries.read, starred: visibleEntries.starred, updatedAt: visibleEntries.updatedAt, @@ -409,6 +411,7 @@ async function updateEntryStarred( const row = result[0]; return { id: row.id, + subscriptionId: row.subscriptionId, read: row.read, starred: row.starred, updatedAt: row.updatedAt, @@ -634,7 +637,7 @@ export const entriesRouter = createTRPCRouter({ and( eq(userEntries.userId, userId), inArray(userEntries.entryId, entryIds), - lte(userEntries.readChangedAt, changedAt) + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})` ) ); } @@ -684,7 +687,10 @@ export const entriesRouter = createTRPCRouter({ entry.id, entry.read, entry.starred, - entry.updatedAt + entry.updatedAt, + entry.subscriptionId, + !input.read, // previousRead: opposite of target state + entry.starred // previousStarred: unchanged by markRead ).catch(() => { // Ignore publish errors - SSE is best-effort }); @@ -738,7 +744,7 @@ export const entriesRouter = createTRPCRouter({ const conditions: any[] = [ eq(userEntries.userId, userId), eq(userEntries.read, false), - lte(userEntries.readChangedAt, changedAt), + sql`(${userEntries.readChangedAt} IS NULL OR ${userEntries.readChangedAt} <= ${changedAt})`, ]; // Filter by subscriptionId - need to look up feed IDs first for validation @@ -893,11 +899,18 @@ export const entriesRouter = createTRPCRouter({ // Publish entry state change event for multi-tab/device sync // Fire and forget - don't block the response - publishEntryStateChanged(userId, entry.id, entry.read, entry.starred, entry.updatedAt).catch( - () => { - // Ignore publish errors - SSE is best-effort - } - ); + publishEntryStateChanged( + userId, + entry.id, + entry.read, + entry.starred, + entry.updatedAt, + entry.subscriptionId, + entry.read, // previousRead: unchanged by setStarred + !input.starred // previousStarred: opposite of target state + ).catch(() => { + // Ignore publish errors - SSE is best-effort + }); return { entry, counts }; }), diff --git a/src/server/trpc/routers/subscriptions.ts b/src/server/trpc/routers/subscriptions.ts index e884b291..3a514c4a 100644 --- a/src/server/trpc/routers/subscriptions.ts +++ b/src/server/trpc/routers/subscriptions.ts @@ -76,6 +76,7 @@ const subscriptionOutputSchema = z.object({ siteUrl: z.string().nullable(), subscribedAt: z.date(), unreadCount: z.number(), + totalCount: z.number(), tags: z.array(tagOutputSchema), fetchFullContent: z.boolean(), // whether to fetch full article content from URL }); @@ -294,6 +295,7 @@ async function subscribeToExistingFeed( customTitle: null, subscribedAt: result.subscribedAt.toISOString(), unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, }; @@ -319,6 +321,7 @@ async function subscribeToExistingFeed( siteUrl: feedRecord.siteUrl, subscribedAt: result.subscribedAt, unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, fetchFullContent: false, // default for new subscriptions }; @@ -475,6 +478,7 @@ async function subscribeToNewOrUnfetchedFeed( customTitle: null, subscribedAt: result.subscribedAt.toISOString(), unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, }; @@ -500,6 +504,7 @@ async function subscribeToNewOrUnfetchedFeed( siteUrl: feedRecord.siteUrl, subscribedAt: result.subscribedAt, unreadCount: result.unreadCount, + totalCount: result.unreadCount, // at creation time, total === unread tags: [] as Array<{ id: string; name: string; color: string | null }>, fetchFullContent: false, // default for new subscriptions }; @@ -735,6 +740,10 @@ export const subscriptionsRouter = createTRPCRouter({ unreadCount: sql` COUNT(${entries.id}) FILTER (WHERE ${userEntries.read} = false)::int `, + // Total entry count from user_entries + totalCount: sql` + COUNT(${entries.id}) FILTER (WHERE ${userEntries.userId} IS NOT NULL)::int + `, }) .from(feeds) .leftJoin(subscriptionTags, eq(subscriptionTags.subscriptionId, subscription.id)) @@ -755,6 +764,7 @@ export const subscriptionsRouter = createTRPCRouter({ const subscriptionTagsList = result.tags; const unreadCount = result.unreadCount; + const totalCount = result.totalCount; // Return flat format return { @@ -767,6 +777,7 @@ export const subscriptionsRouter = createTRPCRouter({ siteUrl: result.siteUrl, subscribedAt: subscription.subscribedAt, unreadCount, + totalCount, tags: subscriptionTagsList, fetchFullContent: subscription.fetchFullContent, }; diff --git a/src/server/trpc/routers/sync.ts b/src/server/trpc/routers/sync.ts index a9bc06d3..0ddd931c 100644 --- a/src/server/trpc/routers/sync.ts +++ b/src/server/trpc/routers/sync.ts @@ -848,6 +848,30 @@ export const syncRouter = createTRPCRouter({ } } + // Batch-fetch total entry counts for all active subscriptions + const totalBySubscription = new Map(); + if (activeSubscriptions.length > 0) { + const totalResults = await Promise.all( + activeSubscriptions.map(async ({ subscription }) => { + const result = await ctx.db + .select({ count: sql`count(*)::int` }) + .from(userEntries) + .innerJoin(entries, eq(entries.id, userEntries.entryId)) + .where( + and( + eq(userEntries.userId, userId), + sql`${entries.feedId} = ANY(${subscription.feedIds})` + ) + ); + return { subscriptionId: subscription.id, count: result[0]?.count ?? 0 }; + }) + ); + + for (const { subscriptionId, count } of totalResults) { + totalBySubscription.set(subscriptionId, count); + } + } + for (const { subscription, feed } of subscriptionResults) { if (subscription.unsubscribedAt === null) { allEvents.push({ @@ -862,6 +886,7 @@ export const syncRouter = createTRPCRouter({ customTitle: subscription.customTitle, subscribedAt: subscription.subscribedAt.toISOString(), unreadCount: unreadBySubscription.get(subscription.id) ?? 0, + totalCount: totalBySubscription.get(subscription.id) ?? 0, tags: tagsBySubscription.get(subscription.id) ?? [], }, feed: { diff --git a/tests/unit/frontend/cache/operations.test.ts b/tests/unit/frontend/cache/operations.test.ts index e945f4be..bcb22c14 100644 --- a/tests/unit/frontend/cache/operations.test.ts +++ b/tests/unit/frontend/cache/operations.test.ts @@ -1,878 +1,185 @@ /** * Unit tests for cache operations. * - * These tests document the behavior of cache operations without full mocking. - * For more comprehensive testing, see the integration tests. - * - * Note: The cache operations call lower-level helpers that interact with - * tRPC utils in complex ways. These tests focus on high-level behavior - * that can be tested with the mock utils. + * These operations now update TanStack DB collections only (no React Query cache writes). + * Tests verify correct behavior by passing null collections (no-op mode) + * and ensuring functions don't throw. Collection state updates are tested + * via integration tests. */ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { createMockTrpcUtils } from "../../../utils/trpc-mock"; import { - handleEntriesMarkedRead, - handleEntryStarred, - handleEntryUnstarred, handleNewEntry, handleSubscriptionCreated, handleSubscriptionDeleted, - type EntryWithContext, + setCounts, + setBulkCounts, + refreshGlobalCounts, type SubscriptionData, + type UnreadCounts, + type BulkUnreadCounts, } from "@/lib/cache/operations"; -describe("handleEntriesMarkedRead", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("does nothing for empty entries array", () => { - handleEntriesMarkedRead(mockUtils.utils, [], true); - expect(mockUtils.operations).toHaveLength(0); - }); - - it("updates entry read status in entries.get cache", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(setDataOps.length).toBeGreaterThan(0); - }); - - it("updates subscription unread counts", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-2", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on subscriptions.list - const subOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(subOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count for starred entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: true, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on entries.count with starredOnly filter - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates saved unread count for saved entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should have called setData on entries.count with type: "saved" filter - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("handles entries without subscription (saved articles)", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: null, starred: false, type: "saved" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should still update entries.get - const entryOps = mockUtils.operations.filter((op) => op.router === "entries"); - expect(entryOps.length).toBeGreaterThan(0); - }); -}); - -describe("handleEntryStarred", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("updates entry starred status", () => { - handleEntryStarred(mockUtils.utils, "entry-1", false); - - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("updates starred count - total always +1", () => { - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count +1 for unread entry", () => { - handleEntryStarred(mockUtils.utils, "entry-1", false); // unread entry - - // The unread delta should be +1 - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("does not change starred unread count for read entry", () => { - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - // The unread delta should be 0 (only total changes) - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); -}); - -describe("handleEntryUnstarred", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("updates entry starred status to false", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", false); - - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("updates starred count - total always -1", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", true); // read entry - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("updates starred unread count -1 for unread entry", () => { - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - // The unread delta should be -1 - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); -}); +function createSubscription(overrides: Partial = {}): SubscriptionData { + return { + id: "sub-1", + type: "web", + url: "https://example.com/feed.xml", + title: "Example Feed", + originalTitle: "Example Feed", + description: "An example feed", + siteUrl: "https://example.com", + subscribedAt: new Date("2024-01-01"), + unreadCount: 0, + totalCount: 0, + tags: [], + fetchFullContent: false, + ...overrides, + }; +} describe("handleNewEntry", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); + it("handles web entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + // Should not throw when collections is null + handleNewEntry(mockUtils.utils, "sub-1", "web", null); }); - it("increments subscription unread count", () => { - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const subOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(subOps.length).toBeGreaterThan(0); + it("handles saved entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, "sub-1", "saved", null); }); - it("increments saved unread count for saved entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "saved"); - - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("increments All Articles count but not saved count for web entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - // For web entries, we update All Articles count but not saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - // Only 1 operation: All Articles count (no saved count for web entries) - expect(countOps.length).toBe(1); + it("handles email entry without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, "sub-1", "email", null); }); - it("increments All Articles count but not saved count for email entries", () => { - handleNewEntry(mockUtils.utils, "sub-1", "email"); - - // Email entries update All Articles count but don't affect saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - // Only 1 operation: All Articles count (no saved count for email entries) - expect(countOps.length).toBe(1); + it("handles null subscriptionId", () => { + const mockUtils = createMockTrpcUtils(); + handleNewEntry(mockUtils.utils, null, "web", null); }); }); describe("handleSubscriptionCreated", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - function createSubscription(overrides: Partial = {}): SubscriptionData { - return { - id: "sub-1", - type: "web", - url: "https://example.com/feed.xml", - title: "Example Feed", - originalTitle: "Example Feed", - description: "An example feed", - siteUrl: "https://example.com", - subscribedAt: new Date("2024-01-01"), - unreadCount: 0, - tags: [], - fetchFullContent: false, - ...overrides, - }; - } - - it("adds subscription to subscriptions.list cache", () => { + it("handles subscription without collections (no-op)", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription(); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); - it("directly updates tags.list cache", () => { - const subscription = createSubscription(); - handleSubscriptionCreated(mockUtils.utils, subscription); - - // Should use setData instead of invalidate for direct cache update - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "tags" && op.procedure === "list" - ); - expect(setDataOps.length).toBe(1); - }); - - it("adds subscription with tags to cache", () => { + it("handles subscription with tags without collections", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ tags: [ { id: "tag-1", name: "News", color: "#ff0000" }, { id: "tag-2", name: "Tech", color: null }, ], }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); - it("adds subscription with non-zero unread count", () => { + it("handles subscription with non-zero unread count", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ unreadCount: 42 }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles email subscription type", () => { - const subscription = createSubscription({ - type: "email", - url: null, - }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + const mockUtils = createMockTrpcUtils(); + const subscription = createSubscription({ type: "email", url: null }); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles saved subscription type", () => { - const subscription = createSubscription({ - type: "saved", - url: null, - }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); + const mockUtils = createMockTrpcUtils(); + const subscription = createSubscription({ type: "saved", url: null }); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); it("handles subscription with null optional fields", () => { + const mockUtils = createMockTrpcUtils(); const subscription = createSubscription({ title: null, description: null, siteUrl: null, }); - handleSubscriptionCreated(mockUtils.utils, subscription); - - // Should not throw - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBeGreaterThan(0); - }); - - it("does not duplicate subscription if already in cache", () => { - const subscription = createSubscription(); - - // Set up cache with existing subscription - mockUtils.setCache("subscriptions", "list", undefined, { - items: [subscription], - }); - - handleSubscriptionCreated(mockUtils.utils, subscription); - - // The setData is still called, but the updater function should not add a duplicate - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: SubscriptionData[]; - }; - expect(cachedData.items.length).toBe(1); + handleSubscriptionCreated(mockUtils.utils, subscription, null); }); }); describe("handleSubscriptionDeleted", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - it("removes subscription from subscriptions.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); + it("does not perform React Query invalidations (uses collection writes instead)", () => { + const mockUtils = createMockTrpcUtils(); + handleSubscriptionDeleted(mockUtils.utils, "sub-1", null); - const setDataOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "subscriptions" && op.procedure === "list" - ); - expect(setDataOps.length).toBe(1); + // With TanStack DB migration, handleSubscriptionDeleted uses collection writes + // and invalidateActiveView() instead of React Query invalidations + expect(mockUtils.operations.length).toBe(0); }); - it("invalidates entries.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const invalidateOps = mockUtils.operations.filter( - (op) => op.type === "invalidate" && op.router === "entries" && op.procedure === "list" - ); - expect(invalidateOps.length).toBe(1); - }); - - it("invalidates tags.list cache", () => { - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const invalidateOps = mockUtils.operations.filter( - (op) => op.type === "invalidate" && op.router === "tags" && op.procedure === "list" - ); - expect(invalidateOps.length).toBe(1); - }); - - it("removes subscription from cache when present", () => { - // Set up cache with subscriptions - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 5, tags: [] }, - { id: "sub-2", unreadCount: 10, tags: [] }, - ], - }); - - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; - }; - expect(cachedData.items.length).toBe(1); - expect(cachedData.items[0].id).toBe("sub-2"); + it("handles deletion without collections (no-op for collection writes)", () => { + const mockUtils = createMockTrpcUtils(); + handleSubscriptionDeleted(mockUtils.utils, "sub-1", null); }); +}); - it("handles deletion of non-existent subscription gracefully", () => { - // Set up cache with different subscription - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-2", unreadCount: 10, tags: [] }], - }); - - // Should not throw - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; +describe("setCounts", () => { + it("handles counts without collections (no-op)", () => { + const counts: UnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscription: { id: "sub-1", unread: 3 }, + tags: [{ id: "tag-1", unread: 5 }], + uncategorized: { unread: 2 }, }; - expect(cachedData.items.length).toBe(1); + setCounts(null, counts); }); - it("handles deletion when cache is empty", () => { - mockUtils.setCache("subscriptions", "list", undefined, { items: [] }); - - // Should not throw - handleSubscriptionDeleted(mockUtils.utils, "sub-1"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string }>; + it("handles counts without optional fields", () => { + const counts: UnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, }; - expect(cachedData.items.length).toBe(0); + setCounts(null, counts); }); }); -describe("cache update logic verification", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - describe("handleEntriesMarkedRead cache state updates", () => { - it("decrements unread count when marking entries as read", () => { - // Set up initial cache state - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(3); - }); - - it("increments unread count when marking entries as unread", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 3, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, false); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(4); - }); - - it("updates multiple subscriptions independently", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 10, tags: [] }, - { id: "sub-2", unreadCount: 5, tags: [] }, - ], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-2", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(8); // sub-1: 10 - 2 - expect(cachedData.items[1].unreadCount).toBe(4); // sub-2: 5 - 1 - }); - - it("does not decrement unread count below zero", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 1, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-3", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(0); // Clamped to 0 - }); - - it("updates tag unread counts based on subscription tags", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 5, tags: [{ id: "tag-1", name: "News", color: null }] }, - ], - }); - mockUtils.setCache("tags", "list", undefined, { - items: [{ id: "tag-1", name: "News", color: null, unreadCount: 10 }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const tagData = mockUtils.getCache("tags", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(tagData.items[0].unreadCount).toBe(9); - }); - }); - - describe("handleNewEntry cache state updates", () => { - it("increments subscription unread count", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(6); - }); - - it("increments tag unread count for subscription with tags", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { - id: "sub-1", - unreadCount: 5, - tags: [{ id: "tag-1", name: "News", color: null }], - }, - ], - }); - mockUtils.setCache("tags", "list", undefined, { - items: [{ id: "tag-1", name: "News", color: null, unreadCount: 10 }], - }); - - handleNewEntry(mockUtils.utils, "sub-1", "web"); - - const tagData = mockUtils.getCache("tags", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(tagData.items[0].unreadCount).toBe(11); - }); - }); - - describe("handleEntryStarred cache state updates", () => { - it("updates starred count total and unread for unread entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryStarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(6); // +1 - expect(countData.unread).toBe(3); // +1 - }); - - it("updates only starred count total for read entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryStarred(mockUtils.utils, "entry-1", true); // read entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(6); // +1 - expect(countData.unread).toBe(2); // unchanged - }); +describe("setBulkCounts", () => { + it("handles bulk counts without collections (no-op)", () => { + const counts: BulkUnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscriptions: [ + { id: "sub-1", unread: 3 }, + { id: "sub-2", unread: 7 }, + ], + tags: [{ id: "tag-1", unread: 5 }], + uncategorized: { unread: 2 }, + }; + setBulkCounts(null, counts); }); - describe("handleEntryUnstarred cache state updates", () => { - it("decrements starred count total and unread for unread entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(4); // -1 - expect(countData.unread).toBe(1); // -1 - }); - - it("decrements only starred count total for read entry", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 5, unread: 2 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", true); // read entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(4); // -1 - expect(countData.unread).toBe(2); // unchanged - }); - - it("does not decrement below zero", () => { - mockUtils.setCache("entries", "count", { starredOnly: true }, { total: 0, unread: 0 }); - - handleEntryUnstarred(mockUtils.utils, "entry-1", false); // unread entry - - const countData = mockUtils.getCache("entries", "count", { starredOnly: true }) as { - total: number; - unread: number; - }; - expect(countData.total).toBe(0); // clamped - expect(countData.unread).toBe(0); // clamped - }); + it("handles bulk counts without uncategorized", () => { + const counts: BulkUnreadCounts = { + all: { total: 100, unread: 50 }, + starred: { total: 10, unread: 5 }, + saved: { total: 20, unread: 10 }, + subscriptions: [], + tags: [], + }; + setBulkCounts(null, counts); }); }); -describe("edge cases", () => { - let mockUtils: ReturnType; - - beforeEach(() => { - mockUtils = createMockTrpcUtils(); - }); - - describe("null/undefined handling", () => { - it("handleEntriesMarkedRead handles entries with null subscriptionId", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: null, starred: false, type: "saved" }, - { id: "entry-2", subscriptionId: null, starred: true, type: "saved" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should still update entries.get - const entryOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "get" - ); - expect(entryOps.length).toBeGreaterThan(0); - }); - - it("handleEntriesMarkedRead handles mixed null and non-null subscriptionIds", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: null, starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - // Only sub-1 should be decremented - expect(cachedData.items[0].unreadCount).toBe(4); - }); - - it("handleEntriesMarkedRead handles when subscriptions cache is undefined", () => { - // Don't set up subscriptions cache - leave it undefined - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - }); - - it("handleNewEntry handles when subscriptions cache is undefined", () => { - // Don't set up subscriptions cache - leave it undefined - // Should not throw - handleNewEntry(mockUtils.utils, "sub-1", "web"); - }); - }); - - describe("empty arrays", () => { - it("handleEntriesMarkedRead with empty array does nothing", () => { - handleEntriesMarkedRead(mockUtils.utils, [], true); - expect(mockUtils.operations).toHaveLength(0); - }); - - it("handleEntriesMarkedRead with empty array does not update subscriptions", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - handleEntriesMarkedRead(mockUtils.utils, [], true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(5); // unchanged - }); - }); - - describe("boundary conditions", () => { - it("handles very large unread count", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 999999, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(999998); - }); - - it("handles many entries being marked at once", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 100, tags: [] }], - }); - - const entries: EntryWithContext[] = Array.from({ length: 50 }, (_, i) => ({ - id: `entry-${i}`, - subscriptionId: "sub-1", - starred: false, - type: "web" as const, - })); - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(50); - }); - - it("handles entry belonging to non-existent subscription", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [{ id: "sub-1", unreadCount: 5, tags: [] }], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "non-existent", starred: false, type: "web" }, - ]; - - // Should not throw - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // sub-1 should be unchanged - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(5); - }); - }); - - describe("all entry types", () => { - it("handles web type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should not update saved count - const countOps = mockUtils.operations.filter( - (op) => - op.type === "setData" && - op.router === "entries" && - op.procedure === "count" && - JSON.stringify((op.input as { type?: string })?.type) === '"saved"' - ); - expect(countOps.length).toBe(0); - }); - - it("handles email type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "email" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should not update saved count - const countOps = mockUtils.operations.filter( - (op) => - op.type === "setData" && - op.router === "entries" && - op.procedure === "count" && - JSON.stringify((op.input as { type?: string })?.type) === '"saved"' - ); - expect(countOps.length).toBe(0); - }); - - it("handles saved type entries", () => { - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - // Should update saved count - const countOps = mockUtils.operations.filter( - (op) => op.type === "setData" && op.router === "entries" && op.procedure === "count" - ); - expect(countOps.length).toBeGreaterThan(0); - }); - - it("handles mix of all entry types", () => { - mockUtils.setCache("subscriptions", "list", undefined, { - items: [ - { id: "sub-1", unreadCount: 10, tags: [] }, - { id: "sub-2", unreadCount: 5, tags: [] }, - { id: "sub-3", unreadCount: 3, tags: [] }, - ], - }); - - const entries: EntryWithContext[] = [ - { id: "entry-1", subscriptionId: "sub-1", starred: false, type: "web" }, - { id: "entry-2", subscriptionId: "sub-2", starred: true, type: "email" }, - { id: "entry-3", subscriptionId: "sub-3", starred: false, type: "saved" }, - ]; - - handleEntriesMarkedRead(mockUtils.utils, entries, true); - - const cachedData = mockUtils.getCache("subscriptions", "list", undefined) as { - items: Array<{ id: string; unreadCount: number }>; - }; - expect(cachedData.items[0].unreadCount).toBe(9); // sub-1 - expect(cachedData.items[1].unreadCount).toBe(4); // sub-2 - expect(cachedData.items[2].unreadCount).toBe(2); // sub-3 - }); +describe("refreshGlobalCounts", () => { + it("does nothing with null collections", async () => { + const mockUtils = createMockTrpcUtils(); + await refreshGlobalCounts(mockUtils.utils, null); + // No fetch operations should have been issued + expect(mockUtils.operations).toHaveLength(0); }); }); diff --git a/tests/unit/frontend/collections/count-sync.test.ts b/tests/unit/frontend/collections/count-sync.test.ts new file mode 100644 index 00000000..f1b66d97 --- /dev/null +++ b/tests/unit/frontend/collections/count-sync.test.ts @@ -0,0 +1,344 @@ +/** + * Unit tests for count syncing logic in createCollections. + * + * Tests verify that createCollections properly: + * 1. Seeds counts from SSR-prefetched data when available + * 2. Seeds uncategorized counts from prefetched tags.list data + * 3. The query cache subscription syncs counts when entries.count queries + * resolve after initialization + * + * Uses a real QueryClient and real TanStack DB collections. + */ + +import { describe, it, expect, afterEach } from "vitest"; +import { QueryClient } from "@tanstack/react-query"; +import { createCollections, type CreateCollectionsResult } from "@/lib/collections"; +import { TRPC_TAGS_LIST_KEY, type TagsListResponse } from "@/lib/collections/tags"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** tRPC query key for tags.list (no input) */ +const TAGS_LIST_KEY = TRPC_TAGS_LIST_KEY as unknown as readonly unknown[]; + +/** Build a tRPC entries.count query key with optional input */ +function entriesCountKey(input?: Record): readonly unknown[] { + return [["entries", "count"], { input: input ?? {}, type: "query" }]; +} + +/** Build a mock TagsListResponse */ +function mockTagsListResponse(overrides?: Partial): TagsListResponse { + return { + items: [], + uncategorized: { feedCount: 5, unreadCount: 3 }, + ...overrides, + }; +} + +/** + * Creates a test QueryClient, calls createCollections, and returns both. + * The cleanup function is tracked for proper teardown. + */ +function setup(prefetch?: (queryClient: QueryClient) => void): { + queryClient: QueryClient; + result: CreateCollectionsResult; +} { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Disable retries and refetching for tests + retry: false, + staleTime: Infinity, + }, + }, + }); + + if (prefetch) { + prefetch(queryClient); + } + + const result = createCollections(queryClient, { + fetchTagsAndUncategorized: async () => mockTagsListResponse(), + }); + + return { queryClient, result }; +} + +// Track created collections for cleanup +const createdResults: CreateCollectionsResult[] = []; + +function setupTracked(prefetch?: (queryClient: QueryClient) => void): ReturnType { + const s = setup(prefetch); + createdResults.push(s.result); + return s; +} + +afterEach(() => { + // Clean up all created collections + for (const result of createdResults) { + result.cleanup(); + } + createdResults.length = 0; +}); + +// ============================================================================ +// SSR Prefetch Seeding - Uncategorized Counts +// ============================================================================ + +describe("SSR seeding: uncategorized counts from tags.list", () => { + it("seeds uncategorized counts when tags.list data is prefetched", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 12, unreadCount: 7 }, + }) + ); + }); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeDefined(); + expect(uncategorized?.total).toBe(12); + expect(uncategorized?.unread).toBe(7); + }); + + it("does not seed uncategorized counts when no tags.list data is prefetched", () => { + const { result } = setupTracked(); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeUndefined(); + }); +}); + +// ============================================================================ +// SSR Prefetch Seeding - Entry Counts +// ============================================================================ + +describe("SSR seeding: entry counts from entries.count", () => { + it("seeds 'all' count from prefetched entries.count with empty input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + }); + + const allCount = result.collections.counts.get("all"); + expect(allCount).toBeDefined(); + expect(allCount?.total).toBe(100); + expect(allCount?.unread).toBe(50); + }); + + it("seeds 'starred' count from prefetched entries.count with starredOnly input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({ starredOnly: true }), { total: 10, unread: 5 }); + }); + + const starredCount = result.collections.counts.get("starred"); + expect(starredCount).toBeDefined(); + expect(starredCount?.total).toBe(10); + expect(starredCount?.unread).toBe(5); + }); + + it("seeds 'saved' count from prefetched entries.count with type=saved input", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({ type: "saved" }), { total: 20, unread: 10 }); + }); + + const savedCount = result.collections.counts.get("saved"); + expect(savedCount).toBeDefined(); + expect(savedCount?.total).toBe(20); + expect(savedCount?.unread).toBe(10); + }); + + it("seeds multiple count categories from prefetched data", () => { + const { result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + qc.setQueryData(entriesCountKey({ starredOnly: true }), { total: 10, unread: 5 }); + qc.setQueryData(entriesCountKey({ type: "saved" }), { total: 20, unread: 10 }); + }); + + expect(result.collections.counts.get("all")?.total).toBe(100); + expect(result.collections.counts.get("starred")?.total).toBe(10); + expect(result.collections.counts.get("saved")?.total).toBe(20); + }); + + it("ignores entries.count queries with unrecognized input (e.g. subscriptionId filter)", () => { + const { result } = setupTracked((qc) => { + // This has a subscriptionId filter, which doesn't map to all/starred/saved + qc.setQueryData(entriesCountKey({ subscriptionId: "sub-1" }), { total: 30, unread: 15 }); + }); + + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); + + it("does not seed any counts when no entries.count data is prefetched", () => { + const { result } = setupTracked(); + + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); +}); + +// ============================================================================ +// Query Cache Subscription - Async Count Updates +// ============================================================================ + +describe("query cache subscription: entries.count updates", () => { + it("syncs 'all' count when entries.count query succeeds after initialization", async () => { + const { queryClient, result } = setupTracked(); + + // Simulate a query resolving after createCollections was called + // by setting query data and triggering the cache subscription + queryClient.setQueryData(entriesCountKey({}), { total: 200, unread: 80 }); + + // The cache subscription fires synchronously in setQueryData + const allCount = result.collections.counts.get("all"); + expect(allCount).toBeDefined(); + expect(allCount?.total).toBe(200); + expect(allCount?.unread).toBe(80); + }); + + it("syncs 'starred' count when entries.count query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ starredOnly: true }), { total: 15, unread: 8 }); + + const starredCount = result.collections.counts.get("starred"); + expect(starredCount).toBeDefined(); + expect(starredCount?.total).toBe(15); + expect(starredCount?.unread).toBe(8); + }); + + it("syncs 'saved' count when entries.count query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ type: "saved" }), { total: 25, unread: 12 }); + + const savedCount = result.collections.counts.get("saved"); + expect(savedCount).toBeDefined(); + expect(savedCount?.total).toBe(25); + expect(savedCount?.unread).toBe(12); + }); + + it("updates existing count when new data arrives", () => { + const { queryClient, result } = setupTracked((qc) => { + qc.setQueryData(entriesCountKey({}), { total: 100, unread: 50 }); + }); + + expect(result.collections.counts.get("all")?.total).toBe(100); + + // Simulate a refetch with updated data + queryClient.setQueryData(entriesCountKey({}), { total: 120, unread: 60 }); + + expect(result.collections.counts.get("all")?.total).toBe(120); + expect(result.collections.counts.get("all")?.unread).toBe(60); + }); + + it("ignores entries.count queries with unrecognized input", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData(entriesCountKey({ subscriptionId: "sub-1" }), { + total: 30, + unread: 15, + }); + + // Should not have created any known count keys + expect(result.collections.counts.has("all")).toBe(false); + expect(result.collections.counts.has("starred")).toBe(false); + expect(result.collections.counts.has("saved")).toBe(false); + }); +}); + +// ============================================================================ +// Query Cache Subscription - Uncategorized Count Updates +// ============================================================================ + +describe("query cache subscription: uncategorized counts from tags.list", () => { + it("syncs uncategorized counts when tags.list query succeeds after initialization", () => { + const { queryClient, result } = setupTracked(); + + queryClient.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 8, unreadCount: 4 }, + }) + ); + + const uncategorized = result.collections.counts.get("uncategorized"); + expect(uncategorized).toBeDefined(); + expect(uncategorized?.total).toBe(8); + expect(uncategorized?.unread).toBe(4); + }); + + it("updates existing uncategorized counts on tags.list refetch", () => { + const { queryClient, result } = setupTracked((qc) => { + qc.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 5, unreadCount: 3 }, + }) + ); + }); + + expect(result.collections.counts.get("uncategorized")?.total).toBe(5); + + // Simulate refetch with updated data + queryClient.setQueryData( + TAGS_LIST_KEY, + mockTagsListResponse({ + uncategorized: { feedCount: 6, unreadCount: 2 }, + }) + ); + + expect(result.collections.counts.get("uncategorized")?.total).toBe(6); + expect(result.collections.counts.get("uncategorized")?.unread).toBe(2); + }); +}); + +// ============================================================================ +// Cleanup +// ============================================================================ + +describe("cleanup", () => { + it("stops syncing counts after cleanup is called", () => { + const { queryClient, result } = setupTracked(); + + // Calling cleanup should unsubscribe from query cache + result.cleanup(); + + // Now set data -- should NOT sync to counts collection + queryClient.setQueryData(entriesCountKey({}), { total: 999, unread: 999 }); + + expect(result.collections.counts.has("all")).toBe(false); + }); +}); + +// ============================================================================ +// Collections structure +// ============================================================================ + +describe("createCollections structure", () => { + it("creates all expected collection types", () => { + const { result } = setupTracked(); + + expect(result.collections.subscriptions).toBeDefined(); + expect(result.collections.tags).toBeDefined(); + expect(result.collections.entries).toBeDefined(); + expect(result.collections.counts).toBeDefined(); + expect(result.collections.activeViewCollection).toBeNull(); + expect(typeof result.collections.invalidateActiveView).toBe("function"); + }); + + it("returns a cleanup function", () => { + const { result } = setupTracked(); + expect(typeof result.cleanup).toBe("function"); + }); + + it("invalidateActiveView is a no-op by default", () => { + const { result } = setupTracked(); + // Should not throw + result.collections.invalidateActiveView(); + }); +}); diff --git a/tests/unit/frontend/collections/writes.test.ts b/tests/unit/frontend/collections/writes.test.ts new file mode 100644 index 00000000..f3fa49df --- /dev/null +++ b/tests/unit/frontend/collections/writes.test.ts @@ -0,0 +1,889 @@ +/** + * Unit tests for TanStack DB collection write functions. + * + * Tests use real TanStack DB collections (local-only) to verify that + * collection writes behave correctly for optimistic updates, SSE handling, + * and mark-all-read operations. + */ + +import { describe, it, expect, beforeEach } from "vitest"; +import { createSubscriptionsCollection } from "@/lib/collections/subscriptions"; +import { createEntriesCollection } from "@/lib/collections/entries"; +import { createCountsCollection } from "@/lib/collections/counts"; +import type { Collections } from "@/lib/collections"; +import type { Subscription, EntryListItem } from "@/lib/collections/types"; +import { + zeroSubscriptionUnreadForMarkAllRead, + upsertEntriesInCollection, + updateEntryReadInCollection, + updateEntryStarredInCollection, + adjustEntriesCountInCollection, + setEntriesCountInCollection, + adjustSubscriptionUnreadInCollection, + setSubscriptionUnreadInCollection, + setBulkSubscriptionUnreadInCollection, + addSubscriptionToCollection, + removeSubscriptionFromCollection, + upsertSubscriptionsInCollection, + updateEntryScoreInCollection, + updateEntryMetadataInCollection, + adjustUncategorizedUnreadInCollection, + adjustUncategorizedFeedCountInCollection, + setUncategorizedUnreadInCollection, +} from "@/lib/collections/writes"; + +// --------------------------------------------------------------------------- +// Test Helpers +// --------------------------------------------------------------------------- + +function createTestSubscription(overrides: Partial = {}): Subscription { + return { + id: "sub-1", + type: "web", + url: "https://example.com/feed.xml", + title: "Example Feed", + originalTitle: "Example Feed", + description: "An example feed", + siteUrl: "https://example.com", + subscribedAt: new Date("2024-01-01"), + unreadCount: 10, + totalCount: 50, + tags: [], + fetchFullContent: false, + ...overrides, + } as Subscription; +} + +function createTestEntry(overrides: Partial = {}): EntryListItem { + return { + id: "entry-1", + subscriptionId: "sub-1", + feedId: "feed-1", + type: "web", + url: "https://example.com/post-1", + title: "Test Post", + author: "Author", + summary: "A test post", + publishedAt: new Date("2024-06-01"), + fetchedAt: new Date("2024-06-01"), + updatedAt: new Date("2024-06-01"), + read: false, + starred: false, + feedTitle: "Example Feed", + siteName: "example.com", + score: null, + implicitScore: 0, + predictedScore: null, + ...overrides, + } as EntryListItem; +} + +/** + * Creates a minimal Collections object with real local-only collections. + * Tags collection is omitted since it requires a QueryClient; tests that + * need tags should create one separately. + */ +function createTestCollections(): Collections { + return { + subscriptions: createSubscriptionsCollection(), + // Tags requires a QueryClient; cast a stub for tests that don't use it + tags: undefined as unknown as Collections["tags"], + entries: createEntriesCollection(), + counts: createCountsCollection(), + tagSubscriptionCollections: new Map(), + activeViewCollection: null, + invalidateActiveView: () => {}, + }; +} + +// ============================================================================ +// zeroSubscriptionUnreadForMarkAllRead +// ============================================================================ + +describe("zeroSubscriptionUnreadForMarkAllRead", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("zeroes all subscriptions when no filter is provided", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-3", unreadCount: 20 })); + + zeroSubscriptionUnreadForMarkAllRead(collections, {}); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(0); + }); + + it("zeroes only the specified subscription when subscriptionId filter is provided", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + zeroSubscriptionUnreadForMarkAllRead(collections, { subscriptionId: "sub-1" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); + }); + + it("zeroes subscriptions matching the tagId filter", () => { + const tag = { id: "tag-1", name: "News", color: "#ff0000" }; + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [tag] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-2", unreadCount: 5, tags: [tag] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-3", unreadCount: 20, tags: [] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "tag-1" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + // Sub-3 has no matching tag, should be untouched + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(20); + }); + + it("does not touch subscriptions that already have zero unread count", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 0 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + // This should not throw or cause issues for already-zero subscriptions + zeroSubscriptionUnreadForMarkAllRead(collections, {}); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(0); + }); + + it("is a no-op when collections is null", () => { + // Should not throw + zeroSubscriptionUnreadForMarkAllRead(null, {}); + zeroSubscriptionUnreadForMarkAllRead(null, { subscriptionId: "sub-1" }); + zeroSubscriptionUnreadForMarkAllRead(null, { tagId: "tag-1" }); + }); + + it("handles subscriptionId that does not exist in the collection", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + // Should not throw for non-existent subscription + zeroSubscriptionUnreadForMarkAllRead(collections, { subscriptionId: "non-existent" }); + + // Existing subscription should be untouched + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("handles tagId with no matching subscriptions", () => { + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "non-existent-tag" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("only zeroes subscriptions whose tag array contains the specified tag", () => { + const tagA = { id: "tag-a", name: "Tag A", color: null }; + const tagB = { id: "tag-b", name: "Tag B", color: null }; + + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", unreadCount: 10, tags: [tagA, tagB] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-2", unreadCount: 5, tags: [tagB] }) + ); + collections.subscriptions.insert( + createTestSubscription({ id: "sub-3", unreadCount: 3, tags: [tagA] }) + ); + + zeroSubscriptionUnreadForMarkAllRead(collections, { tagId: "tag-a" }); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); // only has tag-b + expect(collections.subscriptions.get("sub-3")?.unreadCount).toBe(0); + }); +}); + +// ============================================================================ +// upsertEntriesInCollection +// ============================================================================ + +describe("upsertEntriesInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("inserts new entries into the global entries collection", () => { + const entry1 = createTestEntry({ id: "entry-1" }); + const entry2 = createTestEntry({ id: "entry-2", title: "Second Post" }); + + upsertEntriesInCollection(collections, [entry1, entry2]); + + expect(collections.entries.has("entry-1")).toBe(true); + expect(collections.entries.has("entry-2")).toBe(true); + expect(collections.entries.get("entry-1")?.title).toBe("Test Post"); + expect(collections.entries.get("entry-2")?.title).toBe("Second Post"); + }); + + it("updates existing entries in the collection", () => { + const entry = createTestEntry({ id: "entry-1", title: "Original Title", read: false }); + collections.entries.insert(entry); + + const updatedEntry = createTestEntry({ id: "entry-1", title: "Updated Title", read: true }); + upsertEntriesInCollection(collections, [updatedEntry]); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated Title"); + expect(collections.entries.get("entry-1")?.read).toBe(true); + }); + + it("handles a mix of new and existing entries", () => { + const existing = createTestEntry({ id: "entry-1", title: "Existing" }); + collections.entries.insert(existing); + + const updatedExisting = createTestEntry({ id: "entry-1", title: "Updated Existing" }); + const newEntry = createTestEntry({ id: "entry-2", title: "Brand New" }); + + upsertEntriesInCollection(collections, [updatedExisting, newEntry]); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated Existing"); + expect(collections.entries.get("entry-2")?.title).toBe("Brand New"); + }); + + it("is a no-op when collections is null", () => { + upsertEntriesInCollection(null, [createTestEntry()]); + // Should not throw + }); + + it("is a no-op for empty entries array", () => { + upsertEntriesInCollection(collections, []); + expect(collections.entries.size).toBe(0); + }); +}); + +// ============================================================================ +// updateEntryReadInCollection +// ============================================================================ + +describe("updateEntryReadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("marks entries as read in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + collections.entries.insert(createTestEntry({ id: "entry-2", read: false })); + + updateEntryReadInCollection(collections, ["entry-1", "entry-2"], true); + + expect(collections.entries.get("entry-1")?.read).toBe(true); + expect(collections.entries.get("entry-2")?.read).toBe(true); + }); + + it("marks entries as unread in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: true })); + + updateEntryReadInCollection(collections, ["entry-1"], false); + + expect(collections.entries.get("entry-1")?.read).toBe(false); + }); + + it("skips entries that do not exist in the collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + + // "entry-2" does not exist, should not throw + updateEntryReadInCollection(collections, ["entry-1", "entry-2"], true); + + expect(collections.entries.get("entry-1")?.read).toBe(true); + expect(collections.entries.has("entry-2")).toBe(false); + }); + + it("is a no-op when collections is null", () => { + updateEntryReadInCollection(null, ["entry-1"], true); + // Should not throw + }); + + it("is a no-op for empty entryIds array", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", read: false })); + + updateEntryReadInCollection(collections, [], true); + + expect(collections.entries.get("entry-1")?.read).toBe(false); + }); +}); + +// ============================================================================ +// updateEntryStarredInCollection +// ============================================================================ + +describe("updateEntryStarredInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("stars an entry in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", starred: false })); + + updateEntryStarredInCollection(collections, "entry-1", true); + + expect(collections.entries.get("entry-1")?.starred).toBe(true); + }); + + it("unstars an entry in the global entries collection", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", starred: true })); + + updateEntryStarredInCollection(collections, "entry-1", false); + + expect(collections.entries.get("entry-1")?.starred).toBe(false); + }); + + it("skips entry that does not exist in the collection", () => { + // Should not throw + updateEntryStarredInCollection(collections, "non-existent", true); + }); + + it("is a no-op when collections is null", () => { + updateEntryStarredInCollection(null, "entry-1", true); + // Should not throw + }); +}); + +// ============================================================================ +// adjustEntriesCountInCollection +// ============================================================================ + +describe("adjustEntriesCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adjusts total and unread counts for an existing count record", () => { + collections.counts.insert({ id: "all", total: 100, unread: 50 }); + + adjustEntriesCountInCollection(collections, "all", 1, 1); + + expect(collections.counts.get("all")?.total).toBe(101); + expect(collections.counts.get("all")?.unread).toBe(51); + }); + + it("decreases counts but floors at zero", () => { + collections.counts.insert({ id: "all", total: 1, unread: 0 }); + + adjustEntriesCountInCollection(collections, "all", -5, -5); + + expect(collections.counts.get("all")?.total).toBe(0); + expect(collections.counts.get("all")?.unread).toBe(0); + }); + + it("does not create a count record that does not exist", () => { + // "all" not inserted, so this should be a no-op + adjustEntriesCountInCollection(collections, "all", 1, 1); + + expect(collections.counts.has("all")).toBe(false); + }); + + it("is a no-op when both deltas are zero", () => { + collections.counts.insert({ id: "all", total: 100, unread: 50 }); + + adjustEntriesCountInCollection(collections, "all", 0, 0); + + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("is a no-op when collections is null", () => { + adjustEntriesCountInCollection(null, "all", 1, 1); + // Should not throw + }); + + it("works with starred and saved keys", () => { + collections.counts.insert({ id: "starred", total: 10, unread: 5 }); + collections.counts.insert({ id: "saved", total: 20, unread: 10 }); + + adjustEntriesCountInCollection(collections, "starred", 2, 1); + adjustEntriesCountInCollection(collections, "saved", -3, -2); + + expect(collections.counts.get("starred")?.total).toBe(12); + expect(collections.counts.get("starred")?.unread).toBe(6); + expect(collections.counts.get("saved")?.total).toBe(17); + expect(collections.counts.get("saved")?.unread).toBe(8); + }); +}); + +// ============================================================================ +// setEntriesCountInCollection +// ============================================================================ + +describe("setEntriesCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("creates a count record that does not exist yet", () => { + setEntriesCountInCollection(collections, "all", 100, 50); + + expect(collections.counts.has("all")).toBe(true); + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("updates an existing count record", () => { + collections.counts.insert({ id: "all", total: 50, unread: 25 }); + + setEntriesCountInCollection(collections, "all", 100, 50); + + expect(collections.counts.get("all")?.total).toBe(100); + expect(collections.counts.get("all")?.unread).toBe(50); + }); + + it("is a no-op when collections is null", () => { + setEntriesCountInCollection(null, "all", 100, 50); + // Should not throw + }); +}); + +// ============================================================================ +// adjustSubscriptionUnreadInCollection +// ============================================================================ + +describe("adjustSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adjusts unread count for subscriptions by delta", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + const deltas = new Map([["sub-1", -3]]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(7); + }); + + it("floors unread count at zero", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 2 })); + + const deltas = new Map([["sub-1", -10]]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + }); + + it("handles multiple subscriptions at once", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 5 })); + + const deltas = new Map([ + ["sub-1", -2], + ["sub-2", 3], + ]); + adjustSubscriptionUnreadInCollection(collections, deltas); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(8); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(8); + }); + + it("skips subscriptions not in the collection", () => { + const deltas = new Map([["non-existent", -1]]); + // Should not throw + adjustSubscriptionUnreadInCollection(collections, deltas); + }); + + it("is a no-op when collections is null", () => { + adjustSubscriptionUnreadInCollection(null, new Map([["sub-1", 1]])); + }); + + it("is a no-op for empty deltas map", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + adjustSubscriptionUnreadInCollection(collections, new Map()); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); +}); + +// ============================================================================ +// setSubscriptionUnreadInCollection +// ============================================================================ + +describe("setSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("sets absolute unread count for a subscription", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + + setSubscriptionUnreadInCollection(collections, "sub-1", 42); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(42); + }); + + it("skips subscription not in collection", () => { + setSubscriptionUnreadInCollection(collections, "non-existent", 5); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + setSubscriptionUnreadInCollection(null, "sub-1", 5); + }); +}); + +// ============================================================================ +// setBulkSubscriptionUnreadInCollection +// ============================================================================ + +describe("setBulkSubscriptionUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("sets absolute unread counts for multiple subscriptions", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + collections.subscriptions.insert(createTestSubscription({ id: "sub-2", unreadCount: 20 })); + + const updates = new Map([ + ["sub-1", 0], + ["sub-2", 5], + ]); + setBulkSubscriptionUnreadInCollection(collections, updates); + + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(0); + expect(collections.subscriptions.get("sub-2")?.unreadCount).toBe(5); + }); + + it("skips subscriptions not in collection", () => { + const updates = new Map([["non-existent", 5]]); + setBulkSubscriptionUnreadInCollection(collections, updates); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + setBulkSubscriptionUnreadInCollection(null, new Map([["sub-1", 5]])); + }); + + it("is a no-op for empty updates map", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", unreadCount: 10 })); + setBulkSubscriptionUnreadInCollection(collections, new Map()); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); +}); + +// ============================================================================ +// addSubscriptionToCollection +// ============================================================================ + +describe("addSubscriptionToCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("adds a new subscription to the collection", () => { + const sub = createTestSubscription({ id: "sub-1" }); + addSubscriptionToCollection(collections, sub); + + expect(collections.subscriptions.has("sub-1")).toBe(true); + expect(collections.subscriptions.get("sub-1")?.title).toBe("Example Feed"); + }); + + it("skips duplicate subscriptions (SSE race condition protection)", () => { + const sub = createTestSubscription({ id: "sub-1", unreadCount: 10 }); + collections.subscriptions.insert(sub); + + // Attempt to add the same subscription again with different data + const duplicate = createTestSubscription({ id: "sub-1", unreadCount: 99 }); + addSubscriptionToCollection(collections, duplicate); + + // Original should be preserved + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("is a no-op when collections is null", () => { + addSubscriptionToCollection(null, createTestSubscription()); + }); +}); + +// ============================================================================ +// removeSubscriptionFromCollection +// ============================================================================ + +describe("removeSubscriptionFromCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("removes a subscription from the collection", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1" })); + + removeSubscriptionFromCollection(collections, "sub-1"); + + expect(collections.subscriptions.has("sub-1")).toBe(false); + }); + + it("is a no-op for non-existent subscription", () => { + removeSubscriptionFromCollection(collections, "non-existent"); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + removeSubscriptionFromCollection(null, "sub-1"); + }); +}); + +// ============================================================================ +// upsertSubscriptionsInCollection +// ============================================================================ + +describe("upsertSubscriptionsInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("inserts new subscriptions into the collection", () => { + const sub1 = createTestSubscription({ id: "sub-1" }); + const sub2 = createTestSubscription({ id: "sub-2", title: "Second Feed" }); + + upsertSubscriptionsInCollection(collections, [sub1, sub2]); + + expect(collections.subscriptions.has("sub-1")).toBe(true); + expect(collections.subscriptions.has("sub-2")).toBe(true); + expect(collections.subscriptions.get("sub-2")?.title).toBe("Second Feed"); + }); + + it("updates existing subscriptions in the collection", () => { + collections.subscriptions.insert( + createTestSubscription({ id: "sub-1", title: "Old Title", unreadCount: 5 }) + ); + + const updated = createTestSubscription({ id: "sub-1", title: "New Title", unreadCount: 10 }); + upsertSubscriptionsInCollection(collections, [updated]); + + expect(collections.subscriptions.get("sub-1")?.title).toBe("New Title"); + expect(collections.subscriptions.get("sub-1")?.unreadCount).toBe(10); + }); + + it("handles a mix of new and existing subscriptions", () => { + collections.subscriptions.insert(createTestSubscription({ id: "sub-1", title: "Existing" })); + + const updatedExisting = createTestSubscription({ id: "sub-1", title: "Updated" }); + const newSub = createTestSubscription({ id: "sub-2", title: "New" }); + + upsertSubscriptionsInCollection(collections, [updatedExisting, newSub]); + + expect(collections.subscriptions.get("sub-1")?.title).toBe("Updated"); + expect(collections.subscriptions.get("sub-2")?.title).toBe("New"); + }); + + it("is a no-op for empty array", () => { + upsertSubscriptionsInCollection(collections, []); + expect(collections.subscriptions.size).toBe(0); + }); + + it("is a no-op when collections is null", () => { + upsertSubscriptionsInCollection(null, [createTestSubscription()]); + }); +}); + +// ============================================================================ +// updateEntryScoreInCollection +// ============================================================================ + +describe("updateEntryScoreInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("updates score fields for an entry", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", score: null, implicitScore: 0 })); + + updateEntryScoreInCollection(collections, "entry-1", 2, 2); + + expect(collections.entries.get("entry-1")?.score).toBe(2); + expect(collections.entries.get("entry-1")?.implicitScore).toBe(2); + }); + + it("clears score to null", () => { + collections.entries.insert(createTestEntry({ id: "entry-1", score: 2, implicitScore: 2 })); + + updateEntryScoreInCollection(collections, "entry-1", null, 0); + + expect(collections.entries.get("entry-1")?.score).toBeNull(); + expect(collections.entries.get("entry-1")?.implicitScore).toBe(0); + }); + + it("skips entry that does not exist", () => { + updateEntryScoreInCollection(collections, "non-existent", 1, 1); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + updateEntryScoreInCollection(null, "entry-1", 1, 1); + }); +}); + +// ============================================================================ +// updateEntryMetadataInCollection +// ============================================================================ + +describe("updateEntryMetadataInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + }); + + it("updates metadata fields for an entry", () => { + collections.entries.insert( + createTestEntry({ id: "entry-1", title: "Old Title", author: "Old Author" }) + ); + + updateEntryMetadataInCollection(collections, "entry-1", { + title: "New Title", + author: "New Author", + }); + + expect(collections.entries.get("entry-1")?.title).toBe("New Title"); + expect(collections.entries.get("entry-1")?.author).toBe("New Author"); + }); + + it("partially updates metadata", () => { + collections.entries.insert( + createTestEntry({ id: "entry-1", title: "Original", summary: "Original Summary" }) + ); + + updateEntryMetadataInCollection(collections, "entry-1", { title: "Updated" }); + + expect(collections.entries.get("entry-1")?.title).toBe("Updated"); + expect(collections.entries.get("entry-1")?.summary).toBe("Original Summary"); + }); + + it("skips entry that does not exist", () => { + updateEntryMetadataInCollection(collections, "non-existent", { title: "New" }); + // Should not throw + }); + + it("is a no-op when collections is null", () => { + updateEntryMetadataInCollection(null, "entry-1", { title: "New" }); + }); +}); + +// ============================================================================ +// Uncategorized Count Writes +// ============================================================================ + +describe("adjustUncategorizedUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("adjusts uncategorized unread count by delta", () => { + adjustUncategorizedUnreadInCollection(collections, -1); + expect(collections.counts.get("uncategorized")?.unread).toBe(2); + }); + + it("floors unread count at zero", () => { + adjustUncategorizedUnreadInCollection(collections, -10); + expect(collections.counts.get("uncategorized")?.unread).toBe(0); + }); + + it("is a no-op when delta is zero", () => { + adjustUncategorizedUnreadInCollection(collections, 0); + expect(collections.counts.get("uncategorized")?.unread).toBe(3); + }); + + it("is a no-op when collections is null", () => { + adjustUncategorizedUnreadInCollection(null, -1); + }); + + it("is a no-op when uncategorized record does not exist", () => { + const freshCollections = createTestCollections(); + adjustUncategorizedUnreadInCollection(freshCollections, -1); + expect(freshCollections.counts.has("uncategorized")).toBe(false); + }); +}); + +describe("adjustUncategorizedFeedCountInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("adjusts uncategorized feed count by delta", () => { + adjustUncategorizedFeedCountInCollection(collections, 1); + expect(collections.counts.get("uncategorized")?.total).toBe(6); + }); + + it("floors feed count at zero", () => { + adjustUncategorizedFeedCountInCollection(collections, -10); + expect(collections.counts.get("uncategorized")?.total).toBe(0); + }); + + it("is a no-op when delta is zero", () => { + adjustUncategorizedFeedCountInCollection(collections, 0); + expect(collections.counts.get("uncategorized")?.total).toBe(5); + }); + + it("is a no-op when collections is null", () => { + adjustUncategorizedFeedCountInCollection(null, 1); + }); +}); + +describe("setUncategorizedUnreadInCollection", () => { + let collections: Collections; + + beforeEach(() => { + collections = createTestCollections(); + collections.counts.insert({ id: "uncategorized", total: 5, unread: 3 }); + }); + + it("sets absolute uncategorized unread count", () => { + setUncategorizedUnreadInCollection(collections, 10); + expect(collections.counts.get("uncategorized")?.unread).toBe(10); + }); + + it("does not affect total count", () => { + setUncategorizedUnreadInCollection(collections, 10); + expect(collections.counts.get("uncategorized")?.total).toBe(5); + }); + + it("is a no-op when uncategorized record does not exist", () => { + const freshCollections = createTestCollections(); + setUncategorizedUnreadInCollection(freshCollections, 10); + expect(freshCollections.counts.has("uncategorized")).toBe(false); + }); + + it("is a no-op when collections is null", () => { + setUncategorizedUnreadInCollection(null, 10); + }); +}); diff --git a/tests/unit/frontend/hooks/useEntryMutations.test.ts b/tests/unit/frontend/hooks/useEntryMutations.test.ts index 5d017ddb..e5c0bde4 100644 --- a/tests/unit/frontend/hooks/useEntryMutations.test.ts +++ b/tests/unit/frontend/hooks/useEntryMutations.test.ts @@ -166,8 +166,7 @@ describe("useEntryMutations", () => { describe("useEntryMutations behavior", () => { /** * Note: Full integration testing of useEntryMutations requires a running - * tRPC client which needs significant mocking infrastructure. The cache - * operations themselves are tested in cache/operations.test.ts. + * tRPC client which needs significant mocking infrastructure. * * These tests document the expected behavior: * @@ -185,10 +184,10 @@ describe("useEntryMutations behavior", () => { * 10. isStarPending is true when star or unstar mutation is pending * * On success: - * - markRead calls handleEntriesMarkedRead with returned entries + * - markRead updates entries collection + entries.get cache + counts * - markAllRead invalidates entries.list, subscriptions.list, tags.list, and starred count - * - star calls handleEntryStarred - * - unstar calls handleEntryUnstarred + * - star updates entries collection + entries.get cache + counts + * - unstar updates entries collection + entries.get cache + counts * * On error: * - All mutations show a toast error message @@ -198,7 +197,7 @@ describe("useEntryMutations behavior", () => { // markRead should: // 1. Accept array of IDs and boolean read status // 2. Call entries.markRead mutation - // 3. On success: call handleEntriesMarkedRead with response entries + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to update read status" expect(true).toBe(true); }); @@ -224,7 +223,7 @@ describe("useEntryMutations behavior", () => { // star should: // 1. Accept entryId // 2. Call entries.star mutation with { id: entryId } - // 3. On success: call handleEntryStarred with entry id and read status + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to star entry" expect(true).toBe(true); }); @@ -233,7 +232,7 @@ describe("useEntryMutations behavior", () => { // unstar should: // 1. Accept entryId // 2. Call entries.unstar mutation with { id: entryId } - // 3. On success: call handleEntryUnstarred with entry id and read status + // 3. On success: update entries collection + entries.get cache + counts // 4. On error: show toast error "Failed to unstar entry" expect(true).toBe(true); }); @@ -259,20 +258,18 @@ describe("useEntryMutations behavior", () => { describe("cache integration", () => { /** - * The cache operations called by useEntryMutations are tested in + * Entry state (read, starred, score) is managed by TanStack DB collections. + * Subscription lifecycle and count operations are tested in * tests/unit/frontend/cache/operations.test.ts * * This documents which operations are called by which mutations: */ - it("markRead mutation uses handleEntriesMarkedRead", () => { - // handleEntriesMarkedRead updates: - // - entries.get cache for each entry - // - subscriptions.list unread counts - // - tags.list unread counts - // - entries.count for starred entries - // - entries.count for saved entries (if type is saved) - // - entries.list (removes entries if filtering by unread) + it("markRead mutation updates entries collection and counts", () => { + // markRead updates: + // - entries collection via updateEntryReadInCollection (optimistic) + // - entries.get cache for detail view + // - subscription/tag/entries counts via setBulkCounts on success expect(true).toBe(true); }); @@ -285,19 +282,19 @@ describe("cache integration", () => { expect(true).toBe(true); }); - it("star mutation uses handleEntryStarred", () => { - // handleEntryStarred updates: + it("star mutation updates entries collection and counts", () => { + // star updates: + // - entries collection via updateEntryStarredInCollection (optimistic) // - entries.get cache to set starred: true - // - entries.count with starredOnly: true (total +1, unread +1 if entry is unread) - // - entries.list to add entry to starred list + // - subscription/tag/entries counts via setCounts on success expect(true).toBe(true); }); - it("unstar mutation uses handleEntryUnstarred", () => { - // handleEntryUnstarred updates: + it("unstar mutation updates entries collection and counts", () => { + // unstar updates: + // - entries collection via updateEntryStarredInCollection (optimistic) // - entries.get cache to set starred: false - // - entries.count with starredOnly: true (total -1, unread -1 if entry is unread) - // - entries.list to remove entry from starred list + // - subscription/tag/entries counts via setCounts on success expect(true).toBe(true); }); });