Maple stores relational application state in PostgreSQL with a schema defined by Drizzle in
packages/db/src/schema/.
- Production and staging: one PlanetScale Postgres branch per stage. Cloudflare Workers
connect through the
MAPLE_DBHyperdrive binding; the application never opens the direct administrative connection. - Wrangler development: Docker Postgres on port 5499 through Hyperdrive's
localConnectionString. - Non-Worker local entrypoints and tests: embedded PGlite.
MAPLE_DB_URLis a PGlite data directory, ormemory://for an ephemeral database. It is not a remote database URL. - PR previews: no application database while preview deploys are disabled. Routes that
need
Databasefail normally; DB-free routes such as health checks continue to work.
Application code keeps timestamps as epoch-millisecond numbers and converts at the Drizzle
boundary — use msToDate / dateToMs from apps/api/src/platform/time.ts rather than bare
new Date(ms) / .getTime(), including inside Promise-land helpers.
One connection per invocation — request, cron tick, or Workflow run — created lazily on the first
query and closed at the boundary. This is Cloudflare's documented Hyperdrive shape, and
makePgConnectionScope (apps/api/src/platform/pg-connection-scope.ts) is the only implementation
of it: pgConnectionMiddleware installs a scope for HTTP, withPgConnectionScope for cron, and
executeOnFreshPgClient is the same scope one call long for entry points that have none.
Workers tie TCP sockets to the invocation that opened them, so a connection may be reused freely within one but must never outlive it. Two settings carry hard-won history:
max: 5— Cloudflare's documented value.maxis a ceiling, not a reservation: postgres.js opens a second socket only when a second statement is genuinely in flight. It was 1 for one day on the theory that Postgres should hold at most one of the Worker's six outbound slots, which serialized every statement in a cron tick behind one connection (SELECT actorsp50 928ms → 5687ms at flat volume).- A bounded
connect_timeout— postgres.js only raisesCONNECT_TIMEOUTfromconnectTimedOut(), and itstimer()is a no-op when the option is unset, so an unbounded dial hangs for the whole invocation and lands with noerror.typeto classify. The bound is generous and single: a 2s cap alone once took production 5xx from 0.06% to 5.01%, and the retry ladder that followed existed only to compensate for it.
Start and migrate the Docker Postgres used by Wrangler:
bun db:up
bun db:migrate:localPersistent PGlite is created automatically for non-Worker local entrypoints under
apps/api/.data/pglite. Set MAPLE_DB_URL=memory:// when persistence is not wanted.
Change the Drizzle schema, then generate the SQL and metadata together:
bun run --cwd packages/db db:generateReview the generated file in packages/db/drizzle/ and its matching journal/snapshot changes.
Do not hand-create a migration without also updating drizzle/meta/_journal.json; both deployed
Postgres and PGlite use Drizzle's journal ordering.
Useful local commands:
bun run --cwd packages/db db:migrate
bun run --cwd packages/db db:push
bun run --cwd packages/db db:studiodb:push is a development utility only. Committed environments use migrations.
CI runs drizzle-kit migrate against the stage's PlanetScale direct port 5432 before the
Alchemy deployment. Never run migrations through a pooler or Hyperdrive. The deployed Worker
does not migrate on boot.
PGlite applies the same bundled migrations while its layer is built. The test harness caches a fresh migrated PGlite snapshot and restores it per test, so integration tests exercise the PostgreSQL schema without a shared server.
Raw traces and logs are retained for 30 days. Projection targets that preserve one row per
span or log use the same 30-day ceiling; aggregate targets intentionally retain rollups for 90 or
365 days. The TTL belongs to the target datasource in
packages/domain/src/tinybird/datasources.ts, not to the materialized-view definition.
Two operational consequences:
-
Backfill ceiling. When deploying a new MV with
POPULATE, you can only backfill data the source table still has — anything aged past the source TTL is lost. Plan deploys before any TTL reduction. -
TTL changes require a target audit. Keep row-level projections in lockstep with their raw source. Preserve the independently documented retention of hourly and error rollups unless the product retention policy changes too.
Before deploying, confirm SpanName cardinality fits the MV sort key. Run against production:
SELECT
OrgId,
toStartOfHour(Timestamp) AS hour,
uniq(SpanName) AS span_name_cardinality
FROM traces
WHERE Timestamp > now() - INTERVAL 7 DAY
GROUP BY OrgId, hour
ORDER BY span_name_cardinality DESC
LIMIT 50Decision rule:
- p99 < 1K distinct → keep
SpanNamein MV dimensions (current setup) - p99 1K–10K → keep but only route to MV when query has a
SpanNamefilter - p99 > 10K → drop
SpanNamefrom MV dimensions; group-by-span-name queries fall back to rawtraces
High cardinality is usually a tenant emitting per-request data in span names (anti-pattern, but seen). Address at the source if found.