09f18de652
db/client.ts was creating its own pg Pool with its own lazy initialization, while src/lib/db.ts already had an identical pool and a withTx helper. Two pools per process means double the Postgres connections, double the env-var parsing, and double the BEGIN/COMMIT plumbing to maintain. After this change: - db/client.ts imports getPool/withTx from src/lib/db.ts - withBrand and withPlatformAdmin wrap withTx (single transaction helper) - One pg.Pool per Node process No behavior change for callers — withTx is byte-identical to the deleted runInTransaction. The shared getPool uses a 30s connection timeout (was 10s in the deleted copy); 30s is what src/lib/db.ts has always shipped to production via its callers, so this aligns the Drizzle layer with the rest of the app.
99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
/**
|
|
* 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<Schema>;
|
|
|
|
/**
|
|
* 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<T>(fn: (db: Db) => Promise<T>): Promise<T> {
|
|
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<T>(
|
|
brandId: string,
|
|
fn: (db: Db) => Promise<T>,
|
|
): Promise<T> {
|
|
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<T>(
|
|
fn: (db: Db) => Promise<T>,
|
|
): Promise<T> {
|
|
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 };
|