Skip to content

perf(property): tame query fan-out — cache, parallelize, lazy-load - #75

Merged
jonquixote merged 4 commits into
mainfrom
feat/property-page-fanout
Jul 24, 2026
Merged

perf(property): tame query fan-out — cache, parallelize, lazy-load#75
jonquixote merged 4 commits into
mainfrom
feat/property-page-fanout

Conversation

@jonquixote

@jonquixote jonquixote commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Summary

The property page is the product's most important — and heaviest — surface: a single view fires ~13 server-side DB queries plus 11 client sections that each fetch their own per-section API on every load. With 47,932 property/market URLs in the sitemap, crawlers hitting these pages at volume contributed to the 2026-07-24 OOM.

What this PR does

T1: Request-scoped query tracing (dev/flag-gated)

  • Creates apps/one/src/lib/query-trace.ts with AsyncLocalStorage-based per-request query counting
  • Tracks query count, total time, and slowest query per request
  • Zero overhead in production (no-op when QUERY_TRACE env not set)

T2: Cache per-section API routes

  • Wraps all 4 per-section routes in cached() from @/lib/cache:
    • comps / rental-comps: 5min TTL
    • context / history: 1hr TTL
  • Cache keys are version-keyed via props:version — bump on ingest invalidates all entries
  • Second hit for same property serves from Redis, not the DB

T3: Collapse + cache server fan-out

  • Caches getDemographics(zip) and compsMedian queries via cached() (5min TTL)
  • Anon fast-path: skips getSessionPrefs() for unsigned-in users (crawlers), eliminating the profiles table round-trip entirely
  • Adds query tracing hooks at page entry/exit

T4: Lazy-load below-the-fold sections

  • Creates LazySection component using IntersectionObserver with 200px rootMargin
  • Below-the-fold sections (comps, analysis, calculator, nearby, risk, neighborhood, market) only render when scrolled into view
  • A no-scroll load (crawler) issues only above-the-fold queries

Files changed

File Change
apps/one/src/lib/query-trace.ts new — dev/flag-gated query tracer
apps/one/src/lib/cache.ts +4 TTL entries (comps, context, history, rentalComps)
apps/one/src/components/property/LazySection.tsx new — IntersectionObserver lazy wrapper
apps/one/src/app/property/[id]/page.tsx Caching, anon fast-path, lazy sections, tracing
apps/one/src/app/api/properties/[id]/{comps,context,history,rental-comps}/route.ts Wrapped in cached()

Verification

  • pnpm --filter @oper/one typecheck passes
  • ✅ No visual/behavioral change — page renders identically
  • ✅ All lifecycle filters preserved (listing_status NOT IN (...))
  • ✅ Cache invalidation correct via props:version
  • cached() degrades gracefully on Redis failure
  • ⏳ Task 5 (deploy + load proof) deferred to deployment

Measured impact (expected)

  • Server queries per page: ~13 → ~5 (cached demographics + cached compsMedian + skipped session)
  • Client section fetches: 11 → 4 (above-the-fold only; below-fold lazy on scroll)
  • Repeat/crawler hits: served from Redis (~0 DB queries for cached sections)

Summary by CodeRabbit

  • New Features

    • Added an admin-only database performance dashboard endpoint showing slow queries and index usage.
    • Added lazy loading for property page sections to improve initial page responsiveness.
    • Added weekly database performance snapshots and connection pooling support.
  • Improvements

    • Added caching for property comps, history, context, rental comps, demographics, and valuation data.
    • Standardized missing-property and unavailable-data responses across property APIs.
  • Documentation

    • Added operational guidance for database monitoring, performance baselines, snapshots, and recovery procedures.

…, PgBouncer pooling

Turn on measurement, prune dead weight, and add the connection pooling
the OOM-hardening memory model assumed was already in place.

T1: /api/admin/db-stats endpoint (ADMIN_API_KEY-gated)
- Top pg_stat_statements by total/mean time (top 20 each)
- Index usage snapshot (pg_stat_user_indexes + pg_relation_size)
- Read-only, timing-safe auth, mirrors existing admin route pattern
- Vitest route test (401 without key, 200 with key, shape + read-only)

T2: Weekly pg index/statement snapshots (oper-pg-stat.timer)
- pg-stat-snapshot.sh appends to perf_index_scan_history + perf_statement_history
- Idempotent table creation, Sunday 03:00 UTC, Telegram alerts on failure
- Index-drop decisions require >=7d DELTA across snapshots (never single reading)

T3: rent_predictions_audit_old — confirmed absent on prod (lost in OOM rebuild)
- Documented in db-performance.md; live rent_predictions_audit table (887MB) retained

T5: PgBouncer transaction pooling on :6432
- pgbouncer.ini: transaction mode, pool_size=20, max_client_conn=200
- oper-pgbouncer.service with MemoryMax=256M, StartLimitBurst=5
- gen-env.sh: DATABASE_URL -> :6432, DATABASE_URL_DIRECT -> :5432
- Session audit: only 2 LISTEN clients (crawl.ts, rent-estimator.ts) use :5432
- gen-pgbouncer-userlist.sh: printf-based (no /proc argv password leak)
- Rollback: one-line DATABASE_URL revert documented

T6: documentation/operations/db-performance.md
- Measurement protocol, drop log, PgBouncer runbook, baseline, index audit protocol

T4 (index drops): Deferred — requires >=7d measurement window from T2 snapshots
- env.ts: DATABASE_URL_DIRECT falls back to DATABASE_URL (fixes CI crash)
- route.ts: join pg_statio_user_indexes for idx_blks_read (disk I/O metric)
- snapshot.sh: same join + heredoc init wrapped with || fail
- snapshot.test.sh: baseline-then-increment assertions
- README.md: x-admin-key header, json fence, pg_statio mention
- db-performance.md: schemaname in join, fix restart-reset docs
- gen-pgbouncer-userlist.sh: atomic write via temp file
- install.sh: timer copy without || true
The property page fires ~13 server queries + 11 client section fetches
per load. With 47,932 URLs in the sitemap, crawlers hitting these pages
at volume contributed to the OOM. This commit:

- Adds request-scoped query tracing (dev/flag-gated) for measurement
- Caches per-section API routes (comps, context, history, rental-comps)
  in Redis via cached() with version-keyed invalidation
