/** * Drizzle client + brand-scoped query helper. * * The app connects to Postgres directly via the `pg` driver. Drizzle sits * on top, providing typed queries. The `withBrand` wrapper is the only * sanctioned way to run a brand-scoped query — it sets the * `app.current_brand_id` GUC transaction-locally, and the database's * RLS policies enforce brand isolation even if application code forgets * a `WHERE brand_id = $1`. * * Usage (read): * const products = await withBrand(brandId, (db) => * db.select().from(productsTable).where(eq(productsTable.active, true)), * ); * * Usage (platform admin — sees all brands): * const allBrands = await withPlatformAdmin((db) => * db.select().from(brandsTable), * ); * * Usage (no brand — for the rare case the query isn't brand-scoped): * const plans = await withDb((db) => db.select().from(plansTable)); */ import "server-only"; import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; import * as schema from "./schema"; import { getPool, withTx } from "@/lib/db"; type Schema = typeof schema; export type Db = NodePgDatabase; /** * The Drizzle layer reuses the shared `pg` `Pool` from `@/lib/db` so the * app connects to Postgres through a single pool. Connection settings * (`PG_POOL_MAX`, `PG_POOL_IDLE_MS`, `PG_POOL_CONN_TIMEOUT_MS`, * `DATABASE_URL`) live in one place. See `src/lib/db.ts` for the config. * * The `withBrand` and `withPlatformAdmin` helpers reuse `withTx` from * `@/lib/db` so the BEGIN/COMMIT/ROLLBACK handling has a single home. */ /** * Run `fn` with a Drizzle client. No brand context is set — the caller * is responsible for RLS bypass (e.g. for the `plans` and `add_ons` tables, * which are not brand-scoped). For brand-scoped reads, prefer * `withBrand` or `withPlatformAdmin`. */ export async function withDb(fn: (db: Db) => Promise): Promise { const client = await getPool().connect(); try { const db = drizzle(client, { schema }); return await fn(db); } finally { client.release(); } } /** * Run `fn` inside a transaction with the current brand id set as a * transaction-local GUC. RLS policies on brand-scoped tables will allow * reads/writes only for rows where `brand_id` matches. Pass `null` to * fail open (don't set the GUC) — only useful for the migrations * themselves, never for app code. */ export async function withBrand( brandId: string, fn: (db: Db) => Promise, ): Promise { return withTx(async (client) => { // set_config(setting, value, is_local) — is_local=true makes it // transaction-local so it auto-resets at COMMIT/ROLLBACK and never // leaks across pooled connections. await client.query("SELECT set_config('app.current_brand_id', $1, true)", [ brandId, ]); await client.query("SELECT set_config('app.platform_admin', 'false', true)"); const db = drizzle(client, { schema }); return fn(db); }); } /** * Run `fn` as platform admin. RLS policies permit access to all brands. * Use sparingly — typically only in the /admin/platform routes. */ export async function withPlatformAdmin( fn: (db: Db) => Promise, ): Promise { return withTx(async (client) => { await client.query("SELECT set_config('app.current_brand_id', '', true)"); await client.query("SELECT set_config('app.platform_admin', 'true', true)"); const db = drizzle(client, { schema }); return fn(db); }); } export { schema };