Skip to content

fix: bound notification inbox polling - #308

Open
eas4ai wants to merge 10 commits into
dev-null-sh:productionfrom
eas4ai:fix/bounded-notification-sync
Open

eas4ai wants to merge 10 commits into
dev-null-sh:productionfrom
eas4ai:fix/bounded-notification-sync

Conversation

@eas4ai

@eas4ai eas4ai commented Oct 16, 2026

Copy link
Copy Markdown

Summary

  • add an authenticated notification sync endpoint with a fixed 20-row head and at most 20 requested visible-row refreshes
  • select the head, boundary, and visible representative IDs in one database statement
  • keep normal inbox pages fixed at 20 rows and remove the old depth-dependent through request contract
  • replace expanding Inertia reload polls with non-overlapping JSON polls that reconcile only proven-safe changes
  • reset both server pagination metadata and Inertia's live scroll manager when a complete response could leave an old cursor active

Problem

The old poll sent the timestamp of the oldest loaded notification back to the server. Each extra page made every later poll query, serialize, transfer, and replace a larger result.

This change keeps polling work bounded no matter how far someone has scrolled.

Safety and behavior

The new endpoint is inside the authenticated route group. It validates the filter and a maximum of 20 distinct visible group IDs before acknowledging reads, scopes every row to the signed-in recipient, and returns Cache-Control: private, no-store.

The client compares both the stable group ID and the current representative event ID. It applies a response only when that exact boundary, or an unchanged prefix, proves continuity. Reconciliation runs against Inertia's current page props so a page loaded while the poll was in flight is not overwritten.

Ambiguous changes leave the current list untouched and show New notifications. That button performs a normal page-one reload. Complete responses also check Inertia's live InfiniteScrollRef.hasNext() state, so an error-bearing pagination response cannot leave an obsolete internal cursor behind.

Polls run every 30 seconds, pause in hidden tabs and during navigation, reject overlap, abort stale requests, and time out after 10 seconds.

Verification

  • composer test: 2,098 PHP tests and 12,447 assertions; 100% PHP line coverage and type coverage
  • client suites: 30 directly discovered Node tests plus 10 nested Vitest tests
  • focused notification checks: 63 PHP tests / 689 assertions, 14 reconciliation checks, and 16 component checks
  • TypeScript, formatting, lint, PHPStan, Rector, Wayfinder generation, route inspection, and production build
  • independent task reviews plus a final whole-branch review; two reproduced cursor findings were fixed and re-reviewed
  • whole-branch Ripwire checks were reviewed explicitly: they report the expected added size/complexity in the inbox page and query, plus a static impacted-test list; all named suites ran, and reviewers found no remaining correctness blocker

Local database verification used PostgreSQL 17. The component tests model Inertia lifecycle callbacks and use its public scroll handle; they do not execute a real browser pagination request. Full TSX test discovery remains covered separately by #300.

Limits

Offscreen historical rows can remain stale until they become visible or the page is refreshed. A delayed transaction that becomes visible below a previously proven boundary has the same limitation. Stronger guarantees would require a persistent mutation version or change feed.

This work is separate from #264, which optimizes the global unread count rather than inbox polling depth.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant