PostgreSQL
Edit on GitHubReference for the ts-express-postgres deployment target
Last updated:
This page documents what the compiler emits when a spec is compiled against the
ts-express-postgres target. It is the concrete companion to the target-agnostic
Code Generation Pipeline research doc.
The canonical source of truth is the emitter at
modules/codegen/src/main/scala/specrest/codegen/ts/EmitTs.scala, which renders the Handlebars
templates under modules/codegen/src/main/resources/templates/ts/express/. Running
sbt "cli/run compile --framework express --db postgres --ignore-verify --out /tmp/out fixtures/spec/url_shortener.spec"produces the tree this page describes. If this page and the emitter disagree, the emitter wins; file
an issue or PR to correct the doc. The full byte-level shape of the output for url_shortener.spec
is checked in under fixtures/golden/codegen/ts/express/postgres/url_shortener/ and asserted by
EmitTsTest.
At a glance
| Aspect | Value |
|---|---|
| Language | TypeScript 5 (ES2022, ES modules) |
| Runtime | Node.js 20+ |
| Framework | Express 4 |
| ORM | Prisma 6 (@prisma/client) |
| Migration tool | Prisma Migrate (prisma migrate dev / prisma migrate deploy) |
| Database | PostgreSQL 16+ |
| Validation | Zod 3 schemas (request body) |
| Config | Zod-validated process.env (dotenv) |
| Test runner | Vitest |
| Logging | structured JSON via console.log (no third-party logger) |
Project layout
Type mapping
| Spec type | TypeScript type | Prisma type | PostgreSQL column |
|---|---|---|---|
String | string | String | TEXT |
Int | number | Int | INTEGER |
Float | number | Float | DOUBLE PRECISION |
Bool | boolean | Boolean | BOOLEAN |
DateTime | Date | DateTime | TIMESTAMPTZ |
Date | Date | DateTime | DATE |
UUID | string | String | UUID |
Decimal | Prisma.Decimal | Decimal | DECIMAL |
Bytes | Buffer | Bytes | BYTEA |
Money | number | Int | INTEGER |
Option[T] | T | null | T? | nullable column |
Set[T] | T[] | (Json) | JSONB |
Seq[T] | T[] | (Json) | JSONB |
The mapping lives in modules/profile/src/main/scala/specrest/profile/TypeMap.scala.
Int deliberately maps to INTEGER (not BIGINT): the JSON bigint serialization story is
fragile in v0, so 32-bit integers are the conservative default for the API surface. Surrogate
primary keys still use BIGSERIAL via Prisma's @id @default(autoincrement()) and TypeScript
number.
Operation routing
RouteKind.classify (target-agnostic) maps each operation to one of create, read, list,
delete, redirect, other. The TS emitter renders the matching route/service template.
Operations whose body shape does not match the entity's field set route to other and emit a stub
the user fills in (the spec contract is preserved by the verify gate, so the stub still has a known
interface).
For lookups by a non-id column the emitter uses Prisma's findFirst / deleteMany (which do not
require a @unique constraint). Lookups by id use findUnique / delete.
Path-parameter conversion
Spec paths use {name} placeholders (chi/OpenAPI style); Express expects :name. The emitter
converts /{code} → /:code before rendering the route template.
Observability
GET /health returns 200 unconditionally (liveness; the Dockerfile healthcheck probes it).
GET /ready runs SELECT 1 through the Prisma client and answers 200 or 503. GET /metrics
serves prom-client's registry (default process metrics plus http_requests_total and
http_request_duration_seconds, labelled by method, matched route pattern, and status,
recorded on the response finish event); a scrape failure forwards through next(err) into
the generated errorHandler rather than dying as an unhandled rejection.
Tracing is opt-in. src/tracing.ts loads before anything imports Express; with
OTEL_EXPORTER_OTLP_ENDPOINT set it starts the Node SDK with the HTTP and Express
instrumentations and exports OTLP/HTTP spans, and when unset it is inert. The shutdown
path awaits a final span flush before exiting. OTEL_SERVICE_NAME overrides the default
service name.
Dafny → JavaScript integration
When compile --framework express --db postgres --with-synthesis is invoked and the verified-body
cache is populated, the compiler:
- Routes through
dafny translate js(viaTargetLanguage.JavaScript). - Lays the produced JS files under
src/dafnyKernel/. - Emits operation bindings of the form
dafnyKernel.{OperationName}.
The kernel is only emitted when at least one operation is classified as LLM_SYNTHESIS; otherwise
the TS project is purely CRUD/Prisma.
Extension points
src/extensions/index.ts is scaffolded on first compile and preserved on every subsequent
compile. The generated src/app.ts calls registerExtensions(app) once, before
mounting any spec-derived route. Express only applies app.use(...) to routes registered
after the call, so middleware installed here wraps every generated endpoint; routes added
here take precedence on path collisions. Use it for custom routes or middleware that
should live next to the generated code but survive regeneration:
import type { Express, Request, Response } from 'express';
export function registerExtensions(app: Express): void {
app.get('/custom/ping', (_req: Request, res: Response) => {
res.status(200).json({ status: 'ok' });
});
}spec-to-rest compile --dry-run reports this file as preserve on every run after the
first. Any other manual edit under src/, prisma/, or the infrastructure files is
overwritten on the next compile. In-file protected-region markers are intentionally not
provided; the sidecar module is the supported mechanism.
Multi-environment compose
The base docker-compose.yml ships next to three additional files, all rendered from
modules/codegen/src/main/scala/specrest/codegen/Compose.scala:
docker-compose.override.yml.example: local-dev hints. Copy todocker-compose.override.yml; Compose auto-loads it next to the base. Regenerated on every compile, your renamed copy is never touched.docker-compose.staging.yml:restart: on-failure, appmemory: 256M/cpus: 0.25, dbmemory: 512M/cpus: 0.5, every database secret required via${KEY:?…}. Scaffolded once, thenpreserve = true(the file is never regenerated so you can tune the limits).docker-compose.prod.yml:restart: unless-stopped, appmemory: 512M/cpus: 0.5, dbmemory: 1G/cpus: 1.0, no exposed db port, every secret required via${KEY:?…}. Same preserve semantics as staging.
Apply an overlay with -f layering:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dFor SQLite the staging/prod overlays only harden the app service (no db service to
harden); the override.example still ships.
CI gate
.github/workflows/ts-build.yml is a {postgres, sqlite, mysql} matrix. On every PR that touches
the TS templates, profile, emitter, or the shared migration renderer it re-runs
compile --framework express --db <dialect>, then
npm install && npx prisma generate && npx tsc --noEmit && npm test && npm run build against the
emitted project and a Prisma migration round-trip
(migrate deploy → migrate reset → migrate deploy) against a real postgres:17 / mysql:8.4
service (SQLite uses a file), so the emitted prisma/migrations/** is proven to apply and replay
on every supported dialect, not just that the TypeScript compiles.
Test generation
Conformance / property / stateful test generation is on by default (opt out with
--no-tests) for ts-express-* on every dialect (postgres, sqlite, mysql), emitted
in the target's own language: Vitest +
fast-check property tests, not Python. testgen plugs the
TypeScript renderers into the shared Backend.scala seam (ExprBackend /
StrategyBackend / HarnessTemplates); the same spec-derived derivation engine feeds
the Python (fastapi) path, which stays the byte-identical differential oracle
(#278,
#280; closes
#265).
The emitted suite lives under tests/: behavioral (<svc>.behavioral.test.ts,
positive-ensures via fc.asyncProperty), stateful (<svc>.stateful.test.ts, random
operation sequences asserting invariants per step), and structural-lite
(<svc>.structural.test.ts, fuzzes every non-stub operation with type-valid input and
asserts no 5xx, schemathesis's core check minus full schema validation; fail-loud
stubs are honest-skipped exactly as the Python schemathesis renderer excludes them,
recorded in tests/_testgen_skips.json), plus the _runtime.ts / _client.ts /
_predicates.ts / _strategies.ts harness and vitest.config.ts. The
/admin/* reset/state/seed router is emitted as src/routes/admin.ts and mounted
unconditionally; every route requires Authorization: Bearer $ADMIN_TOKEN. When no
ADMIN_TOKEN is configured (the production default) the routes answer 404, so a
deployment that sets nothing exposes nothing.
Run it against a running service:
export ADMIN_TOKEN=$(openssl rand -hex 32)
npm start &
node tests/run_conformance.mjs smoke # or: npm testts-express -> conformance suite runs this in CI (ts-build.yml) across all four
fixtures × postgres/sqlite, mirroring the python-build.yml pattern. (The
spec-to-rest test CLI wrapper still drives only the Python runner; see
CLI Reference.)