postgrex is a local, single-user PostgreSQL GUI client distributed as an npm
tool (think npx postgrex). The Express server runs on the user's own machine
and serves one browser at a time — never design for concurrent multi-user use.
server/ Express API + Vite middleware (dev) / static dist (prod)
├── index.ts entry: middleware, listen, graceful shutdown
├── config.ts env-derived config (PORT, NODE_ENV)
├── db.ts pg pooling + testConnection
├── session.ts encrypted httpOnly cookie session (connection credentials)
├── api/ one router per domain, aggregated in api/index.ts
└── sql/ SQL strings, one file per domain
client/ React SPA (Vite, path alias @ → client/)
├── App.tsx route table — every page React.lazy'd
├── data/ TanStack Query options + response types, one file per domain
├── lib/ generic api fetch helper (no domain types), utils
├── pages/ presentation only (see rules below)
└── components/
├── [domain]/ self-fetching feature components (e.g. dashboard/)
├── form/ shared react-hook-form field wrappers, one per field
│ type (text-field.tsx, switch-field.tsx, …)
├── layouts/ sidebar, nav, page-header, sidebar-layout
└── ui/ shadcn/base-ui primitives — no app logic here
- SQL never lives inline in route handlers. Every query string goes in
server/sql/[domain].sql.tsas an exported named constant; handlers import it and pass it toquery(). New domain → new.sql.tsfile. server/sql/is for metadata fetching only (read-only catalog queries behind GET endpoints). DDL/CRUD statements are never hardcoded server-side — they are built client-side (see SQL statement builders below) and executed through the genericPOST /api/queryendpoint.- Schema-scoped object lists (tables, views, types, …) filter server-side:
the list query takes the schema as
$1, the router requires?schema=(400 without it) and passes it as a bind parameter — never interpolated. - One Express router per domain in
server/api/[domain].ts, mounted inserver/api/index.ts. The JSON 404 catch-all inapi/index.tsmust stay the last middleware on the router. - Data routes that need a database sit behind
requireConnectionand read the config withconnectionOf(res). The connection credentials live ONLY in the AES-256-GCM-encrypted httpOnly cookie (session.ts) — never in env vars, files, or client-readable state. GET /api/connection must never return the password. - Pooling is single-user:
db.tskeeps exactly one active pool and swaps it when the connection/database changes. Validate candidate credentials withtestConnection()(one-off client), never through the active pool. - Relative imports between server files use the
.tsextension —rewriteRelativeImportExtensionsrewrites them to.jsin the compileddist/serveroutput.
- List page filter state lives in the URL, not component state: search
text as
?q=viauseSearchParamState(client-side filtering), and on schema-scoped pages the schema select as?schema=viauseSchemaSearch+SchemaSearchToolbar(drives the server-side filter; one schema at a time, no "all schemas" option). client/pages/is presentation only. Pages never callfetchor define query logic — they compose self-fetching components fromclient/components/[domain]/, each of which owns itsuseQuerycall plus its own loading skeleton and error state. Query definitions come fromclient/data/[domain].ts, which exportsqueryOptions(foruseQuery), plain mutation functions (foruseMutation), and the API response types. All HTTP goes through theapihelper inclient/lib/api.ts, which stays generic — domain types live next to theirqueryOptions, never inlib/api.ts.- SQL statement builders live in
client/lib/sql/[domain].ts. CRUD/DDL statements (CREATE/ALTER/DROP …) are built client-side with the quoting helpers fromclient/lib/sql/utils.ts(quoteIdent,quoteLiteral,sqlInteger) — never string-interpolate unquoted user input. The mutation function inclient/data/[domain].tsmaps form values to builder input and executes the result throughrunQuery(POST /api/query). Statements that refuse to run in a transaction block (CREATE DATABASE, ALTER DATABASE SET TABLESPACE, …) must be sent one statement per request — a multi-statement string runs in an implicit transaction. - New endpoint → add/extend a
client/data/[domain].tsmodule; new page → lazy import + route inApp.tsxand a sidebar entry incomponents/layouts/app-sidebar.tsx. - Placeholder object pages render the shared
PlaceholderPage; standard pages wrap content in themx-auto flex w-full max-w-6xl flex-1 flex-col gap-8 p-8container withPageHeader. Full-bleed workspace pages (SQL editor, terminal) skip the header and use slim toolbars instead. components/ui/is generated shadcn/base-ui primitives (base-uirenderprop, notasChild) — keep app logic out of them. App-level layout components live incomponents/layouts/.- After switching database or connection, invalidate/clear the whole query cache — every query is scoped to the active database.
- Dev:
npm run dev(Express + Vite middleware;node --watch-path=serverrestarts only on server changes — vite config changes need a manual restart). - Verify with
npx tsc -b(typechecks app, node, and server projects) andnpm run build(emitsdist/client+dist/server). - Local database for testing:
docker compose up -d(postgres 17 on 5432, postgres/postgres) — a fresh volume auto-seeds thedemodatabase fromdemo.sql, which contains one of every browsable object type.