Database Schema
The 5 keyspaces, table-by-table, and how the schema is authored in Drizzle.
OpenInsure uses Cloudflare Hyperdrive as the connection pooler + read-query cache between Cloudflare Workers and PlanetScale. Workers cannot maintain a persistent MySQL connection pool — every request is a fresh isolate. Hyperdrive solves that by keeping warm pools at every CF point of presence, multiplexing many Worker isolates onto shared MySQL connections, and caching eligible read queries.
A Worker isolate has no persistent connection pool. Without Hyperdrive, every cold start would have to TCP + TLS + MySQL-handshake to PlanetScale (~150–300 ms of latency on the critical path of every request). Hyperdrive makes that effectively zero: the Worker’s first query is served from a connection that was already warm in the same PoP.
Three things Hyperdrive does for us:
It also automates credential management — the connection string lives in Hyperdrive’s config in Cloudflare’s control plane; the Worker only ever sees a binding object.
oi-sys-apiWe expose one Hyperdrive binding per keyspace plus a legacy monolithic binding that still routes via the cutover routing rules:
| Binding | Targets keyspace | Purpose |
|---|---|---|
HYPERDRIVE | ai (all 5 via routing) | Legacy / fallback during cutover |
HYPERDRIVE_GLOBAL | oi_global | Orgs, users, programs, reference data |
HYPERDRIVE_POLICIES | oi_policies | Policy lifecycle |
HYPERDRIVE_SUBMISSIONS | oi_submissions | Quote/bind workflow |
HYPERDRIVE_CLAIMS | oi_claims | Claim lifecycle |
HYPERDRIVE_BILLING | oi_billing | Billing, payments, GL |
Each binding has its own connection pool, its own credentials, its own query cache, and its own monitoring. A bad query in one keyspace cannot exhaust connections in another.
The middleware in apps/api/src/middleware/database.ts injects all 6 onto every Hono context as db, globalDb, policiesDb, submissionsDb, claimsDb, billingDb. Routes pull what they need.
oi-sys-api has Cloudflare Smart Placement enabled ([placement] mode = "smart" in wrangler.toml). Cloudflare watches the Worker’s query patterns and reschedules it to run in the region closest to the most-queried Hyperdrive pool. Since all 6 of our Hyperdrives target us-east, the Worker effectively pins itself to a US-East PoP whenever DB latency is on the critical path.
This trades a tiny amount of edge-locality (the user might be served from us-east instead of their own PoP) for a much larger latency win on the database side. For an insurance API where almost every endpoint hits PlanetScale, that tradeoff is the right one.
Hyperdrive caches eligible read-only queries. The eligibility rules:
Cached:
SELECT statements that use only IMMUTABLE functionsNot cached (by Hyperdrive):
INSERT, UPDATE, DELETE, UPSERT, REPLACE)STABLE functions (e.g., NOW(), CURRENT_TIMESTAMP, LAST_INSERT_ID(), LASTVAL())VOLATILE functions (e.g., RANDOM(), UUID())USE statements, multi-statement queries, certain auth plugins)The fix for cached-by-default code that needs NOW() is to compute the value in the Worker and pass it as a parameter:
// Uncacheableconst policies = await policiesDb.execute(sql` SELECT * FROM policies WHERE created_at > NOW() - INTERVAL 1 HOUR`);
// Cacheableconst cutoff = new Date(Date.now() - 60 * 60 * 1000).toISOString();const policies = await policiesDb.execute(sql` SELECT * FROM policies WHERE created_at > ${cutoff}`);Each Hyperdrive binding declares a localConnectionString in apps/api/wrangler.toml so wrangler dev --local can talk to the docker-compose Vitess testserver instead of contacting Cloudflare:
[[hyperdrive]]binding = "HYPERDRIVE_POLICIES"id = "..." # production ID, ignored in --local modelocalConnectionString = "mysql://root:root@127.0.0.1:3306/openinsure"Start the local Vitess: make vitess-up. The 5-keyspace topology mirrors production exactly.
The factory in packages/db/src/domain-clients.ts chooses one of two drivers based on the connection string format:
function buildDomainDb<T>(hd: HyperdriveBindingLike, schema: T) { if (hd.connectionString.startsWith('mysql://')) { // Local dev path: mysql2 driver const pool = mysql.createPool(hd.connectionString); return drizzleMysql2(pool, { schema, mode: 'planetscale' }); } // Production path: @planetscale/database HTTP fetch driver const client = new Client({ url: hd.connectionString }); return drizzlePlanetScale(client, { schema });}| Environment | Driver | Used because |
|---|---|---|
| Workers | @planetscale/database (HTTP fetch) | No raw TCP from a Worker |
| Local dev | mysql2 | Talks to docker-compose Vitess testserver |
| Tests | mysql2 | Same as local dev |
Both go through Drizzle with mode: 'planetscale', so the query API is identical from the route’s perspective.
| Limit | Value | Notes |
|---|---|---|
| Concurrent external conns per Worker request | 5 (Workers platform limit, not HD) | Use max: 5 in mysql2/postgres pool config |
| Hyperdrive cache TTL | configurable per binding (default ~60s) | Set in dashboard or via wrangler hyperdrive update |
| Idle connection lifetime | 24 hours (PlanetScale-side) | mysql2 should send keepalive |
| MySQL features unsupported | USE statements, multi-statement queries, non-UTF8 charsets, certain auth plugins | If you need them, bypass Hyperdrive |
For PostgreSQL-specific unsupported features (LISTEN/NOTIFY, advisory locks, prepared-statement management), see the official Cloudflare docs — we don’t run Postgres so they don’t apply.
Database Schema
The 5 keyspaces, table-by-table, and how the schema is authored in Drizzle.
Database Migrations
Deploy-request workflow, branch lifecycle, and the migration TUI.
Local Development
Setting up the local Vitess testserver and running the API Worker locally.