7.5 KiB
Read / Write Split (Prisma $extends)
Phase 03 task #41. Status: code-complete; replica provisioning is operator-owned.
TL;DR
import { db, dbRead } from "~/utils/db.server";
// Default — primary (write target). Use for all writes, auth, read-after-write.
await db.user.update({ where: { id }, data: { lastSeenAt: new Date() } });
// Explicit primary — same client, communicates intent.
import { getWriteDbClient } from "@toolctrl/db/client.server";
// Replica — eventually consistent. Use for listings, dashboards, public reads.
const users = await dbRead.user.findMany({ where: { tenantId } });
dbRead is wired to DATABASE_READ_URL when set. When unset (single-DB dev,
self-hosted setups) it returns the same client as db, so the code path is
identical and lag = 0.
Convention: when to use which
Use dbRead (replica) |
Use db / getWriteDbClient() (primary) |
|---|---|
| List endpoints / paginated grids | All mutations (create, update, delete, upsert) |
| Public marketing pages (sites, portals) | Auth-resolution paths (session → user → tenant) |
| Knowledge-base public reads + search | Stripe webhook handlers reading state they just wrote |
| Analytics dashboards & aggregations | Outbox dispatcher reading PENDING events (see #42) |
| Admin tenant / role enumerations (post-auth) | Anything inside a db.$transaction(...) callback |
Dashboard counts (count() / groupBy()) |
Idempotency-key lookups (request-scoped consistency) |
| Search-result rendering | Read-after-write inside the same request |
When in doubt: leave on db (= primary). The replica is an optimisation,
not a correctness requirement. Auto-routing is intentionally not
implemented — the spec rejects it because it makes read-after-write
hazards invisible.
Read-after-write hazard
Streaming replication is eventually consistent:
- Typical lag: < 1 s
- Burst writes (bulk imports, k6 load tests): up to 5–10 s
- A query issued on the replica milliseconds after the write hits the primary will read the pre-write state.
Mitigation:
- Always use
dbinside transactions and for writes. - Never re-read on
dbReadwhat you just wrote ondbin the same request. If you must, route the read throughdbtoo. - Auth-critical paths (
getUserAndTenants, session resolution) stay ondbeven though they're "reads" — they gate the rest of the request, and a stale read can mis-route a tenant boundary check.
Fallback semantics
getReadDbClient():
export function getReadDbClient(): DbClient {
const readUrl = process.env.DATABASE_READ_URL;
if (!readUrl || readUrl.length === 0) {
return getDbClient(); // single instance, no double connection
}
// memoised replica singleton
if (!global.__dbRead) {
global.__dbRead = createDbClient({ connectionString: readUrl });
}
return global.__dbRead;
}
State of DATABASE_READ_URL |
dbRead returns |
|---|---|
| unset / empty | the write client (db === dbRead) |
valid postgres://... |
a distinct read-only client |
This means CI, local dev, and any deploy without a replica continue to work without code changes. All migrations are zero-risk to roll out.
Telemetry
@toolctrl/db/telemetry.server exposes attachPrismaTelemetry() — a
$extends({ query }) wrapper that records every operation with
{ client: 'read' | 'write', model, operation, durationMs, ok }. Wire it
in the app bootstrap by adapting the recorder to your metrics sink
(prom-client Histogram / Counter or an OpenTelemetry Meter). The
canonical counter shape is prisma.queries.total{client,model,operation}.
Coolify / staging setup (operator-owned)
Replica provisioning is out of scope for the application repo — these steps live with the platform operator.
-
Primary Postgres (Coolify service
postgres).postgresql.conf:wal_level = replica max_wal_senders = 3 wal_keep_size = 256MBThen:
CREATE USER replicator WITH REPLICATION ENCRYPTED PASSWORD '...'; CREATE USER read_user WITH ENCRYPTED PASSWORD '...'; GRANT CONNECT ON DATABASE toolctrl TO read_user; GRANT USAGE ON SCHEMA public TO read_user; GRANT SELECT ON ALL TABLES IN SCHEMA public TO read_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO read_user; -
Replica Postgres (Coolify service
postgres-replica,postgres:16-alpine):# Init from primary until pg_basebackup -h postgres -D /var/lib/postgresql/data -U replicator -P -R; do sleep 5 donepostgresql.replica.conf:hot_standby = on primary_conninfo = 'host=postgres port=5432 user=replicator password=...' -
App env:
DATABASE_URL=postgres://app_user:...@pgbouncer:6432/toolctrl?pgbouncer=true&connection_limit=10 DATABASE_READ_URL=postgres://read_user:...@postgres-replica:5432/toolctrl?connection_limit=10Optionally front the replica with its own PgBouncer for connection pooling.
-
Verification:
-- On primary SELECT * FROM pg_stat_replication; -- On replica SELECT pg_last_xact_replay_timestamp(), now() - pg_last_xact_replay_timestamp() AS lag;Grafana panel
prisma.queries.total{client="read"}should rise on dashboard / KB / portal traffic.
Rollback
- Per-call-site:
git revert <migration-commit>flips that module back todb(primary). No data change. - Global: unset
DATABASE_READ_URLand restart the app.dbRead === db, all traffic hits the primary. - Replica down: Prisma's read client will throw connection errors.
Either unset
DATABASE_READ_URL(degraded performance, no errors) or add a circuit-breaker (future work, out of scope here).
Migration recipe (for future modules)
- Find candidate read-only functions — pure
findMany,findUnique,findFirst,count,groupBy,aggregatewith no surrounding write. - Check the caller graph — is this function called immediately
after a write that updates the same rows? If yes, leave on
db. - Replace
db.<model>.<op>withdbRead.<model>.<op>and add a one-line comment explaining the choice. - Add
dbReadto the file's imports alongsidedb(keepdbfor the mutations still in the file). - Run
pnpm lint --max-warnings=0andpnpm typecheck. - Tests stay green because of the fallback semantics — single-DB CI doesn't see a difference.
- Commit per-module (
03-scaling/rw-split: migrate <module> ...) so a single revert can roll back one module.
What was migrated in #41
apps/web/app/modules/crm/services/CrmService.ts— user enumeration for sync display.apps/web/app/modules/knowledgeBase/db/*.db.server.ts— public KB reads, listings, search, featured-article queries, dashboard counts.apps/web/app/modules/analytics/db/analyticsUniqueVisitors.db.server.ts—groupByaggregations.apps/web/app/utils/db/tenants.db.server.ts— admin tenant enumerations (post-auth).apps/web/app/modules/sites/db/sites.db.server.ts— read-only site loaders (auth-gatinggetSiteByTypeAndSiteIdleft on primary).apps/web/app/modules/portals/db/portals.db.server.tsandportalPages.db.server.ts— listings, page lookups, dashboard counts.
appData.ts was not migrated wholesale; only the secondary tenant
listings it consumes were moved. The auth-resolution path stays on
primary by design.