You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
fix(reads): page the nine remaining unbounded sweeps and the digest RPC (#548)
PostgREST caps every response at the API "Max rows" limit and returns the
capped result as an ordinary 200, so truncation is indistinguishable from a
complete read. #533 shipped `fetchAllRows` but wired it to only two call
sites; nine reads were left unbounded.
Two of them failed as wrong user-visible behaviour rather than a wrong number:
- The digest's `notification_preferences` read. `resolveChannels(undefined)`
treats a missing row as the schema default `{ inApp: true, email: true }`,
so every student the truncated read dropped got emailed — including the
ones who had turned email off. A short read could not mean "send more
email"; now the read is chunked and paged, and a failure skips the tenant
instead of falling through with an empty preferences map.
- The digest's idempotency read. One notification row is written per
recipient, so past the cap it truncated and a cron retry re-sent the day's
digests, inverting the module's documented "re-runs never double-send"
guarantee exactly when a retry happens.
`get_daily_digest_candidates` could not be fixed from the caller side: it is a
SETOF function with no ORDER BY, so `.range()` was not a stable window and
there was no exact count to verify against. It now takes a keyset cursor on
(tenant_id, user_id) — UNIQUE via tenant_users_unique — with ORDER BY and a
clamped LIMIT. Keyset rather than OFFSET because the candidate set is computed
live and shifts under a multi-second sweep. The caller stops on an EMPTY page,
never a short one: a short page is ambiguous between "end of set" and "the row
cap clamped us", and reading it as "done" is the original bug in a new place.
Also adds `fetchAllRowsIn` for `.in()` lookups. Those have a second ceiling on
the REQUEST side — a few hundred uuids overflow the gateway's URL limit — and
response paging cannot help with a request that is never made.
Verified by forcing the cap, not by a green run on a small dataset:
- Live, against real PostgREST with the row cap lowered to 5 and 37
transactions / 23 payouts / 37 candidates seeded past it
(scripts/qa-548-cap-forcing.mts). Unpaged reads come back with 5 rows while
count reports 37; every paged read returns all 37 and the sums are exact.
The opted-out student at index 30 is invisible to the unpaged read and
found by the paged one.
- In unit tests, via a fake PostgREST that clamps every response to 7 rows
over a 40-student fixture. Reverting the fix fails 6 of them with exactly
the expected numbers (7 of 40 sent; the opted-out student emailed).
`tests/unit/unbounded-read-guard.test.ts` statically scans the tree for reads
against growth tables that never bound themselves, so the next unbounded sweep
fails in review. Pre-existing sites are recorded in KNOWN_UNBOUNDED as a
ratchet, split into `scoped` (bounded by a user or a course) and `gap` (real
platform-wide sweeps still outstanding under #540); a stale entry also fails,
so the list cannot rot.
`rollover_all_leagues`' tenant loop is untouched — it is PL/pgSQL and never
crosses PostgREST, so it is not subject to the cap.
Gates: typecheck, lint, build, test:unit 446/446 (was 411).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0185GrowMKwTrLQE1it2HNGN
0 commit comments