Skip to main content

OdontoX App Performance — Neon Driver Split + TanStack Query Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Cut OdontoX server roundtrip latency by switching Cloudflare Workers from @neondatabase/serverless Pool (WebSocket) to neon-http for all non-transactional queries, and eliminate “module re-entry is slow” by adding TanStack Query with localStorage persistence. Architecture: Three independent phases. Phase 1 introduces three explicit DB accessors (getReadDb HTTP, getWriteDb HTTP alias, getTxDb Pool with mandatory ctx.waitUntil(end())) and migrates routes in batches by risk. Phase 2 adds @tanstack/react-query v5 + PersistQueryClientProvider with clinicId-scoped cache keys and a build-hash buster. Phase 4 is a bundle/CSS audit. Each phase ships independently. Tech Stack: Hono on Cloudflare Workers · Drizzle ORM · @neondatabase/serverless (HTTP + WebSocket) · Vite + React 19 · @tanstack/react-query v5 · @tanstack/query-sync-storage-persister Spec: docs/superpowers/specs/2026-05-03-app-performance-neon-tanstack-design.md

Pre-flight

Before starting, confirm:
  • Working tree on main (or a feature branch off main)
  • Existing uncommitted changes have been stashed or are unrelated
  • pnpm is installed; pnpm install runs cleanly in server/ and ui/
  • DATABASE_URL set in server/.env for local testing (Neon HTTP works with any Neon URL)

Phase 1 — Server Driver Split

Goal: every read goes via HTTP, only interactive transactions use Pool.

Task 1: Add getReadDb, getWriteDb, getTxDb accessors

Files:
  • Modify: server/src/lib/db.ts
  • Test: server/src/lib/db.test.ts (new)
  • Step 1: Write the failing test
Create server/src/lib/db.test.ts:
import { describe, it, expect } from 'vitest';
import { getReadDb, getWriteDb, getTxDb } from './db';

describe('db accessors', () => {
  it('getReadDb returns a Drizzle instance with select capability', async () => {
    const db = getReadDb(process.env.DATABASE_URL);
    expect(db).toBeDefined();
    expect(typeof (db as any).select).toBe('function');
  });

  it('getWriteDb is a usable Drizzle instance', async () => {
    const db = getWriteDb(process.env.DATABASE_URL);
    expect(db).toBeDefined();
    expect(typeof (db as any).insert).toBe('function');
  });

  it('getTxDb returns { db, end } where end is a function', async () => {
    const tx = getTxDb(process.env.DATABASE_URL);
    expect(tx.db).toBeDefined();
    expect(typeof tx.end).toBe('function');
    await tx.end();
  });
});
  • Step 2: Run test to verify it fails
Run: cd server && pnpm test src/lib/db.test.ts Expected: FAIL with getReadDb is not exported / similar.
  • Step 3: Replace server/src/lib/db.ts with the new shape
Replace the entire file contents:
import { drizzle } from 'drizzle-orm/neon-serverless';
import { drizzle as createDrizzlePostgres } from 'drizzle-orm/postgres-js';
import { drizzle as drizzleHttp } from 'drizzle-orm/neon-http';
import { Pool, neon } from '@neondatabase/serverless';
import postgres from 'postgres';
import * as schema from '../schema/index';
import { getEnv } from './env';

const isNeonDatabase = (s: string): boolean =>
  s.includes('neon.tech') || s.includes('neon.database') || s.includes('neondatabase.com');

const resolveConn = (connStr?: string): string => {
  const c = connStr || getEnv('DATABASE_URL') || 'postgresql://postgres:password@localhost:5502/postgres';
  if (!c) throw new Error('DATABASE_URL not configured');
  return c;
};

/**
 * READ + single-statement WRITE driver. Default for all routes.
 * HTTP-mode Neon driver — no WebSocket handshake, lowest latency on Workers.
 * Falls back to postgres-js for local dev (non-Neon URLs).
 * Does NOT support interactive transactions — use getTxDb for those.
 */
export const getReadDb = (connStr?: string) => {
  const c = resolveConn(connStr);
  if (isNeonDatabase(c)) {
    return drizzleHttp(neon(c), { schema });
  }
  const client = postgres(c, { prepare: false, max: 1 });
  return createDrizzlePostgres(client, { schema });
};

/**
 * Single-statement WRITE driver. Currently identical to getReadDb.
 * Kept as a separate name to document write-intent at the callsite and to
 * give us a place to plug a write-only knob later if needed.
 */
export const getWriteDb = getReadDb;

/**
 * Interactive TRANSACTION driver. Pool-backed (WebSocket). Only use when
 * you need BEGIN/COMMIT with conditional logic between statements.
 *
 * Returns { db, end }. The CALLER is required to call:
 *   c.executionCtx.waitUntil(end())
 * before returning the response, otherwise the Worker hangs waiting on
 * the connection close.
 */
export const getTxDb = (connStr?: string) => {
  const c = resolveConn(connStr);
  const pool = new Pool({ connectionString: c });
  const db = drizzle(pool, { schema });
  return { db, end: () => pool.end() };
};

/**
 * @deprecated Use getReadDb (most reads), getWriteDb (single writes), or getTxDb
 * (interactive transactions). Kept as an HTTP alias during route migration.
 */
export const getDatabase = async (connStr?: string) => getReadDb(connStr);

/**
 * @deprecated Old name of getReadDb. Kept during migration.
 */
export const getDatabaseHttp = getReadDb;

export const testDatabaseConnection = async (): Promise<boolean> => {
  try {
    const db = getReadDb();
    await (db as any).select().from(schema.users).limit(1);
    return true;
  } catch (e) {
    console.error('DB connection test failed:', e);
    return false;
  }
};

export const clearConnectionCache = (): void => {
  // No-op: HTTP driver has no per-request connection state to clear.
};
  • Step 4: Run test to verify it passes
Run: cd server && pnpm test src/lib/db.test.ts Expected: PASS, all 3 tests green.
  • Step 5: Run full test suite to make sure nothing else broke
