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: {}