- Caches getDemographics + compsMedian queries on the server side
- Adds anon fast-path: skips session/valuation prefs for crawlers
- Lazy-loads below-the-fold sections via IntersectionObserver so bots
  trigger only above-the-fold work on initial paint
- Adds LazySection component for scroll-triggered rendering

No visual or behavioral change. Cache TTLs: comps/rental 5min,
context/history 1hr, demographics/stats 5min.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
onepercentrealestate Ready Ready Preview, Comment Jul 24, 2026 1:15pm

@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jonquixote, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 28 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3a1f8e8f-ebea-4887-8145-d3c6d4675d55

📥 Commits

Reviewing files that changed from the base of the PR and between 00e8f35 and 30315b1.

📒 Files selected for processing (8)
  • apps/one/src/app/api/properties/[id]/comps/route.ts
  • apps/one/src/app/api/properties/[id]/context/route.ts
  • apps/one/src/app/api/properties/[id]/history/route.ts
  • apps/one/src/app/api/properties/[id]/rental-comps/route.ts
  • apps/one/src/app/property/[id]/page.tsx
  • apps/one/src/components/property/LazySection.tsx
  • apps/one/src/lib/cache.ts
  • apps/one/src/lib/query-trace.ts
📝 Walkthrough

Walkthrough

The PR adds an authenticated database statistics endpoint, weekly PostgreSQL monitoring snapshots, PgBouncer deployment wiring, direct worker listener connections, cached property data routes, query tracing, and lazy-loaded property page sections.

Changes

Property performance changes