Run: cd server && pnpm test Expected: All previously-passing tests still pass.
  • Step 6: Commit
git add server/src/lib/db.ts server/src/lib/db.test.ts
git commit -m "feat(server): add getReadDb/getWriteDb/getTxDb accessors

Adds Neon HTTP-driver accessors (getReadDb, getWriteDb) and a
Pool-backed interactive-transaction accessor (getTxDb) that returns
{ db, end } so callers can ctx.waitUntil(end()).

getDatabase is preserved as an HTTP alias during the route migration."

Task 2: Audit and document transactional callsites

Files:
  • Create: docs/superpowers/plans/2026-05-03-tx-callsite-audit.md
  • Step 1: Run the audit grep
Run:
cd server && grep -rn "db\.transaction\|BEGIN\|FOR UPDATE" src/routes src/lib src/scheduled 2>/dev/null | grep -v ".test.ts" > /tmp/tx-audit.txt
cat /tmp/tx-audit.txt
  • Step 2: Document findings
Create docs/superpowers/plans/2026-05-03-tx-callsite-audit.md:
# Transaction Callsite Audit

Generated: 2026-05-03
Purpose: Identify every interactive `db.transaction(...)` callsite that must
be migrated to `getTxDb` + `ctx.waitUntil(end())` rather than the HTTP driver.

## Confirmed interactive transactions (need getTxDb)

| File                                   | Line | Purpose                                |
| -------------------------------------- | ---- | -------------------------------------- |
| server/src/routes/appointments.ts      | 1417 | Appointment update with conflict check |
| server/src/routes/auth.ts              | 1792 | Signup with phone-uniqueness lock      |
| server/src/routes/installments.ts      | 88   | Create installment plan                |
| server/src/routes/installments.ts      | 431  | Pay installment                        |
| server/src/routes/installments.ts      | 567  | Bulk installment update                |
| server/src/routes/payroll.ts           | 289  | Payroll run creation                   |
| server/src/routes/public-documents.ts  | 421  | Public document signing                |
| server/src/routes/treatment-plans.ts   | 607  | Treatment plan creation                |
| server/src/routes/treatment-plans.ts   | 774  | Treatment plan update                  |

## Non-issues (no migration needed)

- `routes/admin.ts:1874``DO $$ BEGIN ... END $$` is a PL/pgSQL block, not an
  interactive tx. Stays on `getReadDb`.
- `routes/inventory.ts` — "transaction" in identifiers refers to stock
  transactions (the entity), not DB transactions. Stays on `getReadDb`.

## Migration plan

Each row above gets a dedicated commit. Wrap the existing `db.transaction(...)`
body verbatim inside `getTxDb()` and ensure `ctx.waitUntil(end())` is registered.
  • Step 3: Commit
git add docs/superpowers/plans/2026-05-03-tx-callsite-audit.md
git commit -m "docs: audit DB transaction callsites for Phase 1 migration"

Task 3: Migrate clinic-context middleware + low-risk read routes (batch 1)

These are the highest-traffic, lowest-risk read paths. Migration is purely import + accessor name. Files:
  • Modify: server/src/middleware/clinic-context.ts
  • Modify: server/src/routes/clinics.ts
  • Modify: server/src/routes/staff.ts
  • Modify: server/src/routes/services.ts
  • Modify: server/src/routes/clinic-modules.ts
  • Step 1: Replace getDatabase calls with getReadDb in each file
For each file, apply this mechanical change:
-import { getDatabase } from '../lib/db';
+import { getReadDb } from '../lib/db';
Replace every occurrence of:
const db = await getDatabase(getDatabaseUrl());
// or
const db = await getDatabase(databaseUrl);
with:
const db = getReadDb();
(Note: getReadDb is synchronous — drop the await.) If the file has writes that are NOT inside a db.transaction(), leave them on getReadDb (it doubles as getWriteDb).
  • Step 2: Build server
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -40 Expected: No new TS errors in the modified files. Pre-existing errors elsewhere are fine.
  • Step 3: Smoke-test locally
Run: cd server && pnpm dev (starts Hono in dev mode) In another terminal:
curl -s http://localhost:8787/api/v1/health | jq .
curl -s -H "Cookie: <valid auth cookie>" http://localhost:8787/api/v1/protected/clinic/me | jq . | head -20
Expected: 200 responses with the same shape as before. If any 500, revert and investigate.
  • Step 4: Commit
git add server/src/middleware/clinic-context.ts server/src/routes/clinics.ts server/src/routes/staff.ts server/src/routes/services.ts server/src/routes/clinic-modules.ts
git commit -m "perf(server): migrate clinic/staff/services routes to neon-http

Switches read-heavy clinic-context middleware and clinic/staff/services
routes from Pool/WebSocket to neon-http. Removes per-request handshake
latency on hot paths."

Task 4: Migrate patients.ts + appointments.ts (read paths only)

appointments.ts:1417 has an interactive transaction — leave that handler alone in this task. Migrate only the rest. Files:
  • Modify: server/src/routes/patients.ts
  • Modify: server/src/routes/appointments.ts (excluding the handler at line ~1417)
  • Step 1: Apply the same mechanical migration as Task 3
-import { getDatabase } from '../lib/db';
+import { getReadDb } from '../lib/db';
Replace await getDatabase(...)getReadDb() everywhere EXCEPT inside the appointment handler that contains db.transaction(async (tx) => {...}). That handler keeps its current code untouched in this task — Task 10 migrates it to getTxDb.
  • Step 2: Verify the transactional handler is untouched
