@zooly/app-db is the database access package for the main application. This package manages all database interactions using Drizzle ORM and enforces multi-tenant data access patterns.
NOTE - this package should never be imported in a client package.
@zooly/app-dbpackages/dbWe use Drizzle ORM for database access for several important reasons:
pgvector, which is crucial for applications that need vector similarity search and AI/ML featuresThese features make Drizzle the ideal choice for our use case, providing both type safety and the flexibility needed for complex database operations.
accountId parameteraccess package within the db packageThe package follows a strict access pattern:
packages/db/
├── src/
│ ├── access/ # Access functions (enforce tenant filtering)
│ │ ├── stripePayments.ts # Payment CRUD operations
│ │ ├── payOut.ts # Payout operations
│ │ ├── paymentShareTracking.ts # Revenue distribution tracking
│ │ ├── accountPayoutRoutes.ts # Payout route configuration
│ │ ├── accountPaymentSettings.ts # Payment settings
│ │ ├── paymentSystemLogs.ts # Audit logging
│ │ ├── ppu.ts # Purchase code management
│ │ └── accountAgents.ts # Agent relationships
│ ├── schema/ # Database schema definitions
│ │ ├── paymentEnums.ts # Payment-related enums
│ │ ├── stripePayments.ts # Payment transaction records
│ │ ├── payOut.ts # Payout records
│ │ ├── paymentShareTracking.ts # Revenue distribution tracking
│ │ └── ... # Other schemas
│ ├── db.ts # Database connection (not exported)
│ └── index.ts # Public API (only exports access functions)
Critical Design Principle: The raw database schema and database object are never exported from the db package. Instead, all database access is exclusively handled through access functions.
This design pattern serves two critical purposes:
Clean Separation: By keeping the database connection and schema definitions internal to the package, we maintain a clean separation of concerns. Other packages cannot directly access the database, ensuring all interactions go through the intended access layer.
Multi-Tenant Enforcement: Access functions require an accountId parameter and automatically apply tenant filtering. This ensures that all queries are multi-tenant aware by design—it's impossible to bypass tenant filtering when using the access functions.
The build will fail if you attempt to use an access function without providing the required accountId parameter, making multi-tenant support a compile-time guarantee rather than a runtime check.
See Multi-tenant Design in the introduction for more details on how this pattern is enforced across the platform.
All database operations are performed through access functions that:
accountId parameteraccountId is missingThe db.ts module uses two different Drizzle drivers depending on the DATABASE_URL:
| Environment | URL contains | Driver | Why |
|---|---|---|---|
| Production / Staging (Vercel) | neon.tech | @neondatabase/serverless + drizzle-orm/neon-serverless | Purpose-built for serverless; avoids TCP pool exhaustion across cold-starts |
| Local development | anything else | postgres.js + drizzle-orm/postgres-js | Works with local Postgres; no Neon dependency needed |
Detection is automatic — the code checks if the connection string contains neon.tech and picks the right driver. The rest of the codebase is unaffected; both drivers expose the same Drizzle API.
postgres.js maintains a persistent TCP connection pool. On Vercel, every serverless cold-start creates a new pool (default max: 10 connections). Under concurrent traffic, dozens of function instances each holding 10 connections quickly exhaust the database's max_connections limit, causing pool exhaustion errors.
The @neondatabase/serverless driver solves this: regular queries go over stateless HTTP and interactive transactions use WebSocket — no persistent pool that survives across cold-starts.
Neon (production):
| Setting | Value | Purpose |
|---|---|---|
max | 5 | Per-instance connection cap (kept low for serverless) |
idleTimeoutMillis | 20 000 | Release idle connections after 20 s |
connectionTimeoutMillis | 10 000 | Fail fast if a connection can't be established in 10 s |
Local dev (postgres.js):
| Setting | Value | Purpose |
|---|---|---|
idle_timeout | 20 | Release idle connections after 20 s |
connect_timeout | 10 | Fail fast on connection issues |
db.execute() result helpersBecause the two drivers return different shapes from db.execute() (postgres-js returns rows directly as an array; neon-serverless wraps them in { rows: [...], rowCount }) the package exports two helpers:
extractRows<T>(result) — returns the rows array regardless of driver.extractRowCount(result) — returns the affected-row count regardless of driver.Always use extractRows() / extractRowCount() when reading results from db.execute() or tx.execute(). Casting the result directly will break on one of the two drivers.
Schema migrations (packages/db/src/migrate.ts) always use a dedicated postgres.js client with max: 1, regardless of which driver the app uses. This is intentional — Neon recommends direct connections for DDL operations.
@neondatabase/serverless — Neon serverless driver (production)ws — WebSocket polyfill for Node (used by the Neon driver for transactions)postgres — postgres.js driver (local development + migrations)@zooly/types for shared typesDrizzle uses migrations to manage database schema changes. Here's how to work with them:
After making changes to your schema files in src/schema/, generate migration files:
npm run db:generate
This command:
After making changes to your schema files in packages/db/src/schema/, generate migration files:
cd packages/db
npm run db:generate
# or
set -a && source ../../../.env.local && npm run db:generate
This command:
packages/db/drizzle/Important: Migration SQL files are committed to git so they can be applied during production deployments.
For local development, apply migrations manually:
cd packages/db
npm run db:migrate:deploy
# or
set -a && source ../../../.env.local && npm run db:migrate:deploy
This will:
drizzle.__drizzle_migrations tableImportant: db:push should only be used in development. In production, always use db:generate followed by db:migrate to maintain proper migration history and version control.
Critical: Never develop against the remote server database. Running db:generate or db:migrate against a remote production or shared database can break it for other developers or users.
Always use a local database instance for development. See Database Setup for instructions on setting up a local database.
db:generate - Generate migration files from schema changesdb:migrate - Apply pending migrations to the databasedb:push - Push schema changes directly (development only)db:studio - Open Drizzle Studio for database inspectionOn This Page
OverviewPackage DetailsWhy Drizzle ORM?Key FeaturesArchitectureAccess Pattern: No Direct Schema or DB ExportsDatabase OperationsConnection Architecture (Dual Driver)Why not just postgres.js everywhere?Pool configuration[object Object], result helpersMigrationsDependenciesDatabase MigrationsWorking with MigrationsGenerate MigrationsApply MigrationsApply Migrations LocallyDevelopment Best PracticesScripts