diff --git a/apps/frontend/package.json b/apps/frontend/package.json index b6dfd70..4f0ae97 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -44,6 +44,7 @@ "input-otp": "^1.4.2", "jotai": "^2.20.2", "lucide-react": "^1.26.0", + "postgres": "^3.4.9", "react": "^19.2.6", "react-day-picker": "^10.0.1", "react-dom": "^19.2.6", diff --git a/apps/frontend/src/components/ui/sidebar.tsx b/apps/frontend/src/components/ui/sidebar.tsx index 1031aa3..7a5cc10 100644 --- a/apps/frontend/src/components/ui/sidebar.tsx +++ b/apps/frontend/src/components/ui/sidebar.tsx @@ -598,10 +598,8 @@ function SidebarMenuSkeleton({ }: React.ComponentProps<"div"> & { showIcon?: boolean }) { - // Random width between 50 to 90%. - const [width] = React.useState(() => { - return `${Math.floor(Math.random() * 40) + 50}%` - }) + // Fixed width — Math.random() here caused a server/client hydration mismatch. + const width = "70%" return (
Promise } +/** Where this process's `db` points — safe to log (password stripped). */ +export function describeDb(): string { + if (POSTGRES_URL) { + try { + const url = new URL(POSTGRES_URL) + url.password = "" + return `postgres ${url.toString().replace(/\/$/, "")}` + } catch { + return "postgres (POSTGRES_URL set, unparseable)" + } + } + return `pglite ${DATA_DIR}` +} + +const schema = { tools, scripts, runs } + +export type Db = PgliteDatabase + +function isDb(value: unknown): value is Db { + return ( + typeof value === "object" && + value !== null && + "select" in value && + "insert" in value && + "transaction" in value + ) +} + +/** Postgres-js and PGlite drizzle clients share the query surface we use. */ +function asDb(value: unknown): Db { + if (!isDb(value)) throw new Error("expected a drizzle database client") + return value +} + +type Handle = { + db: Db + close: () => Promise + migrate: () => Promise +} /** - * One PGlite instance per process, and module scope alone can't guarantee that - * here: the frontend hosts this package as Vite source, and Vite re-evaluates a - * module whenever something it imports is edited. Parking the handle on - * `globalThis` outlives those re-evaluations, so an edit reuses the open database - * instead of opening a second one against the same dir. + * One database handle per process. Module scope alone can't guarantee that here: + * the frontend hosts this package as Vite source, and Vite re-evaluates a module + * whenever something it imports is edited. Parking the handle on `globalThis` + * outlives those re-evaluations, so an edit reuses the open database instead of + * opening a second one (against the same PGlite dir, or a second Postgres pool). * - * Across processes, see {@link claimDataDir} — PGlite does not enforce that itself. + * Across processes with PGlite, see {@link claimDataDir}. Real Postgres needs no + * such guard — the server serializes writers itself. */ const HANDLE = "__repoApiDb" as const +const POSTGRES_URL = process.env.POSTGRES_URL + /** * Refuses to open the data dir if another live process already has it. * @@ -34,6 +81,8 @@ const HANDLE = "__repoApiDb" as const * So the guard is ours: an atomically-created owner file naming the live pid. A * crashed process leaves a stale one, which is detected and reclaimed — the point * is only to turn a corrupted database into a message that says what to stop. + * + * Only used when `POSTGRES_URL` is unset. */ function claimDataDir(): () => void { const owner = join(DATA_DIR, "owner.json") @@ -78,7 +127,7 @@ function claimDataDir(): () => void { `${DATA_DIR} is already open by pid ${held.pid} (${held.argv?.join(" ") ?? "unknown"}). ` + "PGlite gives each process its own page cache, so a second writer corrupts the " + "database rather than failing. Stop that process first — or run `pnpm dev:ports` " + - "on a different PGLITE_DATA_DIR." + "on a different PGLITE_DATA_DIR, or set POSTGRES_URL to use a shared Postgres." ) } @@ -89,33 +138,57 @@ function claimDataDir(): () => void { return () => rmSync(owner, { force: true }) } -async function open(): Promise { - const releaseOwner = claimDataDir() - const pg = await PGlite.create(DATA_DIR) - - let closing: Promise | undefined - const handle: Handle = { - pg, - close: () => (closing ??= pg.close().finally(releaseOwner)), - } - - // A data dir left mid-write will not reopen, and every host gets signalled as a - // matter of routine — `tsx watch` on each restart, Ctrl-C on the dev server — so - // closing on the way out is what keeps ./pgdata reusable. +function installCloseHooks(close: () => Promise): void { + // A PGlite data dir left mid-write will not reopen, and every host gets signalled + // as a matter of routine — `tsx watch` on each restart, Ctrl-C on the dev server — + // so closing on the way out is what keeps ./pgdata reusable. For Postgres this + // just drains the pool cleanly. for (const signal of ["SIGINT", "SIGTERM"] as const) { - process.once( - signal, - () => void handle.close().finally(() => process.exit(0)) - ) + process.once(signal, () => void close().finally(() => process.exit(0))) } // Covers the processes that just end: `pnpm sync`, `vitest`, any one-shot script // that forgets to close. `beforeExit` fires once the loop drains and awaits work // scheduled inside it, so the close completes — and it never fires while a server // is listening, so this cannot cut a running host off from its database. - process.once("beforeExit", () => void handle.close()) + process.once("beforeExit", () => void close()) +} + +async function openPglite(): Promise { + const releaseOwner = claimDataDir() + const client = await PGlite.create(DATA_DIR) + const db = drizzlePglite({ client, schema }) + + let closing: Promise | undefined + const close = () => (closing ??= client.close().finally(releaseOwner)) + installCloseHooks(close) + + return { + db, + close, + migrate: () => migratePglite(db, { migrationsFolder: MIGRATIONS_DIR }), + } +} + +function openPostgres(url: string): Handle { + const client = postgres(url) + const pgDb = drizzlePostgres({ client, schema }) - return handle + let closing: Promise | undefined + const close = () => + (closing ??= client.end({ timeout: 5 }).then(() => undefined)) + installCloseHooks(close) + + return { + db: asDb(pgDb), + close, + migrate: () => migratePostgres(pgDb, { migrationsFolder: MIGRATIONS_DIR }), + } +} + +async function open(): Promise { + if (POSTGRES_URL) return openPostgres(POSTGRES_URL) + return openPglite() } /** Reuses the handle a previous evaluation of this module parked on `globalThis`. */ @@ -129,14 +202,10 @@ async function acquire(): Promise { const handle = await acquire() -export const pg = handle.pg - /** Idempotent, and hands every caller the same close promise. */ export const closeDb = handle.close -export const db = drizzle({ client: pg, schema: { tools, scripts, runs } }) - -export type Db = typeof db +export const db = handle.db let migrated: Promise | undefined @@ -145,8 +214,9 @@ let migrated: Promise | undefined * * Call this from `pnpm db:migrate` or `pnpm sync`, not from request handlers: * schema changes belong outside the request path so a deploy can't surprise a - * live process mid-query. Stop the host first — migrating underneath a running - * server is what {@link claimDataDir} exists to refuse. + * live process mid-query. With PGlite, stop the host first — migrating underneath + * a running server is what {@link claimDataDir} exists to refuse. With + * `POSTGRES_URL`, concurrent migrate + serve is fine at the connection level; + * still prefer doing schema changes deliberately. */ -export const migrateDb = () => - (migrated ??= migrate(db, { migrationsFolder: MIGRATIONS_DIR })) +export const migrateDb = () => (migrated ??= handle.migrate()) diff --git a/packages/api/src/paths.ts b/packages/api/src/paths.ts index a98e62a..60fb8ce 100644 --- a/packages/api/src/paths.ts +++ b/packages/api/src/paths.ts @@ -2,12 +2,13 @@ import { existsSync } from "node:fs" import { dirname, join, resolve } from "node:path" /** - * Three different processes open this package's data dir — the frontend dev - * server (cwd `apps/frontend`), the standalone API (cwd `apps/backend`) and the - * `sync`/`db:studio` scripts (cwd `packages/api`) — and they must all land on the - * same PGlite directory. Relative paths can't do that, and `import.meta.url` - * moves when the frontend bundles this source into `.output`, so the anchor is - * the workspace root found by walking up from the cwd. + * Three different processes may open this package's embedded PGlite data dir — + * the frontend dev server (cwd `apps/frontend`), the standalone API (cwd + * `apps/backend`) and the `sync`/`db:studio` scripts (cwd `packages/api`) — and + * they must all land on the same directory when `POSTGRES_URL` is unset. + * Relative paths can't do that, and `import.meta.url` moves when the frontend + * bundles this source into `.output`, so the anchor is the workspace root found + * by walking up from the cwd. */ function findWorkspaceRoot(from = process.cwd()): string { for (let dir = resolve(from); ; dir = dirname(dir)) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41ae014..6c3f9fc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: lucide-react: specifier: ^1.26.0 version: 1.26.0(react@19.2.8) + postgres: + specifier: ^3.4.9 + version: 3.4.9 react: specifier: ^19.2.6 version: 19.2.8 @@ -258,13 +261,16 @@ importers: version: 7.0.37(zod@4.4.3) drizzle-orm: specifier: ^0.45.2 - version: 0.45.2(@electric-sql/pglite@0.5.4) + version: 0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9) hono: specifier: ^4.12.32 version: 4.12.32 hono-openapi: specifier: ^1.3.1 version: 1.3.1(@hono/standard-validator@0.3.0(@standard-schema/spec@1.1.0)(hono@4.12.32))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod-to-json-schema@3.25.2(zod@4.4.3))(zod@4.4.3))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.4.3))(@types/json-schema@7.0.15)(hono@4.12.32)(openapi-types@12.1.3) + postgres: + specifier: ^3.4.9 + version: 3.4.9 quickjs-emscripten-core: specifier: 0.32.0 version: 0.32.0 @@ -5309,6 +5315,10 @@ packages: resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} engines: {node: ^10 || ^12 || >=14} + postgres@3.4.9: + resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} + engines: {node: '>=12'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -9535,9 +9545,10 @@ snapshots: esbuild: 0.25.12 tsx: 4.23.1 - drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4): + drizzle-orm@0.45.2(@electric-sql/pglite@0.5.4)(postgres@3.4.9): optionalDependencies: '@electric-sql/pglite': 0.5.4 + postgres: 3.4.9 dunder-proto@1.0.1: dependencies: @@ -11391,6 +11402,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres@3.4.9: {} + powershell-utils@0.1.0: {} prelude-ls@1.2.1: {}