perf: cache open-building bazaar reads + scale audit - #1
Conversation
The building bazaar was force-dynamic, so every request hit Postgres (member join + highlights RPC). For a viral open building that is the one real DB hot spot at high concurrency. Extract the member roster + drop highlights into readBazaarContent() and, for open buildings only, serve it through unstable_cache (30s revalidate, tagged bazaar:<id>) — the same freshness the storefront already accepts. Invite-only buildings still read fresh on every request so the cookie gate is never bypassed by a cached payload, and nothing behind an invite is cached. No RLS change; the cache holds only already-public, Zod-projected, PII-free data. Add docs/scale-audit.md: what is already handled, this change, and the remaining human-decision levers (unbounded admin metrics fetch; redundant tracking poll while SSE is healthy). No optimization required relaxing RLS. npm run verify passes (typecheck + lint + 240 unit tests + build + image-processor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two scale follow-ups from docs/scale-audit.md. 1. Admin metrics aggregation moved to Postgres. loadAdminMetrics() pulled every paid order into the Worker and summed/grouped in JS, growing linearly with lifetime order volume. New security-definer RPC public.get_admin_metrics (migration 0040) does the rollup in SQL and returns one bounded JSON document. The fee rate stays single-sourced in TS (PLATFORM_FEE_BPS passed as p_fee_bps); only per-order rounding is mirrored in SQL, where round(numeric) is half-away-from-zero — identical to Math.round for the non-negative totals paid orders carry. Granted to service_role only; reads store_id + total_cents only (no PII, no select *). The JSON is Zod-validated at the loader boundary (parseAdminMetrics). The aggregation unit test becomes a boundary test; the SQL math is covered by the integration suite. 2. Order tracker pauses its redundant poll once SSE covers the order, but only for pay-at-pickup orders (static payment status). Online orders keep the reconciliation poll because Stripe webhook payment/refund changes are not published to the stream. onerror resumes the poll. npm run verify passes (typecheck + lint + 237 unit tests + build + image-processor). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Exercises migration 0040 against a real database — the SQL the unit suite can't reach. Parallel-safe: every assertion is scoped to this test's seeded seller/building by id (the RPC aggregates the whole platform, and the suite seeds tenants concurrently), never to platform-wide totals. Verifies the per-seller and per-building rollups equal the seeded paid orders exactly, that the unpaid seed order is excluded, that platform fees stay within the per-order rounding bound of rate*GMV on a single snapshot, and that p_top_limit bounds the ranking lists. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The file was removed from the working tree and the deletion was swept into the previous commit by accident. Restore the up-to-date version (with the second-pass follow-ups: SQL admin metrics + SSE-gated tracking poll). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI provides NEXT_PUBLIC_APP_URL but can leave it empty. `?? "..."` only
falls back on undefined, so an empty string reached new URL("") and threw
Invalid URL — failing page-data collection for /_not-found (and thus the
OpenNext Cloudflare build) for every route. Use `||` so an empty value
falls back to the localhost default too.
Verified: `next build` and `npx opennextjs-cloudflare build` both pass with
NEXT_PUBLIC_APP_URL unset/empty.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a823538079
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (typeof EventSource !== "undefined") { | ||
| es = new EventSource(`/api/track/${token}/stream`); | ||
| es.onopen = () => { | ||
| if (sseCoversAllUpdates) stopPoll(); |
There was a problem hiding this comment.
Keep a reconciliation poll while SSE remains best-effort
When a pay-at-pickup tracker has an open EventSource, this stops the only reconciliation loop. The stream publisher is explicitly best-effort (publishOrderUpdate swallows Durable Object failures in lib/orders/order-stream.ts), so if a seller changes a pickup order's status or marks it paid while that publish misses but the EventSource connection stays open, the customer page can remain stale until a visibility change or reconnect; before this change the 20s poll corrected that gap.
Useful? React with 👍 / 👎.
The cloudflare-preview job runs on every PR but its deploy step needs CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID, which aren't set in this repo (didn't carry over from the aptbazaar -> Stoop rename), so the deploy failed and blocked PRs. Probe the token in a step and gate the deploy on it: the OpenNext build still runs on every PR (it's what catches build breakage), the deploy is skipped with a warning when unconfigured, and it re-enables itself automatically once the secrets are added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous change stopped the poll entirely for pay-at-pickup orders once SSE connected. But publishOrderUpdate is best-effort (swallows Durable Object failures), and a missed publish on a still-open EventSource doesn't fire onerror — so the page could stay stale until a tab refocus/reconnect. markPaid also transitions a pickup order pay_at_pickup -> paid over that same best-effort stream, so the gap is real. Relax the poll instead of removing it: online orders keep the tight 20s interval (their payment/refund state arrives only via poll); pay-at-pickup backs off to a 60s reconciliation backstop once SSE is healthy, and onerror tightens it straight back to 20s with an immediate refetch. Keeps the ~3x load reduction for the dominant pickup population while never losing the safety net. verify + e2e (incl. order-lifecycle tracking) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 227b1c16d3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| 'gmv_cents', coalesce((select sum(total_cents) from paid), 0), | ||
| 'platform_fees_cents', coalesce((select sum(fee_cents) from paid), 0), | ||
| 'top_sellers', coalesce(( | ||
| select jsonb_agg(row) |
There was a problem hiding this comment.
Preserve ranking order inside the JSON aggregates
When the dashboard has multiple top sellers, this jsonb_agg(row) does not specify an aggregate ORDER BY, so the JSON array order is not guaranteed even though the UI renders it as rank 1, 2, 3; the same pattern is used for buildings below. The old TypeScript path explicitly sorted before slicing, so put the ranking keys on the aggregate order as well as the limiting subquery to avoid displaying the right top-N rows in an arbitrary order.
Useful? React with 👍 / 👎.
Phase 10.6: Add aggregate ORDER BY keys for founder dashboard top sellers and buildings.
Phase 10.6: Add forward migration for databases that already applied 0040.
Summary
A scale pass aimed at ~10k concurrent users. The headline finding: the app is already heavily optimized for scale — ISR storefront, no
select *on PII, explicit columns, batched email fan-out,Promise.allparallelism, an RPC instead of N+1 on the bazaar, DO-based SSE with a poll fallback, and dedicated scale-index migrations. So rather than manufacture churn, this PR makes the one genuinely high-value change and documents the rest.Changed
app/b/[publicSlug]/page.tsx). The bazaar wasforce-dynamic, hitting Postgres on every request (member join + highlights RPC). Open buildings' reads now go throughunstable_cache(30s revalidate, taggedbazaar:<id>) — the same freshness the storefront already uses. A viral open bazaar drops fromO(requests)round-trips to ~1 per 30s window.docs/scale-audit.md— full audit: what's already handled, this change, and the remaining human-decision levers.Invariants checked
select *on PII, no unit numbers, no Stripe-UI rebuild, service key stays server-side.Recommendations (not implemented — your call)
lib/admin/load-metrics.ts) pulls all paid orders into the Worker and sums in JS. Move to asecurity definerSQL aggregate before it bites. Low risk; single-founder page, not the concurrency hot path.app/o/[token]/tracking.tsx) — pause the 20s poll whileEventSourceis open. Low–medium risk; verify payment-status transitions still land promptly.Verification
npm run verifypasses locally: typecheck + lint + 240 unit tests + build + image-processor (9 tests).🤖 Generated with Claude Code