Run: git diff server/src/routes/appointments.ts | grep -A2 -B2 "transaction" Expected: no diff lines around the db.transaction( call.
  • Step 3: Build server
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors.
  • Step 4: Smoke-test
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/patients?limit=10" | jq '.[] | .id' | head -5
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/appointments?date=2026-05-03" | jq '. | length'
Expected: 200 + lists.
  • Step 5: Commit
git add server/src/routes/patients.ts server/src/routes/appointments.ts
git commit -m "perf(server): migrate patients + appointments reads to neon-http

Transactional handler in appointments.ts (line ~1417) is intentionally
left on the legacy path; Task 10 migrates it to getTxDb."

Task 5: Migrate billing/invoices/payments read paths (batch 3)

Files:
  • Modify: server/src/routes/billing.ts
  • Modify: server/src/routes/invoices.tsx
  • Modify: server/src/routes/payment.ts
  • Modify: server/src/routes/expenses.ts
  • Step 1: Apply the same mechanical migration as Task 3 to all four files
getDatabasegetReadDb, drop await. Skip handlers containing db.transaction(. Note: invoices.tsx has a tx at line ~375 (quotation→invoice conversion) — leave that handler’s body untouched in this task; Task 11 migrates it to getTxDb.
  • Step 2: Build server
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors.
  • Step 3: Smoke-test
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/invoices?limit=5" | jq '. | length'
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/expenses?limit=5" | jq '. | length'
Expected: 200 + lists.
  • Step 4: Commit
git add server/src/routes/billing.ts server/src/routes/invoices.tsx server/src/routes/payment.ts server/src/routes/expenses.ts
git commit -m "perf(server): migrate billing/invoices/payment/expenses to neon-http"

Task 6: Migrate inventory + lab routes (batch 4)

Files:
  • Modify: server/src/routes/inventory.ts
  • Modify: server/src/routes/lab-cases.ts
  • Modify: server/src/routes/lab-services.ts
  • Modify: server/src/routes/laboratories.ts
  • Step 1: Apply mechanical migration
Same pattern. None of these have db.transaction( per the audit.
  • Step 2: Build + smoke-test
cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -10
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/inventory?limit=5" | jq '. | length'
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/lab-cases?limit=5" | jq '. | length'
Expected: clean build + 200s.
  • Step 3: Commit
git add server/src/routes/inventory.ts server/src/routes/lab-cases.ts server/src/routes/lab-services.ts server/src/routes/laboratories.ts
git commit -m "perf(server): migrate inventory + lab routes to neon-http"

Task 7: Migrate remaining read routes (batch 5)

Files:
  • Modify: every other file under server/src/routes/ that imports getDatabase and is NOT in the tx-audit list (Task 2). Use this command to enumerate:
cd server && grep -rln "from '../lib/db'" src/routes | xargs grep -L "db.transaction" | xargs grep -l "getDatabase"
Likely candidates (not exhaustive — run the command):
  • routes/admin.ts
  • routes/ai.ts
  • routes/analytics.ts
  • routes/audit-logs.ts
  • routes/bridge.ts
  • routes/clinical-notes.ts
  • routes/contact.ts
  • routes/cron-jobs.ts
  • routes/dental-charts.ts
  • routes/document-issuance.ts
  • routes/document-views.ts
  • routes/email-templates.ts
  • routes/files.ts
  • routes/help_center.ts
  • routes/insurance-claims.ts
  • routes/medications.ts
  • routes/messages.ts
  • routes/notifications.ts
  • routes/passkeys.ts
  • routes/public-documents-protected.ts
  • routes/system-health.ts
  • Step 1: Apply mechanical migration to each
For each file: getDatabasegetReadDb, drop await, leave any db.transaction( handlers alone. routes/auth.ts is NOT in this list — it has a transaction at line 1792 and is migrated in Task 11 (its non-tx handlers can be migrated separately if you want, but for safety keep auth as a single commit later).
  • Step 2: Build
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors.
  • Step 3: Spot-check a handful of endpoints
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/notifications?limit=5" | jq '. | length'
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/dental-charts/<some-patient-id>" | jq 'keys'
curl -s "http://localhost:8787/api/v1/health" | jq .
  • Step 4: Commit
git add server/src/routes/
git commit -m "perf(server): migrate remaining read routes to neon-http

Excludes routes with interactive transactions (auth, appointments,
installments, payroll, public-documents, treatment-plans) — migrated
separately in Tasks 10–13."

Task 8: Migrate appointments.ts:1417 transaction to getTxDb

Files:
  • Modify: server/src/routes/appointments.ts (the single handler containing db.transaction(async (tx) => {...}))
  • Step 1: Read the handler to capture exact bounds
Run: sed -n '1400,1480p' server/src/routes/appointments.ts and identify the start of the handler and the end of the transaction() block.
  • Step 2: Replace the handler’s db acquisition + add cleanup
Pattern: replace
const db = await getDatabase(getDatabaseUrl());
// ...handler body...
const updated = await db.transaction(async (tx) => {
  // tx body
});
with:
const { db, end } = getTxDb();
c.executionCtx.waitUntil(end());
// ...handler body...
const updated = await db.transaction(async (tx) => {
  // tx body — unchanged
});
The c.executionCtx.waitUntil(end()) registration goes IMMEDIATELY after getTxDb() so the cleanup is guaranteed even on error paths.
  • Step 3: Build
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors.
  • Step 4: Smoke-test the appointment-update path
# Get an existing appointment id
APPT=$(curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/appointments?date=2026-05-03" | jq -r '.[0].id')

# Update a benign field
curl -s -X PATCH -H "Cookie: <auth>" -H "Content-Type: application/json" \
  -d '{"notes":"plan-test-marker"}' \
  "http://localhost:8787/api/v1/protected/appointments/$APPT" | jq .
Expected: 200 with the updated record.
  • Step 5: Commit
git add server/src/routes/appointments.ts
git commit -m "perf(server): migrate appointments tx handler to getTxDb

Uses ctx.waitUntil(end()) so the Worker doesn't hang on connection
close. The transaction body is unchanged."

Task 9: Migrate auth.ts:1792 transaction (signup flow)

Files:
  • Modify: server/src/routes/auth.ts
  • Step 1: Apply the same pattern as Task 8 to the signup handler
Identify the handler containing await db.transaction(async (tx) => { at line ~1792. Replace its await getDatabase(...) with const { db, end } = getTxDb(); c.executionCtx.waitUntil(end());. If auth.ts has OTHER non-tx handlers that still use getDatabase (login, refresh, logout, password reset), they can be migrated to getReadDb in this same commit since auth is now being touched anyway. Skip handlers that contain a db.transaction(.
  • Step 2: Build + smoke-test
cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -10

# Login smoke
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"email":"<test-user>","password":"<pw>"}' \
  http://localhost:8787/api/v1/auth/login | jq '.user.id // .error'
Expected: login succeeds.
  • Step 3: Commit
git add server/src/routes/auth.ts
git commit -m "perf(server): migrate auth signup tx + non-tx handlers to new accessors"

Task 10: Migrate installments.ts (3 transactions)

Files:
  • Modify: server/src/routes/installments.ts
  • Step 1: Apply Task 8 pattern to all three tx handlers (lines 88, 431, 567)
Each handler gets its own getTxDb() + ctx.waitUntil(end()) (don’t share — each handler is its own request scope). Non-tx handlers in the same file: migrate to getReadDb.
  • Step 2: Build + smoke-test
cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -10
# If you have an installment plan in test data:
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/installments?limit=5" | jq '. | length'
  • Step 3: Commit
git add server/src/routes/installments.ts
git commit -m "perf(server): migrate installments tx + read handlers"

Task 11: Migrate remaining transactional callsites

Audit (Task 2 audit doc) found 13 interactive tx callsites total. Tasks 8–10 covered 5 (appointments, auth, 3 in installments). This task covers the remaining 8, grouped by pattern. Files (route handlers — same pattern as Task 8):
  • Modify: server/src/routes/payroll.ts (line ~289)
  • Modify: server/src/routes/treatment-plans.ts (lines ~607 + ~774)
  • Modify: server/src/routes/public-documents.ts (line ~421)
  • Modify: server/src/routes/invoices.tsx (line ~375 — quotation→invoice conversion)
Files (library — caller passes the tx context):
  • Modify: server/src/lib/importer.ts (line ~657 — bulk import)
Files (cron handlers — different waitUntil source):
  • Modify: server/src/scheduled/installment-invoices.ts (line ~73)
  • Modify: server/src/scheduled/appointment-invoices.ts (line ~120)
  • Step 1: Apply Task 8 pattern to the route handlers
For payroll.ts, treatment-plans.ts, public-documents.ts, invoices.tsx: replace
const db = await getDatabase(getDatabaseUrl());
// ... handler body ...
const result = await db.transaction(async (tx) => { ... });
with
const { db, end } = getTxDb();
c.executionCtx.waitUntil(end());
// ... handler body unchanged ...
const result = await db.transaction(async (tx) => { ... });
Migrate non-tx handlers in the same files to getReadDb while you’re in there.
  • Step 2: Migrate lib/importer.ts
Read the function around line 657 first. Two patterns are possible: Pattern A — caller injects db: function signature is async function importFoo(db, ...). No change needed in importer.ts itself; the caller (a route) is responsible for passing a tx-capable db. Just verify the caller uses getTxDb() and ctx.waitUntil(end()). If the caller already migrated to getReadDb, change it to getTxDb. Pattern B — function creates its own connection: function calls getDatabase() internally. Change to getTxDb() and accept an end-cleanup ownership decision: either return end to the caller (preferred — caller registers waitUntil) OR have the function be async and await the work without end() (risky in Workers — leaks). Document choice in code comment. If unsure between A and B, read the caller chain via grep -rn "importFoo\|importer\." server/src and pick A.
  • Step 3: Migrate the cron handlers (scheduled/*)
Cron handlers run from scheduled() in worker.ts, not from a Hono request. The waitUntil source is the ScheduledEvent parameter, not c.executionCtx. Pattern:
// Before:
async function runInstallmentInvoices(env: Env) {
  const db = await getDatabase(env.DATABASE_URL);
  await db.transaction(async (tx) => { /* ... */ });
}

// After:
async function runInstallmentInvoices(env: Env, event: ScheduledEvent) {
  const { db, end } = getTxDb(env.DATABASE_URL);
  event.waitUntil(end());
  await db.transaction(async (tx) => { /* unchanged */ });
}
The function may need an extra event: ScheduledEvent parameter. Update the call-site in worker.ts (search for runInstallmentInvoices( etc.) to pass event. If that’s invasive, an alternative is to change the function to RETURN end and have the caller register the cleanup:
async function runInstallmentInvoices(env: Env): Promise<{ end: () => Promise<void> }> {
  const { db, end } = getTxDb(env.DATABASE_URL);
  await db.transaction(async (tx) => { /* unchanged */ });
  return { end };
}

// In worker.ts scheduled handler:
const { end } = await runInstallmentInvoices(env);
event.waitUntil(end());
Pick whichever requires fewer call-site edits. Both are correct.
  • Step 4: Build
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no new errors.
  • Step 5: Smoke-test (route handlers only — cron tested in staging)
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/treatment-plans?limit=3" | jq '. | length'
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/payroll/runs?limit=3" | jq '. | length'
curl -s -H "Cookie: <auth>" "http://localhost:8787/api/v1/protected/invoices?limit=3" | jq '. | length'
For lib/importer.ts: trigger a small import via the existing import endpoint (or skip and rely on the route smoke test to confirm the caller chain compiles). For scheduled/*: cannot reasonably trigger locally. Validate by:
  1. pnpm build succeeds.
  2. Visual review that event.waitUntil(end()) is registered before any await db.transaction(...).
  • Step 6: Commit
git add server/src/routes/payroll.ts server/src/routes/treatment-plans.ts server/src/routes/public-documents.ts server/src/routes/invoices.tsx server/src/lib/importer.ts server/src/scheduled/installment-invoices.ts server/src/scheduled/appointment-invoices.ts server/src/worker.ts
git commit -m "perf(server): migrate remaining 8 tx callsites to getTxDb

Route handlers (payroll, treatment-plans, public-documents, invoices
quote→invoice) follow the Task 8 pattern with c.executionCtx.waitUntil.
Library importer takes the caller-injected-db pattern. Cron handlers
(scheduled/installment-invoices, scheduled/appointment-invoices) use
event.waitUntil since they have no Hono context."

Task 12: Remove getDatabase deprecated alias

Files:
  • Modify: server/src/lib/db.ts
  • Modify: any straggler files still importing getDatabase (rare — anything still on getDatabase after this point is a bug to fix in this task).
  • Step 1: Find any remaining getDatabase consumers
Run:
cd server && grep -rln "getDatabase\b" src/routes src/middleware src/lib src/scheduled 2>/dev/null
For each result that isn’t db.ts itself, replace getDatabase with the appropriate accessor (getReadDb for non-tx handlers, getTxDb for tx handlers).
  • Step 2: Remove the deprecated exports from db.ts
Edit server/src/lib/db.ts: delete the getDatabase and getDatabaseHttp deprecated re-exports added in Task 1. Keep getReadDb, getWriteDb, getTxDb, testDatabaseConnection, clearConnectionCache.
  • Step 3: Build
Run: cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20 Expected: no getDatabase is not exported errors. If there are, fix the straggler imports identified in Step 1.
  • Step 4: Run full test suite
Run: cd server && pnpm test Expected: all pass.
  • Step 5: Commit
git add server/src/lib/db.ts server/src/
git commit -m "chore(server): remove deprecated getDatabase alias

All routes now use getReadDb / getWriteDb / getTxDb explicitly.
Phase 1 driver migration complete."

Phase 2 — TanStack Query Client Cache

Task 13: Install TanStack Query packages

Files:
  • Modify: ui/package.json + lockfile
  • Step 1: Install
cd ui && pnpm add @tanstack/react-query @tanstack/react-query-persist-client @tanstack/query-sync-storage-persister
  • Step 2: Verify install
Run: cd ui && pnpm list @tanstack/react-query Expected: shows v5.x.
  • Step 3: Commit
git add ui/package.json ui/pnpm-lock.yaml
git commit -m "chore(ui): add @tanstack/react-query + persist-client + sync-storage-persister"

Task 14: Inject build hash into Vite for cache buster

Files:
  • Modify: ui/vite.config.ts
  • Step 1: Add __BUILD_HASH__ define
Edit ui/vite.config.ts. Inside the define block (currently has 'import.meta.env.VITE_API_URL' etc.), add:
'__BUILD_HASH__': JSON.stringify(
  process.env.CF_PAGES_COMMIT_SHA ||
  process.env.GITHUB_SHA ||
  Date.now().toString(36)
),
The fallback Date.now().toString(36) ensures local dev gets a fresh hash on every restart. CF Pages and GH Actions both inject their own SHA in CI.
  • Step 2: Add the global type declaration
Edit ui/src/vite-env.d.ts (or create if missing). Add:
declare const __BUILD_HASH__: string;
  • Step 3: Verify build
Run: cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10 Expected: no errors.
  • Step 4: Commit
git add ui/vite.config.ts ui/src/vite-env.d.ts
git commit -m "chore(ui): inject __BUILD_HASH__ for Query cache buster"

Task 15: Create queryClient.ts and queryKeys.ts

Files:
  • Create: ui/src/lib/queryClient.ts
  • Create: ui/src/lib/queryKeys.ts
  • Step 1: Create ui/src/lib/queryClient.ts
import { QueryClient } from '@tanstack/react-query';
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister';

const STALE_TIME = 30_000;                  // 30s
const GC_TIME = 24 * 60 * 60 * 1000;        // 24h
const PERSIST_MAX_AGE = GC_TIME;

const ACTIVE_CLINIC_KEY = 'odontox-active-clinic-id';

export function getActiveClinicId(): string | null {
  if (typeof window === 'undefined') return null;
  return window.localStorage.getItem(ACTIVE_CLINIC_KEY);
}

function getPersisterStorageKey(): string {
  const clinicId = getActiveClinicId() ?? 'no-clinic';
  return `odontox-rq-${clinicId}`;
}

/**
 * Removes any persisted Query caches whose key prefix doesn't match the
 * current clinic. Run on boot to keep localStorage tidy after clinic switches.
 */
export function pruneStaleClinicCaches(): void {
  if (typeof window === 'undefined') return;
  const currentKey = getPersisterStorageKey();
  for (let i = window.localStorage.length - 1; i >= 0; i--) {
    const k = window.localStorage.key(i);
    if (k && k.startsWith('odontox-rq-') && k !== currentKey) {
      window.localStorage.removeItem(k);
    }
  }
}

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: STALE_TIME,
      gcTime: GC_TIME,
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
});

export const persister = createSyncStoragePersister({
  storage: typeof window !== 'undefined' ? window.localStorage : undefined as any,
  key: getPersisterStorageKey(),
});

export const persistOptions = {
  persister,
  maxAge: PERSIST_MAX_AGE,
  buster: __BUILD_HASH__,
};

/**
 * Wipes all in-memory and persisted query caches. Call on login + logout.
 */
export function clearAllQueryCaches(): void {
  queryClient.clear();
  if (typeof window !== 'undefined') {
    for (let i = window.localStorage.length - 1; i >= 0; i--) {
      const k = window.localStorage.key(i);
      if (k && k.startsWith('odontox-rq-')) {
        window.localStorage.removeItem(k);
      }
    }
  }
}
  • Step 2: Create ui/src/lib/queryKeys.ts
import { getActiveClinicId } from './queryClient';

/**
 * Query key factory. Every key starts with [resource, clinicId, ...].
 * clinicId is read at call time so keys naturally re-scope after a switch.
 *
 * Convention: keys are arrays. Use `qk.patients.list({ search })` etc.
 */
function clinicScope(): string {
  const id = getActiveClinicId();
  if (!id) {
    if (import.meta.env.DEV) {
      throw new Error('queryKeys: called without an active clinicId');
    }
    return 'no-clinic';
  }
  return id;
}

export const qk = {
  patients: {
    all: () => ['patients', clinicScope()] as const,
    list: (params: Record<string, unknown> = {}) => ['patients', clinicScope(), 'list', params] as const,
    detail: (id: string) => ['patients', clinicScope(), 'detail', id] as const,
  },
  appointments: {
    all: () => ['appointments', clinicScope()] as const,
    list: (params: Record<string, unknown> = {}) => ['appointments', clinicScope(), 'list', params] as const,
    detail: (id: string) => ['appointments', clinicScope(), 'detail', id] as const,
  },
  invoices: {
    all: () => ['invoices', clinicScope()] as const,
    list: (params: Record<string, unknown> = {}) => ['invoices', clinicScope(), 'list', params] as const,
    detail: (id: string) => ['invoices', clinicScope(), 'detail', id] as const,
  },
  inventory: {
    all: () => ['inventory', clinicScope()] as const,
    list: (params: Record<string, unknown> = {}) => ['inventory', clinicScope(), 'list', params] as const,
    detail: (id: string) => ['inventory', clinicScope(), 'detail', id] as const,
  },
  staff: {
    all: () => ['staff', clinicScope()] as const,
    list: () => ['staff', clinicScope(), 'list'] as const,
  },
  services: {
    all: () => ['services', clinicScope()] as const,
    list: () => ['services', clinicScope(), 'list'] as const,
  },
  clinic: {
    settings: () => ['clinic', clinicScope(), 'settings'] as const,
    stats: () => ['clinic', clinicScope(), 'stats'] as const,
  },
} as const;
  • Step 3: Build
Run: cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10 Expected: no errors.
  • Step 4: Commit
git add ui/src/lib/queryClient.ts ui/src/lib/queryKeys.ts
git commit -m "feat(ui): add QueryClient + persister + clinicId-scoped query keys"

Task 16: Wrap <App /> in <PersistQueryClientProvider>

Files:
  • Modify: ui/src/main.tsx
  • Step 1: Add the provider wrapper
In ui/src/main.tsx, locate the createRoot(...).render(...) call (line ~253). Wrap the existing <App /> (or whatever is currently rendered) with <PersistQueryClientProvider>. Add imports at the top of the file:
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client';
import { queryClient, persistOptions, pruneStaleClinicCaches } from '@/lib/queryClient';
Before the createRoot(...).render(...) call, add:
pruneStaleClinicCaches();
Replace:
createRoot(document.getElementById('root')!).render(
  <App />
);
(or whatever the existing render call is) with:
createRoot(document.getElementById('root')!).render(
  <PersistQueryClientProvider client={queryClient} persistOptions={persistOptions}>
    <App />
  </PersistQueryClientProvider>
);
If the existing render contains <StrictMode> or other wrappers, keep them — <PersistQueryClientProvider> should be the OUTERMOST wrapper inside <StrictMode>.
  • Step 2: Build + dev-server smoke
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
cd ui && pnpm dev
In the browser, open the dev console and run localStorage. Expected: nothing changes yet (no queries are using TanStack Query yet) but no errors thrown.
  • Step 3: Commit
git add ui/src/main.tsx
git commit -m "feat(ui): mount PersistQueryClientProvider at app root"

Task 17: Hook auth lifecycle (login + logout) to clear caches

Files:
  • Modify: ui/src/lib/serverComm.ts (where signOut lives) — find setActiveClinicId and signOut
  • Modify: ui/src/lib/auth-context.tsx (or wherever login success is handled) — find login completion
  • Step 1: Locate login + logout points
Run:
grep -n "export function signOut\|export async function signOut\|export function setActiveClinicId" ui/src/lib/serverComm.ts
grep -rn "loginSuccess\|onLogin\|setUser" ui/src/lib/auth-context.tsx 2>/dev/null | head -10
  • Step 2: Clear caches on logout
In ui/src/lib/serverComm.ts, edit the signOut function. Add at the very top of the function body:
import { clearAllQueryCaches } from './queryClient';
// ...
export async function signOut() {
  clearAllQueryCaches();   // wipe before the network call so even a failed
                           // request to the backend leaves the client clean
  // ...rest of existing signOut body
}
(If signOut is export const signOut = async () => {...}, adapt syntax accordingly.)
  • Step 3: Clear caches on login success
In ui/src/lib/auth-context.tsx (or wherever the auth context calls setUser after a successful sign-in), add a call to clearAllQueryCaches() IMMEDIATELY before setting the new user. This guarantees no stale data from a previous user persists. Add the import:
import { clearAllQueryCaches } from '@/lib/queryClient';
In the login-success handler:
clearAllQueryCaches();
setUser(newUser);    // existing line
  • Step 4: Build
Run: cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10 Expected: no errors.
  • Step 5: Manual test
In dev:
  1. Log in. In console: Object.keys(localStorage).filter(k => k.startsWith('odontox-rq')) → should be empty before any queries fire.
  2. Trigger a useQuery (will exist after Task 18). Confirm a key appears.
  3. Log out. Confirm the keys are removed.
(Can defer manual verification to Task 18 when there’s a real query to populate cache.)
  • Step 6: Commit
git add ui/src/lib/serverComm.ts ui/src/lib/auth-context.tsx
git commit -m "feat(ui): clear React Query caches on login + logout

Privacy: any cached tenant data is wiped before a new session starts
and after the current session ends, including persisted localStorage."

Task 18: Migrate Patients module to TanStack Query (pattern-setting migration)

This task establishes the pattern that subsequent module migrations will follow. Files:
  • Modify: ui/src/pages/patients/*.tsx (locate primary list + detail pages)
  • Maybe modify: any patients-fetching helper in ui/src/lib/serverComm.ts
  • Step 1: Identify the patients page entry point
Run:
ls ui/src/pages/patients/ 2>/dev/null || find ui/src -path '*patients*' -name '*.tsx' | head -10
Pick the primary list page (often PatientsList.tsx or index.tsx) and the detail page.
  • Step 2: Convert the list page from manual fetch to useQuery
Pattern: replace
const [patients, setPatients] = useState<Patient[]>([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
  (async () => {
    setLoading(true);
    const data = await getPatients({ search });
    setPatients(data);
    setLoading(false);
  })();
}, [search]);
with:
import { useQuery } from '@tanstack/react-query';
import { qk } from '@/lib/queryKeys';
import { getPatients } from '@/lib/serverComm';

const { data: patients = [], isLoading } = useQuery({
  queryKey: qk.patients.list({ search }),
  queryFn: () => getPatients({ search }),
});
The existing getPatients fetch helper in serverComm.ts is reused as-is — TanStack Query just wraps it.
  • Step 3: Convert the detail page
const { data: patient, isLoading } = useQuery({
  queryKey: qk.patients.detail(patientId),
  queryFn: () => getPatient(patientId),
  enabled: !!patientId,
});
  • Step 4: Convert at least one mutation (e.g., create patient)
import { useMutation, useQueryClient } from '@tanstack/react-query';

const queryClient = useQueryClient();

const createPatient = useMutation({
  mutationFn: (input: CreatePatientInput) => apiCreatePatient(input),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: qk.patients.all() });
  },
});

// In the form submit handler:
await createPatient.mutateAsync(formValues);
  • Step 5: Build + manual test
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
cd ui && pnpm dev
Manual test in browser:
  1. Visit /patients — list loads.
  2. Open a patient — detail loads.
  3. Navigate away (e.g., to /dashboard).
  4. Return to /patientslist renders instantly from cache, then revalidates in the background (you’ll see a brief background fetch in DevTools network panel).
  5. Hard reload — list still loads instantly (from localStorage persist).
  6. Create a patient — list refreshes (invalidation works).
  • Step 6: Commit
git add ui/src/pages/patients
git commit -m "feat(ui): migrate Patients module to TanStack Query

Replaces manual useState+useEffect+fetch with useQuery/useMutation.
Module re-entry now renders from cache while revalidating in background.
Establishes the pattern for the remaining module migrations."

Task 19: Migrate Appointments module

Files:
  • Modify: ui/src/pages/appointments/*.tsx (and any sub-pages)
  • Step 1: Apply the Task 18 pattern to the appointments list, calendar, and detail pages
Use qk.appointments.list({...}) and qk.appointments.detail(id) for keys. Mutations invalidate qk.appointments.all(). For the calendar/day view, key on the date filter:
const { data: appts = [] } = useQuery({
  queryKey: qk.appointments.list({ date: selectedDate }),
  queryFn: () => getAppointments({ date: selectedDate }),
});
  • Step 2: Build + manual test
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
Manual: navigate appointments → patients → back to appointments. Same date range should render instantly from cache.
  • Step 3: Commit
git add ui/src/pages/appointments
git commit -m "feat(ui): migrate Appointments module to TanStack Query"

Task 20: Migrate Billing/Invoices module

Files:
  • Modify: ui/src/pages/finance/**/*.tsx (and ui/src/pages/billing.tsx if separate)
  • Step 1: Apply the Task 18 pattern
Use qk.invoices.list({...}) for the invoices list, qk.invoices.detail(id) for an invoice page. Mutations (mark paid, void, refund) invalidate qk.invoices.all() AND qk.patients.detail(patientId) if the invoice change affects the patient’s balance display.
  • Step 2: Build + manual test
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
Manual: visit invoices → away → back. Render instantly. Mark an invoice as paid; the list should refresh automatically.
  • Step 3: Commit
git add ui/src/pages/finance ui/src/pages/billing.tsx
git commit -m "feat(ui): migrate Billing/Invoices module to TanStack Query"

Task 21: Migrate Inventory module

Files:
  • Modify: ui/src/pages/inventory/*.tsx
  • Step 1: Apply the Task 18 pattern with qk.inventory.*
  • Step 2: Build + manual test
  • Step 3: Commit
git add ui/src/pages/inventory
git commit -m "feat(ui): migrate Inventory module to TanStack Query"

Task 22: Migrate Settings, Staff, Services, Lab, and remaining modules

Files:
  • Modify: ui/src/pages/Settings.tsx and any subordinate pages
  • Modify: any staff/services/lab pages
  • Step 1: Apply the Task 18 pattern to each remaining module
Use the corresponding qk.* factories. For modules not yet in queryKeys.ts, add new entries following the same shape.
  • Step 2: Verify all useEffect(() => fetch(...)) patterns in ui/src/pages/ have been migrated
Run:
grep -rn "useEffect" ui/src/pages | grep -v "PublicDocumentPage\|LoginSuccess\|AccountStatus\|UpgradeRequest\|SecuritySettings" | head -20
The remaining useEffect calls should be ones that DO NOT fetch data (e.g., scroll position, side effects, animations). If any still wrap a fetch, migrate them.
  • Step 3: Build + sweep
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
  • Step 4: Commit
git add ui/src/pages ui/src/lib/queryKeys.ts
git commit -m "feat(ui): migrate remaining modules to TanStack Query"

Task 23: Delete the hand-rolled cache.ts

Files:
  • Delete: ui/src/lib/cache.ts
  • Modify: any remaining importers
  • Step 1: Find any remaining importers
Run:
grep -rn "from '@/lib/cache'\|from '../lib/cache'\|from './cache'" ui/src 2>/dev/null
For each result, determine if the consumer is now redundant (TanStack Query has replaced its purpose). If so, remove the cache calls and rely on Query. If it’s a special case (non-route caching), keep cache.ts and DO NOT delete in this task — it can stay.
  • Step 2: If no importers remain, delete cache.ts
rm ui/src/lib/cache.ts
  • Step 3: Build
Run: cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10 Expected: no errors. If there are import errors, you missed an importer in Step 1.
  • Step 4: Commit
git add -A ui/src
git commit -m "chore(ui): delete hand-rolled cache.ts; TanStack Query owns client cache"

Phase 4 — Bundle / CSS Audit

Task 24: Audit Tailwind purge configuration

Files:
  • Inspect: ui/tailwind.config.ts
  • Maybe modify: ui/tailwind.config.ts
  • Step 1: Read current config
Run: cat ui/tailwind.config.ts Look at the content glob. It should explicitly list every directory containing class strings:
content: [
  './index.html',
  './src/**/*.{ts,tsx}',
],
If the glob is too narrow (missing a directory) or too wide (./node_modules/**), fix it.
  • Step 2: Build and check CSS size
cd ui && pnpm build
ls -lh dist/assets/*.css | awk '{print $5, $9}'
Note the CSS file size before any change. Baseline 490KB.
  • Step 3: If config was wrong, fix it and rebuild
If you changed tailwind.config.ts:
cd ui && pnpm build
ls -lh dist/assets/*.css | awk '{print $5, $9}'
Expected: CSS is meaningfully smaller (target: under 200KB if Tailwind v4; under 100KB if v3). If size didn’t drop, the config wasn’t the issue — likely we have a lot of legitimate classes in use. Document the finding and move on.
  • Step 4: Commit (if changed) or skip (if not)
# Only if you actually changed config:
git add ui/tailwind.config.ts
git commit -m "perf(ui): tighten Tailwind content glob to drop unused class scan"
If no change needed, just record findings inline (no commit) and proceed.

Task 25: Lazy-load heavy chunks

Files:
  • Modify: any router file or page that statically imports @react-pdf/renderer, dicom-parser, or other heavy libs
  • Step 1: Find heavy static imports
Run:
cd ui && grep -rn "from '@react-pdf/renderer'\|from 'dicom-parser'\|from 'docx-preview'\|from 'mammoth'" src 2>/dev/null
  • Step 2: Convert each to dynamic import
For component imports, switch to React.lazy:
// Before:
import { PDFViewer } from '@react-pdf/renderer';

// After:
const PDFViewer = React.lazy(() => import('@react-pdf/renderer').then(m => ({ default: m.PDFViewer })));

// Wrap usage in <Suspense>:
<Suspense fallback={<Loader />}>
  <PDFViewer>...</PDFViewer>
</Suspense>
For non-component imports (e.g., dicom-parser), use dynamic import() inside the function that needs it:
async function parseDicom(buf: ArrayBuffer) {
  const { parseDicom: parse } = await import('dicom-parser');
  return parse(new Uint8Array(buf));
}
  • Step 3: Verify route-level lazy-loading is already in place
Run:
grep -n "React.lazy\|lazy(" ui/src/App.tsx ui/src/main.tsx 2>/dev/null
If routes are not lazy, add lazy-loading for at least the heaviest pages (DentalChart, any PDF viewer, DICOM viewer).
  • Step 4: Build + verify chunk split
cd ui && pnpm build
ls -lh dist/assets/*.js | sort -k5 -h | tail -10
Expected: the largest chunk is meaningfully smaller; PDF/DICOM are in their own separate chunks (small base, separate large chunks loaded on demand).
  • Step 5: Commit
git add ui/src
git commit -m "perf(ui): lazy-load heavy renderer chunks (PDF, DICOM, docx)"

Task 26: Icon import audit

Files:
  • Modify: any file using barrel-style lucide imports
  • Step 1: Find lucide imports
Run:
cd ui && grep -rn "from 'lucide-react'" src --include='*.tsx' --include='*.ts' | wc -l
grep -rn "from 'lucide-react'" src --include='*.tsx' --include='*.ts' | head -10
  • Step 2: Confirm tree-shaking works
lucide-react is normally tree-shake-friendly. Confirm by checking the production bundle:
cd ui && pnpm build
grep -o 'lucide-react' dist/assets/*.js | wc -l
Look for any chunk that pulls in obviously unused icons. If a single chunk has hundreds of icons, audit that chunk’s imports.
  • Step 3: Fix any bad imports (if found)
A bad pattern looks like:
import * as Icons from 'lucide-react';   // ❌ pulls everything
Fix by named imports:
import { ChevronRight, Calendar, Plus } from 'lucide-react';   // ✅
  • Step 4: Rebuild + check
cd ui && pnpm build
ls -lh dist/assets/*.js | sort -k5 -h | tail -10
  • Step 5: Commit (only if anything changed)
git add ui/src
git commit -m "perf(ui): drop barrel-style lucide imports"
If nothing was wrong, skip the commit and document findings.

Final verification

Task 27: End-to-end smoke + measurements

  • Step 1: Build server + UI
cd server && pnpm exec tsc --noEmit
cd ../ui && pnpm build
Expected: both succeed.
  • Step 2: Run all tests
cd server && pnpm test
Expected: all pass.
  • Step 3: Browser smoke flow
In a clean browser session:
  1. Login as a clinic user
  2. Navigate: Dashboard → Patients → open a patient → Appointments → back to Patients
  3. Confirm the second visit to Patients renders instantly (no spinner) and a background refetch happens (visible in DevTools network panel)
  4. Hard reload the page
  5. Confirm the dashboard re-loads from cache (instant render, background refetch)
  6. Switch clinics (if applicable) — confirm the new clinic’s data loads cleanly with no leak from the previous clinic
  7. Logout — confirm localStorage odontox-rq-* keys are gone
  8. Login again as a different user — confirm no stale data from the previous user
  • Step 4: Capture metrics (optional but recommended)
Open Chrome DevTools → Performance/Network. Record a Patients-list load BEFORE and AFTER (using a pre-Phase-1 commit for “before” if you want a clean comparison). Note time-to-first-byte and total transfer.
  • Step 5: Final commit (if any housekeeping changes)
If everything is green, no commit needed. Just confirm the plan checklist above is fully ticked.

Out-of-scope follow-ups (post this plan)

  • Hyperdrive evaluation
  • KV edge response cache for hot reads
  • N+1 query audit on tenant-scoped list endpoints
  • DB index audit (patients.clinic_id, appointments.scheduled_at)
  • Mobile app (Expo) caching parity