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 offmain) - Existing uncommitted changes have been stashed or are unrelated
pnpmis installed;pnpm installruns cleanly inserver/andui/DATABASE_URLset inserver/.envfor 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
server/src/lib/db.test.ts:
- Step 2: Run test to verify it fails
cd server && pnpm test src/lib/db.test.ts
Expected: FAIL with getReadDb is not exported / similar.
- Step 3: Replace
server/src/lib/db.tswith the new shape
- Step 4: Run test to verify it passes
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
cd server && pnpm test
Expected: All previously-passing tests still pass.
- Step 6: Commit
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
- Step 2: Document findings
docs/superpowers/plans/2026-05-03-tx-callsite-audit.md:
- Step 3: Commit
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
getDatabasecalls withgetReadDbin each file
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
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
cd server && pnpm dev (starts Hono in dev mode)
In another terminal:
- Step 4: Commit
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
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
git diff server/src/routes/appointments.ts | grep -A2 -B2 "transaction"
Expected: no diff lines around the db.transaction( call.
- Step 3: Build server
cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20
Expected: no new errors.
- Step 4: Smoke-test
- Step 5: Commit
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
getDatabase → getReadDb, 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
cd server && pnpm exec tsc --noEmit -p tsconfig.json 2>&1 | head -20
Expected: no new errors.
- Step 3: Smoke-test
- Step 4: Commit
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
db.transaction( per the audit.
- Step 2: Build + smoke-test
- Step 3: Commit
Task 7: Migrate remaining read routes (batch 5)
Files:- Modify: every other file under
server/src/routes/that importsgetDatabaseand is NOT in the tx-audit list (Task 2). Use this command to enumerate:
-
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
getDatabase → getReadDb, 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
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
- Step 4: Commit
Task 8: Migrate appointments.ts:1417 transaction to getTxDb
Files:
-
Modify:
server/src/routes/appointments.ts(the single handler containingdb.transaction(async (tx) => {...})) - Step 1: Read the handler to capture exact bounds
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
c.executionCtx.waitUntil(end()) registration goes IMMEDIATELY after getTxDb() so the cleanup is guaranteed even on error paths.
- Step 3: Build
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
- Step 5: Commit
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
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
- Step 3: Commit
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)
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
- Step 3: Commit
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)
- Modify:
server/src/lib/importer.ts(line ~657 — bulk import)
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
payroll.ts, treatment-plans.ts, public-documents.ts, invoices.tsx: replace
getReadDb while you’re in there.
- Step 2: Migrate
lib/importer.ts
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/*)
scheduled() in worker.ts, not from a Hono request. The waitUntil source is the ScheduledEvent parameter, not c.executionCtx. Pattern:
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:
- Step 4: Build
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)
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:
pnpm buildsucceeds.- Visual review that
event.waitUntil(end())is registered before anyawait db.transaction(...).
- Step 6: Commit
Task 12: Remove getDatabase deprecated alias
Files:
-
Modify:
server/src/lib/db.ts -
Modify: any straggler files still importing
getDatabase(rare — anything still ongetDatabaseafter this point is a bug to fix in this task). -
Step 1: Find any remaining
getDatabaseconsumers
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
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
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
cd server && pnpm test
Expected: all pass.
- Step 5: Commit
Phase 2 — TanStack Query Client Cache
Task 13: Install TanStack Query packages
Files:-
Modify:
ui/package.json+ lockfile - Step 1: Install
- Step 2: Verify install
cd ui && pnpm list @tanstack/react-query
Expected: shows v5.x.
- Step 3: Commit
Task 14: Inject build hash into Vite for cache buster
Files:-
Modify:
ui/vite.config.ts -
Step 1: Add
__BUILD_HASH__define
ui/vite.config.ts. Inside the define block (currently has 'import.meta.env.VITE_API_URL' etc.), add:
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
ui/src/vite-env.d.ts (or create if missing). Add:
- Step 3: Verify build
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
Expected: no errors.
- Step 4: Commit
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
- Step 2: Create
ui/src/lib/queryKeys.ts
- Step 3: Build
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
Expected: no errors.
- Step 4: Commit
Task 16: Wrap <App /> in <PersistQueryClientProvider>
Files:
-
Modify:
ui/src/main.tsx - Step 1: Add the provider wrapper
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:
createRoot(...).render(...) call, add:
<StrictMode> or other wrappers, keep them — <PersistQueryClientProvider> should be the OUTERMOST wrapper inside <StrictMode>.
- Step 2: Build + dev-server smoke
localStorage. Expected: nothing changes yet (no queries are using TanStack Query yet) but no errors thrown.
- Step 3: Commit
Task 17: Hook auth lifecycle (login + logout) to clear caches
Files:-
Modify:
ui/src/lib/serverComm.ts(wheresignOutlives) — findsetActiveClinicIdandsignOut -
Modify:
ui/src/lib/auth-context.tsx(or wherever login success is handled) — find login completion - Step 1: Locate login + logout points
- Step 2: Clear caches on logout
ui/src/lib/serverComm.ts, edit the signOut function. Add at the very top of the function body:
signOut is export const signOut = async () => {...}, adapt syntax accordingly.)
- Step 3: Clear caches on login success
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:
- Step 4: Build
cd ui && pnpm exec tsc --noEmit -p tsconfig.app.json 2>&1 | head -10
Expected: no errors.
- Step 5: Manual test
- Log in. In console:
Object.keys(localStorage).filter(k => k.startsWith('odontox-rq'))→ should be empty before any queries fire. - Trigger a
useQuery(will exist after Task 18). Confirm a key appears. - Log out. Confirm the keys are removed.
- Step 6: Commit
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
PatientsList.tsx or index.tsx) and the detail page.
- Step 2: Convert the list page from manual fetch to
useQuery
getPatients fetch helper in serverComm.ts is reused as-is — TanStack Query just wraps it.
- Step 3: Convert the detail page
- Step 4: Convert at least one mutation (e.g., create patient)
- Step 5: Build + manual test
- Visit
/patients— list loads. - Open a patient — detail loads.
- Navigate away (e.g., to
/dashboard). - Return to
/patients— list renders instantly from cache, then revalidates in the background (you’ll see a brief background fetch in DevTools network panel). - Hard reload — list still loads instantly (from localStorage persist).
- Create a patient — list refreshes (invalidation works).
- Step 6: Commit
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
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:
- Step 2: Build + manual test
- Step 3: Commit
Task 20: Migrate Billing/Invoices module
Files:-
Modify:
ui/src/pages/finance/**/*.tsx(andui/src/pages/billing.tsxif separate) - Step 1: Apply the Task 18 pattern
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
- Step 3: Commit
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
Task 22: Migrate Settings, Staff, Services, Lab, and remaining modules
Files:-
Modify:
ui/src/pages/Settings.tsxand any subordinate pages - Modify: any staff/services/lab pages
- Step 1: Apply the Task 18 pattern to each remaining module
qk.* factories. For modules not yet in queryKeys.ts, add new entries following the same shape.
- Step 2: Verify all
useEffect(() => fetch(...))patterns inui/src/pages/have been migrated
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
- Step 4: Commit
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
cache.ts and DO NOT delete in this task — it can stay.
- Step 2: If no importers remain, delete
cache.ts
- Step 3: Build
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
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
cat ui/tailwind.config.ts
Look at the content glob. It should explicitly list every directory containing class strings:
./node_modules/**), fix it.
- Step 2: Build and check CSS size
- Step 3: If config was wrong, fix it and rebuild
tailwind.config.ts:
- Step 4: Commit (if changed) or skip (if not)
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
- Step 2: Convert each to dynamic import
React.lazy:
dicom-parser), use dynamic import() inside the function that needs it:
- Step 3: Verify route-level lazy-loading is already in place
- Step 4: Build + verify chunk split
- Step 5: Commit
Task 26: Icon import audit
Files:- Modify: any file using barrel-style lucide imports
- Step 1: Find lucide imports
- Step 2: Confirm tree-shaking works
lucide-react is normally tree-shake-friendly. Confirm by checking the production bundle:
- Step 3: Fix any bad imports (if found)
- Step 4: Rebuild + check
- Step 5: Commit (only if anything changed)
Final verification
Task 27: End-to-end smoke + measurements
- Step 1: Build server + UI
- Step 2: Run all tests
- Step 3: Browser smoke flow
- Login as a clinic user
- Navigate: Dashboard → Patients → open a patient → Appointments → back to Patients
- Confirm the second visit to Patients renders instantly (no spinner) and a background refetch happens (visible in DevTools network panel)
- Hard reload the page
- Confirm the dashboard re-loads from cache (instant render, background refetch)
- Switch clinics (if applicable) — confirm the new clinic’s data loads cleanly with no leak from the previous clinic
- Logout — confirm
localStorageodontox-rq-*keys are gone - Login again as a different user — confirm no stale data from the previous user
- Step 4: Capture metrics (optional but recommended)
- Step 5: Final commit (if any housekeeping changes)
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