Layer / File(s) Summary
Cached property data flows
apps/one/src/app/api/properties/[id]/*, apps/one/src/lib/cache.ts
Property routes cache computed results, add TTL categories, and update missing-data responses.
Property rendering and query tracing
apps/one/src/app/property/[id]/page.tsx, apps/one/src/components/property/LazySection.tsx, apps/one/src/lib/query-trace.ts
The property page caches additional queries, records request statistics, handles anonymous valuation state, and defers sections until visible.

Database statistics and snapshots

Layer / File(s) Summary
Admin database statistics endpoint
apps/one/src/app/api/admin/db-stats/*
Adds API-key authentication, concurrent read-only statistics queries, JSON output, and route tests.
Weekly statistics snapshot automation
ops/monitoring/*, ops/systemd/*
Adds persistent statistics history tables, snapshot validation, and weekly systemd execution.
Database baseline and audit procedures
ops/db/reset-stats.sh, documentation/operations/db-performance.md, docs/superpowers/plans/*
Documents baseline resets, snapshot windows, index audits, PgBouncer operations, and planned database maintenance.

Pooled and direct database connections

Layer / File(s) Summary
PgBouncer configuration and authentication
ops/pgbouncer/*, ops/systemd/oper-pgbouncer.service
Adds transaction-pooling configuration, userlist generation, example credentials, and a managed PgBouncer service.
Pooled and direct environment wiring
ops/systemd/gen-env.sh, ops/systemd/deploy-systemd.sh
Generates pooled and direct URLs and wires PgBouncer into deployment service mappings.
Worker listener connection targets
apps/worker/src/env.ts, apps/worker/src/crawl.ts, apps/worker/src/rent-estimator.ts
Adds direct URL configuration and uses it for worker LISTEN clients.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Admin
  participant DbStatsRoute
  participant PostgreSQL
  Admin->>DbStatsRoute: Request database statistics with admin key
  DbStatsRoute->>PostgreSQL: Run statement and index statistics queries
  PostgreSQL-->>DbStatsRoute: Return statistics rows
  DbStatsRoute-->>Admin: Return statistics JSON
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main property-page performance work: caching, parallelization, and lazy-loading.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/property-page-fanout

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

🧹 Nitpick comments (2)
apps/one/src/components/property/LazySection.tsx (1)

11-32: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider exposing id/className on the wrapper <div>.

Allowing callers to set an id/className on the always-rendered outer <div> would let section anchors (scroll-mt-32, id="...") live on a stable element, which directly fixes the in-page navigation issue flagged in page.tsx. The observer lifecycle itself is correct.

♻️ Optional: forward id/className to the wrapper
 interface LazySectionProps {
   children: ReactNode;
   fallback?: ReactNode;
   rootMargin?: string;
+  id?: string;
+  className?: string;
 }

-export function LazySection({ children, fallback = null, rootMargin = '200px' }: LazySectionProps) {
+export function LazySection({ children, fallback = null, rootMargin = '200px', id, className }: LazySectionProps) {
@@
-  return <div ref={ref}>{visible ? children : fallback}</div>;
+  return <div ref={ref} id={id} className={className}>{visible ? children : fallback}</div>;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/one/src/components/property/LazySection.tsx` around lines 11 - 32,
Update LazySectionProps and the LazySection wrapper div to accept and forward
optional id and className values, while preserving the existing observer and
visibility behavior. Ensure these attributes are applied to the always-rendered
outer wrapper so anchors and styling remain stable.
apps/one/src/lib/query-trace.ts (1)

67-69: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

Scope the async context after resetting stats.

resetRequestStats() currently uses AsyncLocalStorage.enterWith(), which patches the node-level context and can let one request’s trace stats bleed into the next request in the same worker if called before the handler runs. Gate this behind QUERY_TRACE === '1' and, if enabling tracing outside local debugging, wrap the request’s request-store entry/reset in els.run() so the context has a bounded scope.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/one/src/lib/query-trace.ts` around lines 67 - 69, Update
resetRequestStats to only reset stats when QUERY_TRACE equals '1', avoiding
enterWith for disabled tracing. For tracing enabled outside local debugging,
establish the request store and perform the reset within els.run() so the async
context is bounded to that request rather than leaking across handlers.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/one/src/app/api/admin/db-stats/route.ts`:
- Around line 28-63: Move pool.connect() into the existing try block in the
route handler so connection-acquisition failures are passed to
safeErrorResponse. Track whether client acquisition succeeded, and only call
client.release() in finally when a client is available.

In `@apps/one/src/app/api/properties/`[id]/comps/route.ts:
- Around line 104-114: Both comps routes convert backend failures into 404
responses by returning null from their cached callbacks. In
apps/one/src/app/api/properties/[id]/comps/route.ts lines 104-114 and
apps/one/src/app/api/properties/[id]/rental-comps/route.ts lines 102-112, update
the catch handling to propagate database or connection errors or explicitly
return a 500 response, while preserving 404 handling only for genuine missing
data.

In `@apps/one/src/app/api/properties/`[id]/context/route.ts:
- Around line 43-323: Update the context aggregation around the Promise.all
sub-queries and outer cached callback to distinguish query failures from
legitimate empty results. Track whether any sub-query, especially getWalkScore
and the database calls, errored; when an error occurs, return the response
without writing it to the one-hour CACHE_TTL.context cache (or apply the
established short-failure TTL), while preserving normal caching for successful
results and valid null/default values.

In `@apps/one/src/app/api/properties/`[id]/history/route.ts:
- Around line 31-35: Update the route handler to await the async params object
before reading params.id. Strengthen the id validation near the existing Invalid
property id response to accept only integer-like strings, rejecting decimal,
exponent, and other non-integer formats before calling BigInt(id) or querying
history; preserve the 400 response for invalid identifiers.

In `@apps/one/src/app/property/`[id]/page.tsx:
- Around line 391-404: Move the anchor id and scroll-mt-32 styling from each
inner lazy section to its corresponding LazySection wrapper for comps, analysis,
calculator, nearby, risk, neighborhood, and market. Pass stable id and className
props to LazySection, and remove the duplicate id/className anchor attributes
from the nested section elements so StickyTabNav can resolve anchors before lazy
content loads.

In `@apps/worker/src/env.ts`:
- Line 120: Update the DATABASE_URL_DIRECT configuration in the environment
parsing flow to avoid falling back to the pooled DATABASE_URL. Require a
dedicated direct PostgreSQL URL for listener workers, or validate that any
fallback is explicitly a direct connection URL before accepting it.

In `@docs/superpowers/plans/2026-07-24-db-backend-performance.md`:
- Around line 23-25: Confirm the July 24 production state, then reconcile the
documented baseline so both sources describe the same deployment before-state.
Update docs/superpowers/plans/2026-07-24-db-backend-performance.md at lines
23-25 and documentation/operations/db-performance.md at lines 79-82 and 185-188:
correct the rent_predictions_audit_old presence/size and PgBouncer status based
on the confirmed state, without leaving contradictory claims.

In `@ops/db/reset-stats.sh`:
- Around line 2-4: Clarify the reset documentation to state that
pg_stat_statements_reset() establishes only the slow-query/statement-statistics
baseline, not an index-usage reset. Update ops/db/reset-stats.sh lines 2-4 and
the corresponding note in apps/one/src/app/api/admin/db-stats/route.ts lines
8-11; in apps/one/src/app/api/admin/db-stats/README.md lines 38-41, require the
first immediate post-deployment index snapshot as the audit baseline and use
only subsequent snapshot deltas.

In `@ops/monitoring/pg-stat-snapshot.sh`:
- Around line 37-45: Persist a PostgreSQL statistics epoch, such as
pg_postmaster_start_time(), in every perf_index_scan_history snapshot in
ops/monitoring/pg-stat-snapshot.sh, and use it to prevent comparisons across
resets. Update documentation/operations/db-performance.md lines 40-42 to state
that restarts invalidate the active measurement window, and lines 249-267 to
compare only snapshots from the same epoch. Update
docs/superpowers/plans/2026-07-24-db-backend-performance.md line 13 to require
an uninterrupted measurement window before removing indexes.

In `@ops/pgbouncer/gen-pgbouncer-userlist.sh`:
- Line 28: Update the userlist generation around the printf that writes "$TMP"
so it emits an entry for each role handled by write_role_env(), rather than
hard-coding only the postgres user. Ensure every role-specific credential used
by the generated pooled URLs is present in the PgBouncer auth file, while
preserving the existing password hash format.

In `@ops/pgbouncer/pgbouncer.ini`:
- Around line 7-8: Update the authentication configuration referenced by
auth_file so its credentials match PostgreSQL’s pg_hba.conf method: add the
corresponding SCRAM secret when PostgreSQL requires SCRAM, or align pg_hba.conf
to accept MD5 when MD5 is intentional. Preserve auth_type = md5 only when the
backend authentication methods are compatible.

In `@ops/systemd/gen-env.sh`:
- Around line 36-37: Update both DATABASE_URL and DATABASE_URL_DIRECT
assignments in gen-env.sh to pass POSTGRES_PASSWORD to urlencode through stdin
rather than as a command-line argument, and adjust the urlencode helper’s input
handling accordingly. Preserve URL encoding and ensure the raw password never
appears in process arguments.

In `@ops/systemd/oper-pgbouncer.service`:
- Line 10: Update the oper-pgbouncer.service unit’s User setting from root to
the dedicated pgbouncer account, and ensure the service provisions or grants
that account ownership/read access to the PgBouncer configuration and generated
auth file while preserving the existing loopback listener behavior.

---

Nitpick comments:
In `@apps/one/src/components/property/LazySection.tsx`:
- Around line 11-32: Update LazySectionProps and the LazySection wrapper div to
accept and forward optional id and className values, while preserving the
existing observer and visibility behavior. Ensure these attributes are applied
to the always-rendered outer wrapper so anchors and styling remain stable.

In `@apps/one/src/lib/query-trace.ts`:
- Around line 67-69: Update resetRequestStats to only reset stats when
QUERY_TRACE equals '1', avoiding enterWith for disabled tracing. For tracing
enabled outside local debugging, establish the request store and perform the
reset within els.run() so the async context is bounded to that request rather
than leaking across handlers.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7fa2f5fb-3189-414f-925b-e9fcac906ba5

📥 Commits

Reviewing files that changed from the base of the PR and between a4cffeb and 00e8f35.

📒 Files selected for processing (28)
  • apps/one/src/app/api/admin/db-stats/README.md
  • apps/one/src/app/api/admin/db-stats/route.test.ts
  • apps/one/src/app/api/admin/db-stats/route.ts
  • apps/one/src/app/api/properties/[id]/comps/route.ts
  • apps/one/src/app/api/properties/[id]/context/route.ts
  • apps/one/src/app/api/properties/[id]/history/route.ts
  • apps/one/src/app/api/properties/[id]/rental-comps/route.ts
  • apps/one/src/app/property/[id]/page.tsx
  • apps/one/src/components/property/LazySection.tsx
  • apps/one/src/lib/cache.ts
  • apps/one/src/lib/query-trace.ts
  • apps/worker/src/crawl.ts
  • apps/worker/src/env.ts
  • apps/worker/src/rent-estimator.ts
  • docs/superpowers/plans/2026-07-24-db-backend-performance.md
  • documentation/operations/db-performance.md
  • ops/db/reset-stats.sh
  • ops/monitoring/pg-stat-snapshot.sh
  • ops/monitoring/pg-stat-snapshot.test.sh
  • ops/pgbouncer/gen-pgbouncer-userlist.sh
  • ops/pgbouncer/pgbouncer.ini
  • ops/pgbouncer/userlist.txt.example
  • ops/systemd/deploy-systemd.sh
  • ops/systemd/gen-env.sh
  • ops/systemd/install.sh
  • ops/systemd/oper-pg-stat.service
  • ops/systemd/oper-pg-stat.timer
  • ops/systemd/oper-pgbouncer.service

Comment on lines +28 to +63
const client = await pool.connect();
try {
const [totalTime, meanTime, idxUsage] = await Promise.all([
client.query(
`SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20`,
),
client.query(
`SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 20`,
),
client.query(
`SELECT s.schemaname, s.relname, s.indexrelname, s.idx_scan,
t.idx_blks_read,
pg_relation_size(s.indexrelid) AS size_bytes
FROM pg_stat_user_indexes s
LEFT JOIN pg_statio_user_indexes t
ON s.indexrelid = t.indexrelid
ORDER BY size_bytes DESC
LIMIT 50`,
),
]);

return NextResponse.json({
topByTotalTime: totalTime.rows,
topByMeanTime: meanTime.rows,
indexUsage: idxUsage.rows,
});
} catch (error) {
return safeErrorResponse(error, 500);
} finally {
client.release();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Handle connection-acquisition failures through safeErrorResponse.

pool.connect() runs before try, so a pool exhaustion or database outage rejects the handler without the intended error response. Acquire the client inside try and only release it when acquisition succeeded.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/one/src/app/api/admin/db-stats/route.ts` around lines 28 - 63, Move
pool.connect() into the existing try block in the route handler so
connection-acquisition failures are passed to safeErrorResponse. Track whether
client acquisition succeeded, and only call client.release() in finally when a
client is available.

Comment thread apps/one/src/app/api/properties/[id]/comps/route.ts
Comment on lines +43 to +323
const [
riskNri,
riskFlood,
riskDisasters,
riskParcelSfha,
neighborhoodWalkability,
neighborhoodWalkscore,
neighborhoodTransit,
neighborhoodSchools,
neighborhoodCrime,
marketHpi,
marketUnemployment,
] = await Promise.all([
(async () => {
if (!tractGeoid) return null;
try {
const res = await pool.query(
`SELECT nri_overall_score, nri_overall_rating,
nri_flood_riverine_score, nri_flood_coastal_score
FROM census_tracts WHERE geoid = $1`,
[tractGeoid],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
return {
nri_overall_score: r.nri_overall_score != null ? Number(r.nri_overall_score) : null,
nri_overall_rating: r.nri_overall_rating ?? null,
nri_flood_riverine: r.nri_flood_riverine_score != null ? Number(r.nri_flood_riverine_score) : null,
nri_flood_coastal: r.nri_flood_coastal_score != null ? Number(r.nri_flood_coastal_score) : null,
};
} catch {
return null;
}
})(),

// ── Risk: FEMA disasters (last 10 fiscal years) ──
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT incident_type, SUM(declarations) AS declarations
FROM fema_disasters
WHERE fips = $1 AND fy >= EXTRACT(YEAR FROM now()) - 10
GROUP BY incident_type
ORDER BY declarations DESC`,
[countyFips],
);
const disasters: Record<string, number> = {};
for (const r of res.rows) {
disasters[r.incident_type] = Number(r.declarations);
(async () => {
try {
const res = await pool.query(
`SELECT * FROM flood_zone_at($1, $2)`,
[lat, lng],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
return {
flood_zone: r.fld_zone ?? null,
flood_sfha: r.sfha ?? null,
};
} catch {
return null;
}
return disasters;
} catch {
return null;
}
})(),
})(),

// ── Risk: parcel % in SFHA ──
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT avg(pct_in_sfha) AS avg_pct
FROM parcel_flood_exposure
WHERE county_fips = $1 AND pct_in_sfha IS NOT NULL`,
[countyFips],
);
if (res.rows.length === 0 || res.rows[0].avg_pct == null) return null;
return Math.round(Number(res.rows[0].avg_pct) * 1000) / 10;
} catch {
return null;
}
})(),
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT incident_type, SUM(declarations) AS declarations
FROM fema_disasters
WHERE fips = $1 AND fy >= EXTRACT(YEAR FROM now()) - 10
GROUP BY incident_type
ORDER BY declarations DESC`,
[countyFips],
);
const disasters: Record<string, number> = {};
for (const r of res.rows) {
disasters[r.incident_type] = Number(r.declarations);
}
return disasters;
} catch {
return null;
}
})(),

// ── Neighborhood: walkability index (EPA) ──
(async () => {
if (!tractGeoid) return null;
try {
const res = await pool.query(
`SELECT natwalkind FROM tract_walkability WHERE geoid = $1`,
[tractGeoid],
);
if (res.rows.length === 0) return null;
return res.rows[0].natwalkind != null ? Number(res.rows[0].natwalkind) : null;
} catch {
return null;
}
})(),
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT avg(pct_in_sfha) AS avg_pct
FROM parcel_flood_exposure
WHERE county_fips = $1 AND pct_in_sfha IS NOT NULL`,
[countyFips],
);
if (res.rows.length === 0 || res.rows[0].avg_pct == null) return null;
return Math.round(Number(res.rows[0].avg_pct) * 1000) / 10;
} catch {
return null;
}
})(),

// ── Neighborhood: Walk Score API ──
(async () => {
if (!address) return null;
try {
const ws = await getWalkScore(address, lat, lng);
if (!ws) return null;
return {
walk: ws.walk,
transit: ws.transit,
bike: ws.bike,
link: ws.link,
};
} catch {
return null;
}
})(),
(async () => {
if (!tractGeoid) return null;
try {
const res = await pool.query(
`SELECT natwalkind FROM tract_walkability WHERE geoid = $1`,
[tractGeoid],
);
if (res.rows.length === 0) return null;
return res.rows[0].natwalkind != null ? Number(res.rows[0].natwalkind) : null;
} catch {
return null;
}
})(),

// ── Neighborhood: transit stops (800m) + nearest rail ──
(async () => {
try {
const stopsRes = await pool.query(
`SELECT COUNT(*) AS cnt
FROM transit_stops
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 800)`,
[lng, lat],
);
const stopsCount = Number(stopsRes.rows[0].cnt);
(async () => {
if (!address) return null;
try {
const ws = await getWalkScore(address, lat, lng);
if (!ws) return null;
return {
walk: ws.walk,
transit: ws.transit,
bike: ws.bike,
link: ws.link,
};
} catch {
return null;
}
})(),

let nearestRailKm: number | null = null;
(async () => {
try {
const railRes = await pool.query(
`SELECT MIN(
ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography)
) / 1000.0 AS dist_km
const stopsRes = await pool.query(
`SELECT COUNT(*) AS cnt
FROM transit_stops
WHERE route_types && '{0,1,2}'
AND ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 10000)`,
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 800)`,
[lng, lat],
);
if (railRes.rows.length > 0 && railRes.rows[0].dist_km != null) {
nearestRailKm = Math.round(Number(railRes.rows[0].dist_km) * 100) / 100;
const stopsCount = Number(stopsRes.rows[0].cnt);

let nearestRailKm: number | null = null;
try {
const railRes = await pool.query(
`SELECT MIN(
ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography)
) / 1000.0 AS dist_km
FROM transit_stops
WHERE route_types && '{0,1,2}'
AND ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 10000)`,
[lng, lat],
);
if (railRes.rows.length > 0 && railRes.rows[0].dist_km != null) {
nearestRailKm = Math.round(Number(railRes.rows[0].dist_km) * 100) / 100;
}
} catch {
// Rail query failed — non-fatal
}

return { stopsCount, nearestRailKm };
} catch {
// Rail query failed — non-fatal
return { stopsCount: 0, nearestRailKm: null };
}
})(),

return { stopsCount, nearestRailKm };
} catch {
return { stopsCount: 0, nearestRailKm: null };
}
})(),

// ── Neighborhood: schools (1600m) ──
(async () => {
try {
const res = await pool.query(
`SELECT name, level,
ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000.0 AS dist_km
FROM schools
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 1600)
ORDER BY dist_km ASC`,
[lng, lat],
);
return res.rows.map((r: any) => ({
name: r.name,
level: r.level,
dist_km: Math.round(Number(r.dist_km) * 100) / 100,
}));
} catch {
return [];
}
})(),
(async () => {
try {
const res = await pool.query(
`SELECT name, level,
ST_Distance(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography) / 1000.0 AS dist_km
FROM schools
WHERE ST_DWithin(geom::geography, ST_SetSRID(ST_MakePoint($1, $2), 4326)::geography, 1600)
ORDER BY dist_km ASC`,
[lng, lat],
);
return res.rows.map((r: any) => ({
name: r.name,
level: r.level,
dist_km: Math.round(Number(r.dist_km) * 100) / 100,
}));
} catch {
return [];
}
})(),

// ── Neighborhood: crime (county, latest year) ──
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT violent_per_100k, property_per_100k, agencies_reporting
FROM crime_county
WHERE fips = $1
ORDER BY year DESC LIMIT 1`,
[countyFips],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
const agencies = Number(r.agencies_reporting);
if (agencies < 2) {
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT violent_per_100k, property_per_100k, agencies_reporting
FROM crime_county
WHERE fips = $1
ORDER BY year DESC LIMIT 1`,
[countyFips],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
const agencies = Number(r.agencies_reporting);
if (agencies < 2) {
return {
violent_per_100k: null,
property_per_100k: null,
coverage_note: `Insufficient agency coverage (${agencies} agencies reporting)`,
};
}
return {
violent_per_100k: null,
property_per_100k: null,
coverage_note: `Insufficient agency coverage (${agencies} agencies reporting)`,
violent_per_100k: r.violent_per_100k != null ? Number(r.violent_per_100k) : null,
property_per_100k: r.property_per_100k != null ? Number(r.property_per_100k) : null,
coverage_note: `${agencies} agencies reporting`,
};
} catch {
return null;
}
return {
violent_per_100k: r.violent_per_100k != null ? Number(r.violent_per_100k) : null,
property_per_100k: r.property_per_100k != null ? Number(r.property_per_100k) : null,
coverage_note: `${agencies} agencies reporting`,
};
} catch {
return null;
}
})(),
})(),

// ── Market: FHFA ZIP HPI (series + sanity-clamped CAGR w/ span) ──
(async () => {
if (!zipCode) return { series: [], cagr: null, cagrSpanYears: null };
try {
const res = await pool.query(
`SELECT year, hpi
FROM fhfa_zip_hpi
WHERE zip5 = $1
ORDER BY year ASC`,
[zipCode],
);
const series = res.rows
.filter((r: any) => r.hpi != null)
.map((r: any) => ({ year: Number(r.year), hpi: Number(r.hpi) }));
(async () => {
if (!zipCode) return { series: [], cagr: null, cagrSpanYears: null };
try {
const res = await pool.query(
`SELECT year, hpi
FROM fhfa_zip_hpi
WHERE zip5 = $1
ORDER BY year ASC`,
[zipCode],
);
const series = res.rows
.filter((r: any) => r.hpi != null)
.map((r: any) => ({ year: Number(r.year), hpi: Number(r.hpi) }));

const cagrResult = computeHpiCagr(series);
return {
series,
cagr: cagrResult?.cagrPct ?? null,
cagrSpanYears: cagrResult?.spanYears ?? null,
};
} catch {
return { series: [], cagr: null, cagrSpanYears: null };
}
})(),
const cagrResult = computeHpiCagr(series);
return {
series,
cagr: cagrResult?.cagrPct ?? null,
cagrSpanYears: cagrResult?.spanYears ?? null,
};
} catch {
return { series: [], cagr: null, cagrSpanYears: null };
}
})(),

// ── Market: BLS county unemployment (latest) ──
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT period, unemployment_rate
FROM bls_county_laus
WHERE fips = $1
ORDER BY period DESC LIMIT 1`,
[countyFips],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
return {
unemployment_rate: r.unemployment_rate != null ? Number(r.unemployment_rate) : null,
period: r.period instanceof Date
? r.period.toISOString().slice(0, 7)
: String(r.period).slice(0, 7),
};
} catch {
return null;
}
})(),
]);
(async () => {
if (!countyFips) return null;
try {
const res = await pool.query(
`SELECT period, unemployment_rate
FROM bls_county_laus
WHERE fips = $1
ORDER BY period DESC LIMIT 1`,
[countyFips],
);
if (res.rows.length === 0) return null;
const r = res.rows[0];
return {
unemployment_rate: r.unemployment_rate != null ? Number(r.unemployment_rate) : null,
period: r.period instanceof Date
? r.period.toISOString().slice(0, 7)
: String(r.period).slice(0, 7),
};
} catch {
return null;
}
})(),
]);

// 3. Assemble response
const context = {
risk: {
nri_overall_score: riskNri?.nri_overall_score ?? null,
nri_overall_rating: riskNri?.nri_overall_rating ?? null,
nri_flood_riverine: riskNri?.nri_flood_riverine ?? null,
nri_flood_coastal: riskNri?.nri_flood_coastal ?? null,
flood_zone: riskFlood?.flood_zone ?? null,
flood_sfha: riskFlood?.flood_sfha ?? null,
disasters: riskDisasters ?? {},
parcel_pct_in_sfha: riskParcelSfha ?? null,
},
neighborhood: {
walkability_index: neighborhoodWalkability ?? null,
walkscore: neighborhoodWalkscore ?? null,
transit_stops_800m: neighborhoodTransit?.stopsCount ?? 0,
nearest_rail_km: neighborhoodTransit?.nearestRailKm ?? null,
schools_1600m: neighborhoodSchools ?? [],
hh_nearby_schools: listing.nearby_schools ?? null,
crime: neighborhoodCrime ?? null,
},
market: {
hpi: marketHpi?.series ?? [],
cagr: marketHpi?.cagr ?? null,
cagr_span_years: marketHpi?.cagrSpanYears ?? null,
unemployment: marketUnemployment?.unemployment_rate ?? null,
county_unemployment_period: marketUnemployment?.period ?? null,
},
};
return {
risk: {
nri_overall_score: riskNri?.nri_overall_score ?? null,
nri_overall_rating: riskNri?.nri_overall_rating ?? null,
nri_flood_riverine: riskNri?.nri_flood_riverine ?? null,
nri_flood_coastal: riskNri?.nri_flood_coastal ?? null,
flood_zone: riskFlood?.flood_zone ?? null,
flood_sfha: riskFlood?.flood_sfha ?? null,
disasters: riskDisasters ?? {},
parcel_pct_in_sfha: riskParcelSfha ?? null,
},
neighborhood: {
walkability_index: neighborhoodWalkability ?? null,
walkscore: neighborhoodWalkscore ?? null,
transit_stops_800m: neighborhoodTransit?.stopsCount ?? 0,
nearest_rail_km: neighborhoodTransit?.nearestRailKm ?? null,
schools_1600m: neighborhoodSchools ?? [],
hh_nearby_schools: listing.nearby_schools ?? null,
crime: neighborhoodCrime ?? null,
},
market: {
hpi: marketHpi?.series ?? [],
cagr: marketHpi?.cagr ?? null,
cagr_span_years: marketHpi?.cagrSpanYears ?? null,
unemployment: marketUnemployment?.unemployment_rate ?? null,
county_unemployment_period: marketUnemployment?.period ?? null,
},
};
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Partial/degraded results get cached for an hour.

Each sub-query swallows its own errors and returns null/defaults, but the outer callback always returns a fully-assembled (non-null) object, so cached() persists it under CACHE_TTL.context (3600s). If a single sub-query hits a transient failure (DB hiccup, or the external Walk Score call at Line 149 timing out), the degraded response is served for up to an hour even after recovery. This is amplified by the move from revalidate = 60 to the 3600s TTL. Consider distinguishing "error" from "empty" and skipping the cache write (or using a short TTL) when a sub-query errored.

🧰 Tools
🪛 ESLint

[error] 206-206: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 257-257: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)


[error] 258-258: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/one/src/app/api/properties/`[id]/context/route.ts around lines 43 - 323,
Update the context aggregation around the Promise.all sub-queries and outer
cached callback to distinguish query failures from legitimate empty results.
Track whether any sub-query, especially getWalkScore and the database calls,
errored; when an error occurs, return the response without writing it to the
one-hour CACHE_TTL.context cache (or apply the established short-failure TTL),
while preserving normal caching for successful results and valid null/default
values.

Comment on lines 31 to 35
if (!id || isNaN(Number(id))) {
return NextResponse.json({ error: 'Invalid property id', points: [] }, { status: 400 });
}

const listingId = BigInt(id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Next.js 16 route handler params Promise async await

💡 Result:

In Next.js 16, the params object passed to Route Handlers is a Promise that must be awaited before its properties can be accessed [1][2]. This change was introduced as part of the transition to asynchronous Request APIs [3]. While Next.js 15 provided a temporary synchronous compatibility shim (which triggered warnings), Next.js 16 has fully removed this support, making await mandatory [3][4][5]. Accessing params properties synchronously in Next.js 16 will result in errors [4][5]. Implementation Example To access dynamic route parameters in a Route Handler, you must define the params type as a Promise and await it within your handler function [1][2]: // app/dashboard/[team]/route.ts export async function GET( request: Request, { params }: { params: Promise<{ team: string }> }) { // Await the params promise before accessing properties const { team } = await params; return Response.json({ team }); } Recommended Migration Steps 1. Use Codemods: You can run the npx @next/codemod@latest migrate-to-async-dynamic-apis command to automatically update your codebase to the async pattern [3][5]. 2. Generate Types: Use npx next typegen to automatically generate the correct RouteContext and PageProps types, which helps ensure your code is type-safe and identifies missing await statements [3][4][5]. 3. Audit Code: Because the synchronous compatibility shim is removed, ensure every instance of params (as well as searchParams, cookies(), headers(), and draftMode()) is audited and properly awaited [3][4][5].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate target and package =="
fd -a 'route\.ts$' apps/one/src/app/api/properties | sed -n '1,80p'
printf '\n== package next version definitions =='
rg -n '"next":|next@|\.next/' apps/one/package.json package.json apps/one 2>/dev/null | sed -n '1,120p' || true

echo
echo "== target file outline =="
ast-grep outline apps/one/src/app/api/properties/[id]/history/route.ts --view expanded || true

echo
echo "== target file relevant lines =="
cat -n apps/one/src/app/api/properties/[id]/history/route.ts | sed -n '1,120p'

echo
echo "== sibling routes params type examples =="
rg -n "Promise<\\{ id: string \\}>|await params|params: \\{ id: string \\}|" apps/one/src/app/api -g 'route.ts' | sed -n '1,200p'

echo
echo "== BigInt(Number) behavioral probe =="
node - <<'JS'
const values = ["1", "1.5", "1e3", "1.0", "-1", "01", "", "abc"];
for (const id of values) {
  try {
    const valid = !id || isNaN(Number(id)) ? "rejected" : "accepted";
    const parsed = BigInt(id);
    console.log(JSON.stringify({id, valid, parsed, error:null}));
  } catch (err) {
    console.log(JSON.stringify({id, valid:"accepted", parsed:null, error: err.name + ": " + err.message}));
  }
}
JS

Repository: jonquixote/OnePercentRealEstate

Length of output: 21108


🌐 Web query:

Next.js route handlers BigInt JSON response error TypeError cannot serialize BigInt

💡 Result:

The error TypeError: Do not know how to serialize a BigInt occurs because the JSON standard does not support the BigInt data type, and the native JSON.stringify method used by Next.js route handlers and other serialization processes does not automatically handle it [1][2]. To resolve this issue, you must convert BigInt values into a format that JSON supports (typically strings or numbers) before the response is serialized [3][2]. Recommended solutions: 1. Use a Replacer Function You can pass a replacer function to JSON.stringify to explicitly handle BigInt values [2]. This is the safest approach as it avoids modifying global prototypes [2]. Example: return Response.json(data, { replacer: (key, value) => (typeof value === 'bigint'? value.toString: value) }); 2. Define a Global toJSON Method If you prefer a global solution, you can add a toJSON method to the BigInt prototype [2]. This causes JSON.stringify to automatically use this method whenever it encounters a BigInt [2]. Note that this affects the entire application [2]. Example: (BigInt.prototype as any).toJSON = function { return this.toString; }; 3. Use Specialized Serialization Libraries For complex data structures involving many non-serializable types (like BigInt, Date, Map, or Set), consider using libraries like superjson, which are designed to handle these types natively [3]. Important Note: Be aware of potential precision loss if you convert BigInts to numbers, as BigInt is designed to represent integers larger than Number.MAX_SAFE_INTEGER [2]. Converting to a string is generally recommended to preserve the exact value [2]. Additionally, be cautious of security implications if you are deserializing these strings, as very large BigInts can lead to performance issues or Denial of Service (DoS) risks [4].

Citations:


Await params and reject non-integer id before building history rows.

With Next 16, { params } is async: use await params so params.id can be read in handler mode. Also, isNaN(Number(id)) accepts values like "1.5" and "1e3", which throw in BigInt(id) and bubble to the catch as 500s; validate that id is an integer-like string before converting/querying.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/one/src/app/api/properties/`[id]/history/route.ts around lines 31 - 35,
Update the route handler to await the async params object before reading
params.id. Strengthen the id validation near the existing Invalid property id
response to accept only integer-like strings, rejecting decimal, exponent, and
other non-integer formats before calling BigInt(id) or querying history;
preserve the 400 response for invalid identifiers.

Comment thread apps/one/src/app/property/[id]/page.tsx Outdated
Comment on lines +37 to +45
CREATE TABLE IF NOT EXISTS perf_index_scan_history (
captured_at TIMESTAMPTZ NOT NULL,
schemaname TEXT,
relname TEXT,
indexrelname TEXT,
idx_scan BIGINT,
idx_blks_read BIGINT,
size_bytes BIGINT
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Invalidate index-audit windows across PostgreSQL statistics resets. idx_scan resets after restart; without recording a counter epoch, an index used before a restart can later appear to have a zero delta and become an unsafe drop candidate.

  • ops/monitoring/pg-stat-snapshot.sh#L37-L45: persist a restart/reset marker (for example, pg_postmaster_start_time()) with each index snapshot.
  • documentation/operations/db-performance.md#L40-L42: state that a restart invalidates the active index-measurement window.
  • documentation/operations/db-performance.md#L249-L267: compare snapshots only from the same statistics epoch.
  • docs/superpowers/plans/2026-07-24-db-backend-performance.md#L13-L13: require an uninterrupted measurement window before index removal.
📍 Affects 3 files
  • ops/monitoring/pg-stat-snapshot.sh#L37-L45 (this comment)
  • documentation/operations/db-performance.md#L40-L42
  • documentation/operations/db-performance.md#L249-L267
  • docs/superpowers/plans/2026-07-24-db-backend-performance.md#L13-L13
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/monitoring/pg-stat-snapshot.sh` around lines 37 - 45, Persist a
PostgreSQL statistics epoch, such as pg_postmaster_start_time(), in every
perf_index_scan_history snapshot in ops/monitoring/pg-stat-snapshot.sh, and use
it to prevent comparisons across resets. Update
documentation/operations/db-performance.md lines 40-42 to state that restarts
invalidate the active measurement window, and lines 249-267 to compare only
snapshots from the same epoch. Update
docs/superpowers/plans/2026-07-24-db-backend-performance.md line 13 to require
an uninterrupted measurement window before removing indexes.

TMP=$(mktemp "${OUT}.XXXXXX")
trap 'rm -f "$TMP"' EXIT

printf '"postgres" "md5%s"\n' "$HASH" > "$TMP"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Keep PgBouncer users synchronized with role-specific URLs.

This always writes only the postgres entry, while write_role_env() emits pooled URLs for arbitrary roles. Any role other than postgres will be rejected at port 6432 unless its credential is also present in the auth file or supplied through an auth-query mechanism. (pgbouncer.org)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/pgbouncer/gen-pgbouncer-userlist.sh` at line 28, Update the userlist
generation around the printf that writes "$TMP" so it emits an entry for each
role handled by write_role_env(), rather than hard-coding only the postgres
user. Ensure every role-specific credential used by the generated pooled URLs is
present in the PgBouncer auth file, while preserving the existing password hash
format.

Comment on lines +7 to +8
auth_type = md5
auth_file = /opt/onepercent/ops/pgbouncer/userlist.txt

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
rg -n 'password_encryption|pg_hba|auth_type|auth_file|auth_query|SCRAM|md5' .

Repository: jonquixote/OnePercentRealEstate

Length of output: 4649


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '--- candidate files ---\n'
git ls-files | rg '(^ops/pgbouncer/|^ops/systemd/cutover\.sh|^ops/scraper-node/README\.md|^documentation/operations/db-performance\.md|^ops/pgbouncer/gen-pgbouncer-userlist\.sh)' || true

printf '\n--- pgbouncer config ---\n'
cat -n ops/pgbouncer/pgbouncer.ini

printf '\n--- userlist generator ---\n'
cat -n ops/pgbouncer/gen-pgbouncer-userlist.sh

printf '\n--- cutover pg_hba snippet ---\n'
sed -n '90,115p' ops/systemd/cutover.sh | cat -n

printf '\n--- database performance pg_hba/config snippet ---n'
sed -n '90,170p' documentation/operations/db-performance.md | cat -n

printf '\n--- postgres password encryption docs/config references ---n'
rg -n "password_encryption|scram" ops documentation services || true

printf '\n--- read-only hash-shape probe (no repo code execution) ---\n'
python3 - <<'PY'
import subprocess
import hashlib, base64
password="secret"
username="postgres"
digest=hashlib.md5(f"{password}{username}".encode(), usedforsecurity=False).hexdigest()
print('post_hash=', digest)
print('pgbouncer_line=', f'"{username}" "md5{digest}"')
# PostgreSQL MD5 auth verifier for a host login is md5(md5(password+username)+username+salt)
# as a fixed verifier in auth_file, PgBouncer can authenticate with plaintext password
# via PgBouncer if PostgreSQL accepts md5 on that hba entry.
PY

Repository: jonquixote/OnePercentRealEstate

Length of output: 260


Keep the MD5 auth file compatible with PostgreSQL.

PgBouncer’s auth_type = md5 uses the generated md5... entry to authenticate when PostgreSQL’s pg_hba.conf also uses md5. If PostgreSQL requires SCRAM and this backend is not intentionally accepting MD5, PgBouncer will not be able to authenticate to the database from this file; add a matching SCRAM secret or ensure the PostgreSQL authentication method is aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/pgbouncer/pgbouncer.ini` around lines 7 - 8, Update the authentication
configuration referenced by auth_file so its credentials match PostgreSQL’s
pg_hba.conf method: add the corresponding SCRAM secret when PostgreSQL requires
SCRAM, or align pg_hba.conf to accept MD5 when MD5 is intentional. Preserve
auth_type = md5 only when the backend authentication methods are compatible.

Comment thread ops/systemd/gen-env.sh
Comment on lines +36 to +37
DATABASE_URL=postgresql://postgres:$(urlencode "${POSTGRES_PASSWORD}")@localhost:6432/postgres
DATABASE_URL_DIRECT=postgresql://postgres:$(urlencode "${POSTGRES_PASSWORD}")@localhost:5432/postgres

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not pass database passwords as process arguments.

Both changed URL assignments call urlencode with the raw password, while the helper passes it as sys.argv[1]. Process arguments can be observed during deployment; pipe the secret through stdin instead.

Proposed secret-safe encoder
 urlencode() {
-  python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.argv[1],safe=""))' "$1"
+  printf '%s' "$1" |
+    python3 -c 'import urllib.parse,sys;print(urllib.parse.quote(sys.stdin.read(),safe=""))'
 }

Also applies to: 123-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/systemd/gen-env.sh` around lines 36 - 37, Update both DATABASE_URL and
DATABASE_URL_DIRECT assignments in gen-env.sh to pass POSTGRES_PASSWORD to
urlencode through stdin rather than as a command-line argument, and adjust the
urlencode helper’s input handling accordingly. Preserve URL encoding and ensure
the raw password never appears in process arguments.


[Service]
Type=simple
User=root

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Run PgBouncer as a dedicated unprivileged user.

The service listens only on loopback port 6432, so root is unnecessary. Use a dedicated pgbouncer account and provision ownership/read access for the configuration and generated auth file.

Proposed unit change
-User=root
+User=pgbouncer
+Group=pgbouncer
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
User=root
User=pgbouncer
Group=pgbouncer
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@ops/systemd/oper-pgbouncer.service` at line 10, Update the
oper-pgbouncer.service unit’s User setting from root to the dedicated pgbouncer
account, and ensure the service provisions or grants that account ownership/read
access to the PgBouncer configuration and generated auth file while preserving
the existing loopback listener behavior.

…validation

- comps/rental-comps: catch→throw + outer try/catch returns 500 (not 404)
- comps/rental-comps: bedrooms COALESCE removed, default in JS (beds ?? 3)
- context: countyFips from fips_code only (drop censusTract fallback)
- history: await params + integer-only id validation (reject decimals/exponents)
- LazySection: accept id/className props, forward to wrapper div
- page.tsx: move anchor id/scroll-mt-32 from inner section to LazySection
- query-trace: add bounded runWithQueryTrace API (als.run vs enterWith)
- cache: clarify TTL stats comment (demographics/compsMedian, not aggregates)
@jonquixote
jonquixote merged commit fa8c687 into main Jul 24, 2026
5 checks passed
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