The Postgres Port

The Postgres Port

The base warpkit template's differentiator is per-user SQLite: every user gets their own database file, isolation by construction. That's still the right default for most single-region, self-hosted B2C SaaS apps , see the base template's design philosophy for why.

This fork (warpkit-postgres) makes a different tradeoff for a different deploy target. It runs on a single shared Postgres database instead, because that's what unlocks deploying to Cloudflare Workers , a serverless runtime with no persistent filesystem to hold data/users/<userId>/user.db files, and no long-lived process to hold a connection-pool-per-user cache in memory. If your deploy target is a VPS or a container with a persistent volume, the base template's per-user SQLite model is simpler and gives you isolation for free; if you want serverless/edge deploy (Cloudflare Workers today, this doc's reasoning generalizes to similar platforms), this fork's shared-Postgres model is what makes that possible.

What changed, structurally

Base warpkitThis fork
User dataOne SQLite file per user (data/users/<userId>/user.db)Shared Postgres, every table scoped by user_id
Shared app datameta.db (SQLite)Same shared Postgres database, same tables
Write serializationwithWriteLock(userId, fn) , process-local mutexwithUserTransaction(userId, fn) , a real Postgres transaction
IDsBun.randomUUIDv7() (native, Bun-only)uuidv7() from the uuidv7 npm package (portable, works under workerd too)
MigrationsHand-written files in src/lib/db/migrations/, run on getUserDb()drizzle-kit generatedrizzle/*.sqlscripts/migrate.ts
Deploy targetsSelf-hosted only (Bun long-lived process)Self-hosted (unchanged) and Cloudflare Workers

The tenant isolation tradeoff, stated plainly

The old per-user-SQLite model was isolation by construction , no SQL surface connected tenants, so a bug could not leak cross-tenant data. This fork uses a single shared Postgres database with app-level user_id scoping on every query instead. A forgotten .where(eq(table.userId, ...)) is now a real cross-tenant leak, not a structural impossibility. Every feature must filter every tenant-table query by user_id , there is no compensating enforcement for this yet (a CI lint check for it is a documented TODO, not built). See src/lib/db/user-db.ts's header comment for the full reasoning.

This fork deliberately did not use Postgres Row Level Security (RLS) to close that gap. RLS is the standard way to enforce tenant isolation at the database layer on shared Postgres, and it's a reasonable choice , but this fork chose app-level user_id filtering instead, consistent with how every other query in the codebase already works (requireUser() gates, withUserTransaction scoping). Revisit RLS if the app-level discipline proves insufficient in practice; it wasn't rejected for a technical reason, just not the path taken here.

withUserTransaction replaces the old withWriteLock mutex, but is not equivalent locking: the old mutex serialized every request for a given user, one at a time, for the whole operation. A Postgres transaction at the default read-committed isolation level does not , two concurrent requests from the same user can both read a pre-write count before either writes, and both proceed. This is a real, accepted phase-1 limitation for check-then-write paths (rate limits, plan-limit counts), not a preserved guarantee. See user-db.ts's comments before assuming otherwise.

Reading and writing data

import { withUserTransaction } from '@/lib/db/user-db';
import { db } from '@/lib/db';
import { items } from '@/lib/db/schema';
import { eq } from 'drizzle-orm';
import { uuidv7 } from 'uuidv7';

// Read-only: query the shared pool directly, always filter by user_id
const rows = await db.select().from(items).where(eq(items.userId, user.id));

// Write: inside withUserTransaction, using the passed-in tx handle for every query
return withUserTransaction(user.id, async (tx, userId) => {
  await tx.insert(items).values({ id: uuidv7(), userId, title: data.title });
  return ok({ created: true });
});

The closure passed to withUserTransaction must use the tx handle it receives for every query inside , calling a helper with the outer db (or re-deriving a connection some other way) instead of tx breaks atomicity silently: each call would land on a different pooled connection and nothing would actually be transactional.

Adding a table

  1. Add the table to src/lib/db/schema.ts (a normal Drizzle pgTable, with a userId column + FK to users.id with onDelete: 'cascade' for any user-scoped table)
  2. bun run db:generate (drizzle-kit generate) , produces a new drizzle/*.sql file
  3. bun run db:migrate (scripts/migrate.ts) , applies pending migrations against DATABASE_URL

Reference: any already-ported feature (src/features/notes/, api-keys/, uploads/) added its table this way.

Why UUIDv7

Both templates use UUIDv7 over crypto.randomUUID()'s UUIDv4: the format is identical (a standard UUID string), but v7 is time-ordered and monotonically increasing within the same millisecond, which gives better B-tree/index locality on high-insert tables , this matters more on Postgres than SQLite, since Postgres's default heap storage and any B-tree index on an ID column benefit from insert-order locality the way UUIDv4's fully-random ordering doesn't.

The base template calls Bun.randomUUIDv7() , native, zero dependencies, but Bun-only, which doesn't run under workerd (Cloudflare Workers). This fork uses the uuidv7 npm package instead:

import { uuidv7 } from 'uuidv7';
// or, for a minimal call-site diff when porting code that used the old name:
import { uuidv7 as randomUUIDv7 } from 'uuidv7';

const id = uuidv7();
// e.g. "019e3bbe-3150-7001-a28b-5fcf6ba4d1ad"
//      ^^^^^^^^^^^^ timestamp prefix: sequential

Verified monotonic by default for this exact package , no boolean argument, it doesn't exist on this package's API.

Cloudflare Workers deploy

See docs/warpkit/deployment.md's Cloudflare Workers section for the full setup , Hyperdrive (proxies the standard Postgres wire protocol for any Postgres host, not an HTTP-only driver), nodejs_compat, Static Assets binding, and the Cron Trigger that replaces the self-hosted poll loop for background jobs (see docs/warpkit/features/jobs.md).

Scaling beyond a single shared Postgres database

The base template's "scaling beyond per-user SQLite" question (multiple workspaces per identity, multiple simultaneous identities) still applies here, just without the "give each workspace its own SQLite file" step , a new workspace is just another row scoped by workspace_id in the same shared database, following the same user_id-scoping pattern already used everywhere. Better Auth's multiSession plugin (multiple identities in one browser session) is unaffected by the DB layer choice either way , it's already configured in src/server/auth.ts regardless of which template you're on.

At real scale, shared Postgres brings its own questions this fork doesn't yet need to answer , read replicas, connection pool sizing under high concurrency, partitioning a very large table. None of that has come up yet at this fork's current scale; cross that bridge when a real workload demands it, not before.