Skip to content

Latest commit

 

History

History
104 lines (94 loc) · 5.94 KB

File metadata and controls

104 lines (94 loc) · 5.94 KB

postgrex — conventions for agents

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.

Layout

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

Server rules

  • SQL never lives inline in route handlers. Every query string goes in server/sql/[domain].sql.ts as an exported named constant; handlers import it and pass it to query(). New domain → new .sql.ts file.
  • 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 generic POST /api/query endpoint.
  • 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 in server/api/index.ts. The JSON 404 catch-all in api/index.ts must stay the last middleware on the router.
  • Data routes that need a database sit behind requireConnection and read the config with connectionOf(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.ts keeps exactly one active pool and swaps it when the connection/database changes. Validate candidate credentials with testConnection() (one-off client), never through the active pool.
  • Relative imports between server files use the .ts extension — rewriteRelativeImportExtensions rewrites them to .js in the compiled dist/server output.

Client rules

  • List page filter state lives in the URL, not component state: search text as ?q= via useSearchParamState (client-side filtering), and on schema-scoped pages the schema select as ?schema= via useSchemaSearch + SchemaSearchToolbar (drives the server-side filter; one schema at a time, no "all schemas" option).
  • client/pages/ is presentation only. Pages never call fetch or define query logic — they compose self-fetching components from client/components/[domain]/, each of which owns its useQuery call plus its own loading skeleton and error state. Query definitions come from client/data/[domain].ts, which exports queryOptions (for useQuery), plain mutation functions (for useMutation), and the API response types. All HTTP goes through the api helper in client/lib/api.ts, which stays generic — domain types live next to their queryOptions, never in lib/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 from client/lib/sql/utils.ts (quoteIdent, quoteLiteral, sqlInteger) — never string-interpolate unquoted user input. The mutation function in client/data/[domain].ts maps form values to builder input and executes the result through runQuery (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].ts module; new page → lazy import + route in App.tsx and a sidebar entry in components/layouts/app-sidebar.tsx.
  • Placeholder object pages render the shared PlaceholderPage; standard pages wrap content in the mx-auto flex w-full max-w-6xl flex-1 flex-col gap-8 p-8 container with PageHeader. Full-bleed workspace pages (SQL editor, terminal) skip the header and use slim toolbars instead.
  • components/ui/ is generated shadcn/base-ui primitives (base-ui render prop, not asChild) — keep app logic out of them. App-level layout components live in components/layouts/.
  • After switching database or connection, invalidate/clear the whole query cache — every query is scoped to the active database.

Workflow

  • Dev: npm run dev (Express + Vite middleware; node --watch-path=server restarts only on server changes — vite config changes need a manual restart).
  • Verify with npx tsc -b (typechecks app, node, and server projects) and npm run build (emits dist/client + dist/server).
  • Local database for testing: docker compose up -d (postgres 17 on 5432, postgres/postgres) — a fresh volume auto-seeds the demo database from demo.sql, which contains one of every browsable object type.