Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 2 additions & 4 deletions apps/frontend/src/components/ui/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,10 @@ const config = defineConfig({
resolve: { tsconfigPaths: true },
ssr: {
// @repo/api is TypeScript source, so Vite has to transform it rather than hand
// it to Node. PGlite ships WASM and stays a plain Node import.
// it to Node. PGlite ships WASM and postgres is a Node driver — both stay
// plain Node imports.
noExternal: ["@repo/api"],
external: ["@electric-sql/pglite"],
external: ["@electric-sql/pglite", "postgres"],
},
plugins: [
rootEnv(),
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
"build": "turbo run build",
"start": "turbo run start",
"sync": "pnpm --filter @repo/api sync",
"db:migrate": "pnpm --filter @repo/api db:migrate",
"typecheck": "turbo run typecheck",
"urls": "portless list",
"fmt": "biome check --write --unsafe .",
Expand Down
16 changes: 11 additions & 5 deletions packages/api/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { defineConfig } from "drizzle-kit"

const url = process.env.POSTGRES_URL

export default defineConfig({
schema: "./src/schema.ts",
out: "./drizzle",
dialect: "postgresql",
driver: "pglite",
// The data dir sits at the workspace root so every host opens the same one; see
// src/paths.ts. drizzle-kit always runs from this package, hence the relative
// path rather than that lookup.
dbCredentials: { url: "../../pgdata" },
...(url
? { dbCredentials: { url } }
: {
// Local default: embedded PGlite. The data dir sits at the workspace root
// so every host opens the same one; see src/paths.ts. drizzle-kit always
// runs from this package, hence the relative path rather than that lookup.
driver: "pglite",
dbCredentials: { url: "../../pgdata" },
}),
})
8 changes: 5 additions & 3 deletions packages/api/migrate.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
/**
* Apply drizzle migrations and exit. Stop the server first — PGlite holds an
* exclusive lock on the data dir, so this and a running host cannot share it.
* Apply drizzle migrations and exit. With the default PGlite backend, stop the
* server first — PGlite cannot safely share its data dir across processes. With
* `POSTGRES_URL` set, a running host can stay up.
*
* `pnpm sync` also migrates before writing, so a fresh clone that syncs never
* needs to call this separately. Use it when you changed a migration and want
* the schema updated without a catalog pull.
*/
import { closeDb, migrateDb } from "./src/db"
import { closeDb, describeDb, migrateDb } from "./src/db"

console.log(`migrating ${describeDb()}`)
await migrateDb()
await closeDb()
console.log("migrations applied")
5 changes: 3 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
"rpc-demo": "tsx --env-file-if-exists=../../.env src/rpc-demo.ts",
"typecheck": "tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx migrate.ts",
"db:studio": "drizzle-kit studio",
"db:migrate": "tsx --env-file-if-exists=../../.env migrate.ts",
"db:studio": "node --env-file-if-exists=../../.env ./node_modules/drizzle-kit/bin.cjs studio",
"test": "node --env-file-if-exists=../../.env node_modules/vitest/vitest.mjs run",
"smoke": "tsx --env-file-if-exists=../../.env smoke.ts"
},
Expand Down Expand Up @@ -38,6 +38,7 @@
"drizzle-orm": "^0.45.2",
"hono": "^4.12.32",
"hono-openapi": "^1.3.1",
"postgres": "^3.4.9",
"quickjs-emscripten-core": "0.32.0",
"zod": "^4.4.3"
}
Expand Down
146 changes: 108 additions & 38 deletions packages/api/src/db.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,73 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { PGlite } from "@electric-sql/pglite"
import { drizzle } from "drizzle-orm/pglite"
import { migrate } from "drizzle-orm/pglite/migrator"
import {
drizzle as drizzlePglite,
type PgliteDatabase,
} from "drizzle-orm/pglite"
import { migrate as migratePglite } from "drizzle-orm/pglite/migrator"
import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js"
import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator"
import postgres from "postgres"
import { DATA_DIR, MIGRATIONS_DIR } from "./paths"
import { runs, scripts, tools } from "./schema"

export { DATA_DIR }

type Handle = { pg: PGlite; close: () => Promise<void> }
/** 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<typeof schema>

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<void>
migrate: () => Promise<void>
}

/**
* 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.
*
Expand All @@ -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")
Expand Down Expand Up @@ -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."
)
}

Expand All @@ -89,33 +138,57 @@ function claimDataDir(): () => void {
return () => rmSync(owner, { force: true })
}

async function open(): Promise<Handle> {
const releaseOwner = claimDataDir()
const pg = await PGlite.create(DATA_DIR)

let closing: Promise<void> | 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>): 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<Handle> {
const releaseOwner = claimDataDir()
const client = await PGlite.create(DATA_DIR)
const db = drizzlePglite({ client, schema })

let closing: Promise<void> | 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<void> | 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<Handle> {
if (POSTGRES_URL) return openPostgres(POSTGRES_URL)
return openPglite()
}

/** Reuses the handle a previous evaluation of this module parked on `globalThis`. */
Expand All @@ -129,14 +202,10 @@ async function acquire(): Promise<Handle> {

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<void> | undefined

Expand All @@ -145,8 +214,9 @@ let migrated: Promise<void> | 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())
13 changes: 7 additions & 6 deletions packages/api/src/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
17 changes: 15 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.