spec_to_rest
TypeScriptExpress

PostgreSQL

Edit on GitHub

Reference 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

AspectValue
LanguageTypeScript 5 (ES2022, ES modules)
RuntimeNode.js 20+
FrameworkExpress 4
ORMPrisma 6 (@prisma/client)
Migration toolPrisma Migrate (prisma migrate dev / prisma migrate deploy)
DatabasePostgreSQL 16+
ValidationZod 3 schemas (request body)
ConfigZod-validated process.env (dotenv)
Test runnerVitest
Loggingstructured JSON via console.log (no third-party logger)

Project layout

.

Type mapping

Spec typeTypeScript typePrisma typePostgreSQL column
StringstringStringTEXT
IntnumberIntINTEGER
FloatnumberFloatDOUBLE PRECISION
BoolbooleanBooleanBOOLEAN
DateTimeDateDateTimeTIMESTAMPTZ
DateDateDateTimeDATE
UUIDstringStringUUID
DecimalPrisma.DecimalDecimalDECIMAL
BytesBufferBytesBYTEA
MoneynumberIntINTEGER
Option[T]T | nullT?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:

  1. Routes through dafny translate js (via TargetLanguage.JavaScript).
  2. Lays the produced JS files under src/dafnyKernel/.
  3. 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 to docker-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, app memory: 256M/cpus: 0.25, db memory: 512M/cpus: 0.5, every database secret required via ${KEY:?…}. Scaffolded once, then preserve = true (the file is never regenerated so you can tune the limits).
  • docker-compose.prod.yml: restart: unless-stopped, app memory: 512M/cpus: 0.5, db memory: 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 -d

For 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 test

ts-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.)

On this page