diff --git a/.changeset/postgrest-typegen-openapi-producer.md b/.changeset/postgrest-typegen-openapi-producer.md
new file mode 100644
index 000000000..713672ee8
--- /dev/null
+++ b/.changeset/postgrest-typegen-openapi-producer.md
@@ -0,0 +1,5 @@
+---
+"@supabase/postgrest-typegen": minor
+---
+
+Add `@supabase/postgrest-typegen/openapi` producer: `openApiToGeneratorMetadata(doc)` builds `GeneratorMetadata` from a PostgREST OpenAPI document's `x-postgrest-typegen-metadata` vendor extension, so types can be generated from a PostgREST URL alone (no database connection). Requires PostgREST's opt-in `openapi-metadata` config to emit the extension.
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/.gitignore b/packages/postgrest-typegen/experiments/openapi-spike/.gitignore
new file mode 100644
index 000000000..a05259a44
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/.gitignore
@@ -0,0 +1,15 @@
+# Reproducible artifacts emitted by run.ts — regenerate with
+# `bun experiments/openapi-spike/run.ts`. Not committed (and kept out of
+# oxlint/oxfmt, which honor .gitignore) since candidate.ts/reference.ts are
+# generated TypeScript, not hand-written source.
+openapi.json
+reference.ts
+candidate.ts
+output.diff
+stats.json
+# parity.ts artifacts (regenerate with `bun experiments/openapi-spike/parity.ts`)
+parity.openapi.json
+parity.reference.ts
+parity.candidate.ts
+parity.diff
+parity.postgrest.conf
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/REPORT.md b/packages/postgrest-typegen/experiments/openapi-spike/REPORT.md
new file mode 100644
index 000000000..d19bb2340
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/REPORT.md
@@ -0,0 +1,200 @@
+# Spike report: generating `GeneratorMetadata` from a PostgREST OpenAPI contract
+
+**TL;DR** — Plumbing works perfectly: a `openApiToGeneratorMetadata(doc)` producer
+drops straight into the existing pure generators with **zero generator changes**, and
+the resulting `candidate.ts` is **valid, strict-typechecking TypeScript**. But fidelity
+is low. Against the rich introspection fixture the OpenAPI-only output is **~46 % of the
+reference byte size** (17,001 vs 36,607 bytes) with a **1,069-line unified diff**. Some
+surfaces are fully recoverable (enums, constants, column scalar types, RPC *argument*
+types, primary keys, base-table FK direction); the load-bearing ones are not (object kind,
+function **return** types, computed fields, relationship expansion, composite types,
+insert/identity optionality).
+
+**Recommendation: do NOT ship OpenAPI as a standalone source of truth.** It is viable only
+as an explicitly-labelled *lossy / best-effort* adapter, or — better — as
+"OpenAPI + a few supplementary catalog queries". Details and a decision matrix at the end.
+
+---
+
+## How this was produced
+
+- **Schema:** the existing rich fixtures `test/introspection/fixtures/00-init.sql` +
+ `01-memes.sql` (13 tables incl. 2 partitions, 5 views, 1 matview, 1 foreign table,
+ enum + 2 composite types, ~80 functions incl. overloads / polymorphic / computed-field /
+ SETOF / table-arg, RLS, partitioned table).
+- **Stack:** `postgres:15-alpine` + `postgrest/postgrest` (the real image — it pulled
+ fine on local Docker Desktop, so the host-PostgREST fallback was **not** needed and no
+ OpenAPI input was hand-fabricated). Containers are driven via the `docker` CLI from
+ `run.ts` (testcontainers' lifecycle promise did not resume under Bun after its
+ health-check wait — see the note in `run.ts`). PostgREST ran with
+ `PGRST_DB_SCHEMAS=public`, `PGRST_DB_ANON_ROLE=anon`,
+ `PGRST_OPENAPI_MODE=ignore-privileges`. The schema cache was reloaded
+ (`NOTIFY pgrst, 'reload schema'`) and the root endpoint polled until the expected
+ definitions appeared before capture.
+- **Two paths, one generator:**
+ - reference: `introspect(pool, {includedSchemas:["public"]})` → `generateTypescript` → `reference.ts`
+ - candidate: `openApiToGeneratorMetadata(openapi.json)` → `generateTypescript` → `candidate.ts`
+- **Artifacts** (all in this dir): `openapi.json`, `reference.ts`, `candidate.ts`,
+ `output.diff`, `stats.json`.
+- **Validation:** the synthesized metadata passes `parseGeneratorMetadata()` and both
+ `reference.ts` and `candidate.ts` pass `tsc --noEmit --strict`.
+
+### Headline metrics (`stats.json`)
+
+| metric | reference (live DB) | candidate (OpenAPI) |
+|---|---|---|
+| tables | 13 | **18** (everything collapses here) |
+| foreign tables | 1 | 0 |
+| views | 5 | 0 |
+| materialized views | 1 | 0 |
+| columns | 65 | 57 (missing the 2 partitions' 8 cols) |
+| relationships | 25 | 5 |
+| functions | 72 | 32 |
+| types | 658 | 13 (only the ones actually referenced) |
+| generated TS bytes | 36,607 | 17,001 |
+| unified diff lines | — | 1,069 |
+
+---
+
+## What IS faithfully recoverable
+
+These came out byte-identical (or semantically equivalent) and need no DB access:
+
+- **Enums** — `Enums` block and the `Constants` enum-value arrays match exactly
+ (`meme_status`, `user_status`). PostgREST emits the `enum` array + the qualified type
+ name in `format`, which is everything the generator needs.
+- **Column scalar types** — after a name-mapping layer (PostgREST emits OpenAPI/ SQL
+ spellings: `int64`, `int32`, `numeric`, `text`, `uuid`, `timestamp with time zone`,
+ `text[]`, …; the generator wants pg_catalog short names: `int8`, `int4`, `timestamptz`,
+ `_text`, …). Enum- and table-row-typed columns resolve correctly too.
+- **Primary keys** — parsed from the `` marker in each property `description`.
+- **Base-table FK direction** — parsed from ``; the referenced
+ relation and columns are correct for direct table→table FKs.
+- **RPC *argument* types** — recoverable from the RPC POST body schema, which carries each
+ arg's `format`. Scalars (`additional_interval: string`), numerics (`user_id: number`),
+ and even relation-row args (`user_row: Database["public"]["Tables"]["users"]["Row"]`)
+ all render correctly.
+- **Row nullability for NOT NULL columns** — see the nuance below; the common case works.
+
+---
+
+## Gap categories (ranked by impact)
+
+### 1. Object-kind classification — *structural, pervasive* 🔴
+PostgREST's OpenAPI does not distinguish **table / view / materialized view / foreign
+table**: they are all just `definitions`. Everything lands under `Tables`; `Views`,
+`materializedViews` and `foreignTables` are empty in the candidate. Concretely:
+`a_view`, `todos_view`, `users_view`, `user_todos_summary_view`,
+`users_view_with_multiple_refs_to_users`, `todos_matview`, `foreign_table` all move from
+their correct collection into `Tables`. This also corrupts cross-references: a column typed
+as a view row renders `…["Tables"]["a_view"]["Row"]` instead of `…["Views"]["a_view"]["Row"]`.
+**Not fixable from OpenAPI alone.**
+
+### 2. Function **return** types & SETOF metadata — *structural, the biggest single loss* 🔴
+PostgREST OpenAPI describes RPC *responses* generically (`200 OK`, no schema). Every
+candidate function is `Returns: unknown`, with:
+- **No `SetofOptions`** (the `from`/`to`/`isOneToOne`/`isSetofReturn` block the generator
+ emits for table-/view-returning functions — used by the client for embedded selects).
+- **Overloads collapsed** — PostgREST exposes one `/rpc/` path per name, so
+ `test_unnamed_row_setof` (3 overloads → 1) and similar lose their union signatures.
+- **Unnamed single-arg / computed-style functions absent** — functions with unnamed table-row
+ args (`blurb`, `details_length`, `test_unnamed_row_scalar`, …) aren't RPC-callable and
+ don't appear at all (see also #4).
+- **No conflict/error reporting** — the `{ error: true } & "…schema cache…"` diagnostics the
+ generator produces for unresolvable overloads are gone.
+
+Functions are 32 (candidate) vs 72 (reference). *Argument* types are fine (see above); it's
+the **return** side that's unrecoverable.
+
+### 3. Relationship expansion — *structural* 🔴
+Reference emits 25 relationships, candidate 5. Missing:
+- **View / matview relationship expansion** (e.g. `todos → users_view`, `todos_view → users`,
+ multiple refs through `users_view_with_multiple_refs_to_users`). PostgREST only tags FKs on
+ base-table columns.
+- **Reverse relationships** and any FK not expressible as a single `` tag.
+- **Real constraint names** — synthesized as `__fkey`, which is wrong whenever the
+ FK lives on a view-like object (`todos_view_user-id_fkey` vs the real `todos_user-id_fkey`).
+- **`isOneToOne`** — always `false`; 1:1 detection is impossible.
+- Documented PostgREST quirks that would bite a real adapter: **FK tag dropped when the column
+ is also UNIQUE** (PostgREST #3418) and **cross-schema FK omits the schema** (#1874). Not
+ triggered by this single-schema fixture but real.
+
+### 4. Computed fields (function-based columns) — *structural* 🟠
+Functions like `blurb`, `details_length`, `created_ago`, `days_since_event`,
+`get_todos_by_matview` appear as **extra `Row` keys** in the reference (e.g.
+`events.Row.days_since_event: number | null`). They are **not present anywhere** in the
+OpenAPI document (neither as columns nor as `/rpc`), so the candidate omits them entirely.
+
+### 5. Composite types — *structural* 🟠
+`CompositeTypes` is empty in the candidate (`[_ in never]: never`) vs two entries in the
+reference (`composite_type_with_array_attribute`, `composite_type_with_record_attribute`).
+PostgREST doesn't surface standalone composite types as definitions.
+
+### 6. Insert/Update optionality (identity & generated) — *behavioural* 🟠
+- `is_identity` / `is_generated` / `identity_generation` are unavailable. The generator uses
+ them to mark insert columns optional and to emit `?: never` for `GENERATED ALWAYS`. Result:
+ every identity PK becomes **required on Insert** in the candidate (`id: number` vs reference
+ `id?: number`) across `users`, `todos`, `category`, `memes`, `interval_test`,
+ `table_with_primary_key_other_than_id`, …
+- PostgREST does emit `default` for *some* columns (`status`, `user_uuid`) but **not** for
+ identity columns, so even the "has a default ⇒ optional on insert" heuristic can't fully
+ recover this.
+
+### 7. Partitioned tables — *coverage* 🟠
+Partition children (`events_2024`, `events_2025`) are not exposed by PostgREST; only the
+parent `events` appears. Accounts exactly for the column delta (65 → 57).
+
+### 8. Row nullability nuance — *mostly OK, one behavioural edge* 🟡
+**Empirical correction to the spike premise:** PostgREST's `required` array reflects **NOT
+NULL** (the identity PK `id` is `required` *despite* having a default), not "NOT NULL **and**
+no default". So `is_nullable := !required` is accurate for `Row` types in the common case. The
+residual error is only the Insert-optionality interaction in #6.
+
+### 9. Things absent but harmless for the *TypeScript* generator — 🟢
+RLS flags, replica identity, table stats/size, column `check`, `is_unique`, `data_type`,
+schema owner — none are read by `generateTypescript`, so fabricating defaults costs nothing
+here. (They would matter for other consumers of `GeneratorMetadata`.)
+
+### 10. OID synthesis — *non-issue* 🟢
+The generator is name-keyed for almost everything; a monotonic memoized OID factory with a
+small synthesized `types` registry was sufficient to make all joins (`table_id`, arg
+`type_id`) resolve. No real OIDs needed.
+
+---
+
+## Corrections to the original assumptions
+
+1. **`required` = NOT NULL**, not "NOT NULL and no default" (see #8). Nullability is *less*
+ lossy than feared.
+2. **RPC argument types are recoverable** from the POST body schema `format` (the plan
+ expected "best-effort / weak"). Only *return* types are truly absent.
+3. **A type-name mapping layer is mandatory** (`int64→int8`, `timestamp with time zone→
+ timestamptz`, `text[]→_text`, …) — PostgREST does not speak pg_catalog short names.
+4. **Object kind is the dominant structural gap**, ahead of functions — every view/matview/
+ foreign table is misfiled, which also poisons column cross-references.
+
+---
+
+## Recommendation
+
+**Drop OpenAPI as a standalone producer; keep it as an opt-in lossy adapter, and seriously
+consider the hybrid.**
+
+| Option | Verdict |
+|---|---|
+| **Ship OpenAPI-only as the introspection replacement** | ❌ No. Loses object kind, function returns, computed fields, relationship expansion, composite types, identity insert-optionality. Output is valid TS but materially wrong for real client usage. |
+| **Ship as an explicitly-labelled "lossy / no-credentials" adapter** | ⚠️ Acceptable only behind a loud opt-in. Good enough for a rough scaffold from a URL; not for production client types. Must document the gaps above. |
+| **OpenAPI + a handful of supplementary catalog queries** | ✅ Most promising. OpenAPI already nails enums, scalar column types, PKs, base FKs and RPC arg types. A *small* set of catalog reads (relkind for object classification, `pg_proc` for return types/SETOF/overloads, `pg_attribute.attidentity`/`attgenerated`, `pg_type` for composites, `pg_depend`/`pg_constraint` for full relationships) closes the structural gaps — but that path needs a DB connection, which defeats the "URL only" goal. |
+| **Drop entirely** | Reasonable if the only motivation was "types from a URL with no DB"; the lossy output may do more harm than good for users who assume parity. |
+
+If we proceed with an adapter, the production version should: (a) live behind a clearly named
+opt-in (e.g. `fromPostgrestOpenApi`), (b) carry the type-name mapping table, (c) emit a
+machine-readable "fidelity warnings" list (object-kind unknown, function returns unknown,
+computed fields dropped, …) so downstream tools can surface them, and (d) reuse this spike's
+diff harness as a regression gate against the live-DB path.
+
+---
+
+*This is throwaway spike code under `experiments/openapi-spike/` — not wired into the package
+build or exports, and ships no changeset. Re-run with
+`cd packages/postgrest-typegen && bun experiments/openapi-spike/run.ts` (Docker required).*
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/adapter.ts b/packages/postgrest-typegen/experiments/openapi-spike/adapter.ts
new file mode 100644
index 000000000..d5740d353
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/adapter.ts
@@ -0,0 +1,392 @@
+/**
+ * SPIKE adapter: PostgREST OpenAPI (Swagger 2.0) -> GeneratorMetadata.
+ *
+ * Throwaway, experimental code. NOT wired into the package build/exports and
+ * deliberately spike-quality. Its only job is to let the harness in `run.ts`
+ * feed `generateTypescript` from an OpenAPI document so we can diff the result
+ * against the live-DB `introspect()` path and quantify the fidelity gaps.
+ *
+ * Known, intentional approximations are flagged inline with `GAP:` comments and
+ * summarized in REPORT.md.
+ */
+import type {
+ GeneratorMetadata,
+ PostgresColumn,
+ PostgresFunction,
+ PostgresRelationship,
+ PostgresType,
+} from "../../src/types.ts";
+
+// ---------------------------------------------------------------------------
+// Minimal Swagger 2.0 shape (only the bits PostgREST emits that we consume).
+// ---------------------------------------------------------------------------
+interface OpenApiProperty {
+ type?: string;
+ format?: string;
+ description?: string;
+ default?: unknown;
+ enum?: string[];
+ items?: OpenApiProperty;
+ maxLength?: number;
+}
+interface OpenApiDefinition {
+ type?: string;
+ required?: string[];
+ properties?: Record;
+ description?: string;
+}
+interface OpenApiBodyParameter {
+ name?: string;
+ in?: string;
+ required?: boolean;
+ schema?: {
+ $ref?: string;
+ properties?: Record;
+ required?: string[];
+ };
+ type?: string;
+ format?: string;
+}
+interface OpenApiOperation {
+ parameters?: OpenApiBodyParameter[];
+ description?: string;
+ summary?: string;
+ tags?: string[];
+}
+interface OpenApiPathItem {
+ get?: OpenApiOperation;
+ post?: OpenApiOperation;
+ patch?: OpenApiOperation;
+ delete?: OpenApiOperation;
+}
+export interface OpenApiDocument {
+ swagger?: string;
+ info?: { title?: string; description?: string; version?: string };
+ definitions?: Record;
+ paths?: Record;
+ parameters?: Record;
+}
+
+const DEFAULT_SCHEMA = "public";
+
+/**
+ * Map PostgREST's `format` (full SQL type names) to the pg_catalog SHORT names
+ * that the TypeScript generator's `pgTypeToTsType` understands.
+ *
+ * GAP: postgres-meta carries the catalog short name (`int8`, `timestamptz`, …)
+ * directly. PostgREST exposes the human SQL spelling (`bigint`, `timestamp with
+ * time zone`, …). Anything not in this table falls through unmapped and the
+ * generator renders it as `unknown` (or as an enum/composite name if it matches
+ * a synthesized type).
+ */
+const SQL_TYPE_TO_PG_FORMAT: Record = {
+ // PostgREST emits OpenAPI numeric `format`s for integers/floats …
+ int64: "int8",
+ int32: "int4",
+ int16: "int2",
+ double: "float8",
+ float: "float4",
+ // … and the raw SQL spelling for everything else.
+ bigint: "int8",
+ integer: "int4",
+ smallint: "int2",
+ "double precision": "float8",
+ real: "float4",
+ numeric: "numeric",
+ boolean: "bool",
+ text: "text",
+ "character varying": "varchar",
+ character: "bpchar",
+ uuid: "uuid",
+ json: "json",
+ jsonb: "jsonb",
+ date: "date",
+ "timestamp without time zone": "timestamp",
+ "timestamp with time zone": "timestamptz",
+ "time without time zone": "time",
+ "time with time zone": "timetz",
+ bytea: "bytea",
+ interval: "interval",
+};
+
+/** Synthesizes monotonic OIDs, memoized by key so joins stay consistent. */
+function makeOidFactory() {
+ let next = 16384; // mimic the userland OID range
+ const byKey = new Map();
+ return (key: string): number => {
+ const existing = byKey.get(key);
+ if (existing !== undefined) return existing;
+ const id = next++;
+ byKey.set(key, id);
+ return id;
+ };
+}
+
+/** Strip a leading `public.` (or any schema) qualifier from a type name. */
+function bareTypeName(name: string): string {
+ const dot = name.indexOf(".");
+ return dot === -1 ? name : name.slice(dot + 1);
+}
+
+/**
+ * Translate an OpenAPI property's `format`/`type` into the generator's
+ * `column.format` short name. Returns the bare enum/composite name untouched
+ * (so `pgTypeToTsType` can resolve it against a synthesized type), otherwise the
+ * pg_catalog short name, otherwise the raw string (-> `unknown` downstream).
+ */
+function toColumnFormat(prop: OpenApiProperty): string {
+ const raw = prop.format ?? prop.type ?? "unknown";
+ // Array column: PostgREST puts the element type on the property `format`
+ // (e.g. `text[]`); the `items` schema only carries the JSON `type`, no
+ // pg format. Prefer the `format` spelling, fall back to `items`.
+ if (raw.endsWith("[]")) {
+ const el = raw.slice(0, -2);
+ return `_${SQL_TYPE_TO_PG_FORMAT[el] ?? bareTypeName(el)}`;
+ }
+ if (prop.type === "array" && prop.items) {
+ return `_${toColumnFormat(prop.items)}`;
+ }
+ if (raw in SQL_TYPE_TO_PG_FORMAT) return SQL_TYPE_TO_PG_FORMAT[raw];
+ // User-defined type (enum/composite/table-row): keep the bare name so the
+ // generator can match it against a synthesized type / table / view.
+ return bareTypeName(raw);
+}
+
+interface ParsedDescription {
+ comment: string | null;
+ isPrimaryKey: boolean;
+ fk: { table: string; column: string } | null;
+}
+
+/** Pull the comment, ``, and `` markers out of a description. */
+function parseDescription(description: string | undefined): ParsedDescription {
+ const desc = description ?? "";
+ const isPrimaryKey = //.test(desc);
+ const fkMatch = desc.match(//);
+ // Everything before the "Note:" block is the user comment.
+ let comment: string | null = desc.split(/\n*Note:/)[0] ?? "";
+ comment = comment.replace(/<[^>]+>/g, "").trim();
+ if (comment === "") comment = null;
+ return {
+ comment,
+ isPrimaryKey,
+ fk: fkMatch ? { table: fkMatch[1], column: fkMatch[2] } : null,
+ };
+}
+
+export function openApiToGeneratorMetadata(
+ doc: OpenApiDocument,
+): GeneratorMetadata {
+ const oid = makeOidFactory();
+ const schemaId = oid("schema:public");
+
+ const tables: GeneratorMetadata["tables"] = [];
+ const columns: PostgresColumn[] = [];
+ const relationships: PostgresRelationship[] = [];
+ const types: PostgresType[] = [];
+ const functions: PostgresFunction[] = [];
+
+ // A single registry for synthesized types, keyed by (bare) name. Base types
+ // (`int8`, `text`, `interval`) don't strictly need an entry — `pgTypeToTsType`
+ // resolves them by name string — but the generator only *calls* it for an arg
+ // when `typesById.get(arg.type_id)` resolves, so every referenced type needs a
+ // row here. Enum entries additionally carry `enums` so they classify as enums.
+ const typesByName = new Map();
+ function ensureType(name: string, enums?: string[]): number {
+ const existing = typesByName.get(name);
+ if (existing) {
+ if (enums && existing.enums.length === 0) existing.enums = enums;
+ return existing.id;
+ }
+ const t: PostgresType = {
+ id: oid(`type:${name}`),
+ name,
+ schema: DEFAULT_SCHEMA,
+ format: name,
+ enums: enums ?? [],
+ attributes: [],
+ comment: null,
+ type_relation_id: null,
+ };
+ typesByName.set(name, t);
+ types.push(t);
+ return t.id;
+ }
+
+ const definitions = doc.definitions ?? {};
+
+ // -------------------------------------------------------------------------
+ // definitions -> tables + columns
+ //
+ // GAP (object-kind classification): PostgREST's OpenAPI does not distinguish
+ // tables / views / materialized views / foreign tables — they are all just
+ // `definitions`. Everything therefore lands in `tables`; `views`,
+ // `materializedViews` and `foreignTables` stay empty.
+ // -------------------------------------------------------------------------
+ for (const [relName, def] of Object.entries(definitions)) {
+ const tableId = oid(`rel:${relName}`);
+ const required = new Set(def.required ?? []);
+ const primaryKeys: GeneratorMetadata["tables"][number]["primary_keys"] = [];
+
+ tables.push({
+ id: tableId,
+ schema: DEFAULT_SCHEMA,
+ name: relName,
+ rls_enabled: false, // GAP: not in OpenAPI
+ rls_forced: false, // GAP
+ replica_identity: "DEFAULT", // GAP
+ bytes: 0, // GAP
+ size: "0 bytes", // GAP
+ live_rows_estimate: 0, // GAP
+ dead_rows_estimate: 0, // GAP
+ comment: parseDescription(def.description).comment,
+ primary_keys: primaryKeys,
+ relationships: [], // legacy shape; generator reads the top-level relationships
+ });
+
+ const props = def.properties ?? {};
+ let ordinal = 0;
+ for (const [colName, prop] of Object.entries(props)) {
+ ordinal += 1;
+ const parsed = parseDescription(prop.description);
+ const format = toColumnFormat(prop);
+
+ // Enum column -> register a matching type so pgTypeToTsType resolves it.
+ if (prop.enum && prop.enum.length > 0) {
+ ensureType(bareTypeName(prop.format ?? format), prop.enum);
+ }
+
+ columns.push({
+ table_id: tableId,
+ schema: DEFAULT_SCHEMA,
+ table: relName,
+ id: `${tableId}.${ordinal}`,
+ ordinal_position: ordinal,
+ name: colName,
+ // GAP: PostgREST has no notion of a column default value at all in
+ // OpenAPI beyond presence in `required`; `default` is only emitted for
+ // some types. Use it when present, else null.
+ default_value: prop.default ?? null,
+ data_type: prop.format ?? prop.type ?? "unknown",
+ format,
+ is_identity: false, // GAP: unavailable
+ identity_generation: null, // GAP: unavailable -> can't emit `?: never`
+ is_generated: false, // GAP: unavailable
+ // CENTRAL GAP (nullability): OpenAPI `required` == NOT NULL *and* no
+ // default. We can only approximate "nullable" as "not required", which
+ // wrongly marks NOT-NULL-with-default columns (identity PKs, defaulted
+ // columns) as nullable.
+ is_nullable: !required.has(colName),
+ is_updatable: true, // GAP: assumed
+ is_unique: false, // GAP: unavailable (and FK-drop quirk, see below)
+ enums: prop.enum && prop.enum.length > 0 ? prop.enum : [],
+ check: null, // GAP
+ comment: parsed.comment,
+ });
+
+ if (parsed.isPrimaryKey) {
+ primaryKeys.push({
+ schema: DEFAULT_SCHEMA,
+ table_name: relName,
+ name: colName,
+ table_id: tableId,
+ });
+ }
+
+ if (parsed.fk) {
+ relationships.push({
+ // GAP: real constraint name is unavailable; synthesize a plausible one.
+ foreign_key_name: `${relName}_${colName}_fkey`,
+ schema: DEFAULT_SCHEMA,
+ relation: relName,
+ columns: [colName],
+ is_one_to_one: false, // GAP: 1:1 detection impossible from OpenAPI
+ // GAP (cross-schema FK, PostgREST #1874): the schema is omitted in
+ // the fk tag; we can only assume the same schema.
+ referenced_schema: DEFAULT_SCHEMA,
+ referenced_relation: parsed.fk.table,
+ referenced_columns: [parsed.fk.column],
+ });
+ }
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // /rpc/* -> functions
+ //
+ // GAP (functions/RPC, the largest gap): OpenAPI describes the callable
+ // surface, not catalog truth. Overloads collapse to a single path, argument
+ // modes are assumed `in`, and there is no reliable return-type information, so
+ // every function renders `Returns: unknown`. No SETOF / relation / prorows.
+ // -------------------------------------------------------------------------
+ for (const [pathKey, item] of Object.entries(doc.paths ?? {})) {
+ if (!pathKey.startsWith("/rpc/")) continue;
+ const fnName = pathKey.slice("/rpc/".length);
+ const op = item.post;
+ if (!op) continue;
+
+ const args: PostgresFunction["args"] = [];
+ for (const param of op.parameters ?? []) {
+ // Body parameter carrying the arg object.
+ const schema = param.schema;
+ if (schema?.properties) {
+ const req = new Set(schema.required ?? []);
+ for (const [argName, argProp] of Object.entries(schema.properties)) {
+ // Argument TYPES are recoverable: the RPC body schema carries each
+ // arg's `format` (scalar like `interval`, or relation like
+ // `public.users`). Return types are not — see below.
+ args.push({
+ mode: "in", // GAP: in/out/inout/variadic/table indistinguishable
+ name: argName,
+ type_id: ensureType(toColumnFormat(argProp)),
+ has_default: !req.has(argName),
+ });
+ }
+ } else if (param.in === "query" && param.name) {
+ // Some args surface as query parameters.
+ args.push({
+ mode: "in",
+ name: param.name,
+ type_id: ensureType(toColumnFormat(param as OpenApiProperty)),
+ has_default: !param.required,
+ });
+ }
+ }
+
+ functions.push({
+ id: oid(`fn:${fnName}`),
+ schema: DEFAULT_SCHEMA,
+ name: fnName,
+ language: "sql", // GAP
+ definition: "", // GAP
+ complete_statement: "", // GAP
+ args,
+ // GAP: real `argument_types` (the pg signature, e.g. "todos") is what the
+ // generator uses to attach computed fields to a table Row. OpenAPI cannot
+ // recover it; left empty so we neither fabricate computed fields nor inject
+ // spurious ones when an arg name happens to equal a table name.
+ argument_types: "",
+ identity_argument_types: "", // GAP
+ return_type_id: oid("type:__rpc_unknown_return__"), // not in `types` -> unknown
+ return_type: "unknown", // GAP
+ return_type_relation_id: null, // GAP: no SETOF/relation detection
+ is_set_returning_function: false, // GAP
+ prorows: null, // GAP
+ behavior: "VOLATILE", // GAP
+ security_definer: false, // GAP
+ config_params: null, // GAP
+ });
+ }
+
+ return {
+ schemas: [{ id: schemaId, name: DEFAULT_SCHEMA, owner: "postgres" }],
+ tables,
+ foreignTables: [], // GAP: cannot distinguish from tables
+ views: [], // GAP: cannot distinguish from tables
+ materializedViews: [], // GAP: cannot distinguish from tables
+ columns,
+ relationships,
+ functions,
+ types,
+ };
+}
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/parity.ts b/packages/postgrest-typegen/experiments/openapi-spike/parity.ts
new file mode 100644
index 000000000..0f982486c
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/parity.ts
@@ -0,0 +1,207 @@
+/**
+ * Parity check: locally-built PostgREST (with `openapi-metadata` ON) vs the live
+ * DB introspection path, both fed through the same TypeScript generator.
+ *
+ * reference: live DB -> introspect() -> generateTypescript()
+ * candidate: PostgREST OpenAPI (x-postgrest-typegen-metadata)
+ * -> openApiToGeneratorMetadata() -> generateTypescript()
+ *
+ * Unlike the spike, this drives the PRODUCTION adapter (`src/openapi`) against a
+ * PostgREST built from this branch (which emits the metadata block).
+ *
+ * Requires the `POSTGREST_BIN` env var pointing at the built binary
+ * (`cabal list-bin exe:postgrest`). Docker required for Postgres.
+ *
+ * Run via run-parity.sh (sets POSTGREST_BIN + nix env).
+ */
+import { execFileSync, spawn } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { Pool } from "pg";
+
+import { generateTypescript } from "../../src/generation/index.ts";
+import { introspect } from "../../src/introspection/index.ts";
+import { openApiToGeneratorMetadata } from "../../src/openapi/index.ts";
+import type { OpenApiDocumentWithMetadata } from "../../src/openapi/types.ts";
+import { sortGeneratorMetadata } from "../../src/sort.ts";
+
+const HERE = import.meta.dir;
+const FIXTURE_DIR = join(HERE, "..", "..", "test", "introspection", "fixtures");
+const NETWORK = "parity-net";
+const PG_NAME = "parity-pg";
+const PGRST_PORT = 3019;
+const POSTGREST_BIN = process.env.POSTGREST_BIN;
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+const docker = (args: string[], allowFail = false) => {
+ try {
+ return execFileSync("docker", args, { encoding: "utf8" }).trim();
+ } catch (e: any) {
+ if (allowFail) return "";
+ throw new Error(
+ `docker ${args.join(" ")} failed:\n${e.stderr ?? e.message}`,
+ );
+ }
+};
+const mappedPort = (name: string, p: number) => {
+ const out = docker(["port", name, String(p)]).split("\n")[0] ?? "";
+ return Number(out.slice(out.lastIndexOf(":") + 1));
+};
+const cleanupDocker = () => {
+ docker(["rm", "-f", PG_NAME], true);
+ docker(["network", "rm", NETWORK], true);
+};
+
+async function main() {
+ if (!POSTGREST_BIN) throw new Error("POSTGREST_BIN env var is required");
+
+ cleanupDocker();
+ docker(["network", "create", NETWORK]);
+ docker([
+ "run",
+ "-d",
+ "--name",
+ PG_NAME,
+ "--network",
+ NETWORK,
+ "-e",
+ "POSTGRES_USER=postgres",
+ "-e",
+ "POSTGRES_PASSWORD=postgres",
+ "-e",
+ "POSTGRES_DB=postgres",
+ "-p",
+ "127.0.0.1::5432",
+ "postgres:15-alpine",
+ ]);
+ const pgPort = mappedPort(PG_NAME, 5432);
+ const pool = new Pool({
+ connectionString: `postgres://postgres:postgres@127.0.0.1:${pgPort}/postgres`,
+ });
+
+ let ready = false;
+ for (let i = 0; i < 60; i++) {
+ try {
+ await pool.query("select 1");
+ ready = true;
+ break;
+ } catch {
+ await sleep(1000);
+ }
+ }
+ if (!ready) throw new Error("postgres not ready");
+ console.log("[parity] postgres ready, applying fixtures…");
+ await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8"));
+ await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8"));
+ await pool.query(readFileSync(join(HERE, "roles-seed.sql"), "utf8"));
+
+ // Local PostgREST against the mapped pg port, with the metadata flag ON.
+ const confPath = join(HERE, "parity.postgrest.conf");
+ writeFileSync(
+ confPath,
+ [
+ `db-uri = "postgres://authenticator:authpass@127.0.0.1:${pgPort}/postgres"`,
+ `db-schemas = "public"`,
+ `db-anon-role = "anon"`,
+ `openapi-mode = "ignore-privileges"`,
+ `openapi-metadata = true`,
+ `server-port = ${PGRST_PORT}`,
+ ].join("\n") + "\n",
+ );
+
+ console.log(`[parity] starting postgrest (${POSTGREST_BIN})…`);
+ const pgrst = spawn(POSTGREST_BIN, [confPath], { stdio: "inherit" });
+ const baseUrl = `http://127.0.0.1:${PGRST_PORT}`;
+ try {
+ let openapi: OpenApiDocumentWithMetadata | null = null;
+ for (let i = 0; i < 40; i++) {
+ try {
+ const res = await fetch(`${baseUrl}/`);
+ if (res.ok) {
+ const doc = (await res.json()) as OpenApiDocumentWithMetadata;
+ const defs = (doc.definitions ?? {}) as Record;
+ if ("users" in defs && "memes" in defs) {
+ openapi = doc;
+ break;
+ }
+ }
+ } catch {
+ /* not up yet */
+ }
+ await sleep(1000);
+ }
+ if (!openapi)
+ throw new Error("could not fetch OpenAPI from local postgrest");
+ writeFileSync(
+ join(HERE, "parity.openapi.json"),
+ JSON.stringify(openapi, null, 2),
+ );
+ const hasBlockKey = "x-postgrest-typegen-metadata" in openapi;
+ console.log(`[parity] openapi fetched; has metadata block: ${hasBlockKey}`);
+
+ const candidateMeta = openApiToGeneratorMetadata(openapi);
+ const candidateTs = await generateTypescript(
+ sortGeneratorMetadata(candidateMeta),
+ );
+ writeFileSync(join(HERE, "parity.candidate.ts"), candidateTs);
+
+ const ref = await introspect(pool, { includedSchemas: ["public"] });
+ // Normalize: PostgREST doesn't expose partition children (or other
+ // non-API-reachable relations), so restrict the reference to the relation
+ // set the candidate exposes before diffing.
+ const exposed = new Set(
+ [
+ ...candidateMeta.tables,
+ ...candidateMeta.views,
+ ...candidateMeta.foreignTables,
+ ...candidateMeta.materializedViews,
+ ].map((t) => `${t.schema}.${t.name}`),
+ );
+ ref.tables = ref.tables.filter((t) => exposed.has(`${t.schema}.${t.name}`));
+ ref.views = ref.views.filter((t) => exposed.has(`${t.schema}.${t.name}`));
+ ref.foreignTables = ref.foreignTables.filter((t) =>
+ exposed.has(`${t.schema}.${t.name}`),
+ );
+ ref.materializedViews = ref.materializedViews.filter((t) =>
+ exposed.has(`${t.schema}.${t.name}`),
+ );
+ ref.columns = ref.columns.filter((c) =>
+ exposed.has(`${c.schema}.${c.table}`),
+ );
+ const referenceTs = await generateTypescript(sortGeneratorMetadata(ref));
+ writeFileSync(join(HERE, "parity.reference.ts"), referenceTs);
+
+ let diff = "";
+ try {
+ diff = execFileSync(
+ "diff",
+ [
+ "-u",
+ join(HERE, "parity.reference.ts"),
+ join(HERE, "parity.candidate.ts"),
+ ],
+ { encoding: "utf8" },
+ );
+ } catch (e: any) {
+ diff = e.stdout ?? "";
+ }
+ writeFileSync(join(HERE, "parity.diff"), diff || "(identical)\n");
+ console.log(`[parity] diff lines: ${diff ? diff.split("\n").length : 0}`);
+ console.log(
+ `[parity] reference ${referenceTs.length}B, candidate ${candidateTs.length}B`,
+ );
+ } finally {
+ pgrst.kill("SIGTERM");
+ await pool.end();
+ cleanupDocker();
+ }
+}
+
+main()
+ .then(() => process.exit(0))
+ .catch((e) => {
+ console.error(e);
+ cleanupDocker();
+ process.exit(1);
+ });
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/roles-seed.sql b/packages/postgrest-typegen/experiments/openapi-spike/roles-seed.sql
new file mode 100644
index 000000000..9c81b72bb
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/roles-seed.sql
@@ -0,0 +1,25 @@
+-- Spike-only PostgREST role setup.
+--
+-- The shared introspection fixtures (00-init.sql / 01-memes.sql) describe a
+-- schema but do not create the PostgREST auth roles. PostgREST connects as an
+-- `authenticator` role and switches to the anonymous role for unauthenticated
+-- requests, so we add both here and grant the anon role broad read/execute
+-- access on `public` so the OpenAPI surface is as complete as possible.
+--
+-- This file is applied AFTER the fixtures and is throwaway spike scaffolding —
+-- it is never imported by the package and ships no changeset.
+
+create role anon nologin;
+create role authenticator noinherit login password 'authpass';
+grant anon to authenticator;
+
+grant usage on schema public to anon;
+grant all on all tables in schema public to anon;
+grant all on all sequences in schema public to anon;
+grant all on all functions in schema public to anon;
+grant all on all routines in schema public to anon;
+
+-- Cover objects created after this seed runs (none in this spike, but harmless).
+alter default privileges in schema public grant all on tables to anon;
+alter default privileges in schema public grant all on functions to anon;
+alter default privileges in schema public grant all on routines to anon;
diff --git a/packages/postgrest-typegen/experiments/openapi-spike/run.ts b/packages/postgrest-typegen/experiments/openapi-spike/run.ts
new file mode 100644
index 000000000..f8387d794
--- /dev/null
+++ b/packages/postgrest-typegen/experiments/openapi-spike/run.ts
@@ -0,0 +1,260 @@
+/**
+ * SPIKE harness: compare type generation from a live DB vs from a PostgREST
+ * OpenAPI document.
+ *
+ * reference path: live DB -> introspect() -> generateTypescript()
+ * candidate path: PostgREST OpenAPI -> spike adapter -> generateTypescript()
+ *
+ * Both paths feed the SAME pure generator, so any difference is attributable to
+ * the producer. The harness writes artifacts next to this file and a unified
+ * diff; REPORT.md (written separately) buckets the differences.
+ *
+ * Containers are driven through the `docker` CLI directly rather than via
+ * testcontainers: under Bun the testcontainers lifecycle promise did not hand
+ * control back after the health-check wait completed. The CLI path is fully
+ * synchronous and deterministic, which is all a throwaway spike needs.
+ *
+ * Run: `cd packages/postgrest-typegen && bun experiments/openapi-spike/run.ts`
+ *
+ * Throwaway experimental code — not wired into the package build/exports, no
+ * changeset.
+ */
+import { execFileSync } from "node:child_process";
+import { readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+
+import { Pool } from "pg";
+
+import { generateTypescript } from "../../src/generation/index.ts";
+import { introspect } from "../../src/introspection/index.ts";
+import { parseGeneratorMetadata } from "../../src/types.ts";
+import { openApiToGeneratorMetadata, type OpenApiDocument } from "./adapter.ts";
+
+const HERE = import.meta.dir;
+const FIXTURE_DIR = join(HERE, "..", "..", "test", "introspection", "fixtures");
+
+const NETWORK = "spike-net";
+const PG_NAME = "spike-pg";
+const PGRST_NAME = "spike-pgrst";
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+function docker(args: string[], opts: { allowFail?: boolean } = {}): string {
+ try {
+ return execFileSync("docker", args, { encoding: "utf8" }).trim();
+ } catch (e: any) {
+ if (opts.allowFail) return "";
+ throw new Error(
+ `docker ${args.join(" ")} failed:\n${e.stderr ?? e.message}`,
+ );
+ }
+}
+
+/** `docker port ` -> host port number. */
+function mappedPort(name: string, containerPort: number): number {
+ const out = docker(["port", name, String(containerPort)]);
+ // e.g. "0.0.0.0:55001\n[::]:55001" — take the first line's trailing number.
+ const first = out.split("\n")[0]?.trim() ?? "";
+ const port = Number(first.slice(first.lastIndexOf(":") + 1));
+ if (!Number.isFinite(port))
+ throw new Error(`could not parse port from: ${out}`);
+ return port;
+}
+
+function cleanup() {
+ docker(["rm", "-f", PG_NAME, PGRST_NAME], { allowFail: true });
+ docker(["network", "rm", NETWORK], { allowFail: true });
+}
+
+async function main() {
+ cleanup(); // remove any leftovers from a previous run
+
+ docker(["network", "create", NETWORK]);
+ console.log("[spike] created docker network");
+
+ console.log("[spike] starting postgres:15-alpine …");
+ docker([
+ "run",
+ "-d",
+ "--name",
+ PG_NAME,
+ "--network",
+ NETWORK,
+ "-e",
+ "POSTGRES_USER=postgres",
+ "-e",
+ "POSTGRES_PASSWORD=postgres",
+ "-e",
+ "POSTGRES_DB=postgres",
+ "-p",
+ "127.0.0.1::5432",
+ "postgres:15-alpine",
+ ]);
+ const pgPort = mappedPort(PG_NAME, 5432);
+ const pool = new Pool({
+ connectionString: `postgres://postgres:postgres@127.0.0.1:${pgPort}/postgres`,
+ });
+
+ // Wait for Postgres to accept connections.
+ let connected = false;
+ for (let i = 0; i < 60; i++) {
+ try {
+ await pool.query("select 1");
+ connected = true;
+ break;
+ } catch {
+ await sleep(1000);
+ }
+ }
+ if (!connected)
+ throw new Error("[spike] postgres did not become ready in 60s");
+ console.log("[spike] postgres ready");
+
+ console.log("[spike] applying fixtures + roles seed …");
+ await pool.query(readFileSync(join(FIXTURE_DIR, "00-init.sql"), "utf8"));
+ await pool.query(readFileSync(join(FIXTURE_DIR, "01-memes.sql"), "utf8"));
+ await pool.query(readFileSync(join(HERE, "roles-seed.sql"), "utf8"));
+
+ console.log("[spike] starting postgrest/postgrest …");
+ docker([
+ "run",
+ "-d",
+ "--name",
+ PGRST_NAME,
+ "--network",
+ NETWORK,
+ "-e",
+ `PGRST_DB_URI=postgres://authenticator:authpass@${PG_NAME}:5432/postgres`,
+ "-e",
+ "PGRST_DB_SCHEMAS=public",
+ "-e",
+ "PGRST_DB_ANON_ROLE=anon",
+ // Emit the full API surface regardless of the anon role's privileges.
+ "-e",
+ "PGRST_OPENAPI_MODE=ignore-privileges",
+ "-e",
+ "PGRST_LOG_LEVEL=info",
+ "-p",
+ "127.0.0.1::3000",
+ "postgrest/postgrest",
+ ]);
+ const pgrstPort = mappedPort(PGRST_NAME, 3000);
+ const baseUrl = `http://127.0.0.1:${pgrstPort}`;
+ console.log(`[spike] postgrest listening at ${baseUrl}`);
+
+ // Schema-cache reload + poll: PostgREST builds its cache at boot (the schema
+ // is already in place above), but issue an explicit reload and poll the root
+ // OpenAPI endpoint until the expected definitions appear, to avoid any race.
+ // https://docs.postgrest.org/en/stable/references/schema_cache.html
+ await pool.query(`NOTIFY pgrst, 'reload schema'`);
+
+ let openapi: OpenApiDocument | null = null;
+ for (let attempt = 0; attempt < 40; attempt++) {
+ try {
+ const res = await fetch(`${baseUrl}/`);
+ if (res.ok) {
+ const doc = (await res.json()) as OpenApiDocument;
+ const defs = doc.definitions ?? {};
+ if ("users" in defs && "memes" in defs) {
+ openapi = doc;
+ break;
+ }
+ }
+ } catch {
+ // postgrest not ready yet
+ }
+ await sleep(1000);
+ }
+
+ if (!openapi) {
+ throw new Error(
+ "[spike] could not obtain an OpenAPI doc with the expected definitions after 40s",
+ );
+ }
+ writeFileSync(join(HERE, "openapi.json"), JSON.stringify(openapi, null, 2));
+ console.log(
+ `[spike] saved openapi.json (${Object.keys(openapi.definitions ?? {}).length} definitions, ${
+ Object.keys(openapi.paths ?? {}).filter((p) => p.startsWith("/rpc/"))
+ .length
+ } rpc paths)`,
+ );
+
+ // --- reference path -------------------------------------------------------
+ console.log("[spike] reference path: introspect -> generateTypescript …");
+ const ref = await introspect(pool, { includedSchemas: ["public"] });
+ const referenceTs = await generateTypescript(ref);
+ writeFileSync(join(HERE, "reference.ts"), referenceTs);
+
+ // --- candidate path -------------------------------------------------------
+ console.log(
+ "[spike] candidate path: openapi adapter -> generateTypescript …",
+ );
+ const candidateMeta = openApiToGeneratorMetadata(openapi);
+ try {
+ parseGeneratorMetadata(candidateMeta);
+ console.log("[spike] candidate metadata passed parseGeneratorMetadata()");
+ } catch (err) {
+ console.warn(
+ "[spike] candidate metadata FAILED shape validation:",
+ String(err),
+ );
+ }
+ const candidateTs = await generateTypescript(candidateMeta);
+ writeFileSync(join(HERE, "candidate.ts"), candidateTs);
+
+ // --- diff -----------------------------------------------------------------
+ let diff = "";
+ try {
+ diff = execFileSync(
+ "diff",
+ ["-u", join(HERE, "reference.ts"), join(HERE, "candidate.ts")],
+ { encoding: "utf8" },
+ );
+ } catch (e: any) {
+ diff = e.stdout ?? ""; // diff exits 1 when files differ
+ }
+ writeFileSync(join(HERE, "output.diff"), diff || "(identical)\n");
+
+ // --- quick stats ----------------------------------------------------------
+ const stats = {
+ reference: {
+ tables: ref.tables.length,
+ foreignTables: ref.foreignTables.length,
+ views: ref.views.length,
+ materializedViews: ref.materializedViews.length,
+ columns: ref.columns.length,
+ relationships: ref.relationships.length,
+ functions: ref.functions.length,
+ types: ref.types.length,
+ },
+ candidate: {
+ tables: candidateMeta.tables.length,
+ foreignTables: candidateMeta.foreignTables.length,
+ views: candidateMeta.views.length,
+ materializedViews: candidateMeta.materializedViews.length,
+ columns: candidateMeta.columns.length,
+ relationships: candidateMeta.relationships.length,
+ functions: candidateMeta.functions.length,
+ types: candidateMeta.types.length,
+ },
+ diffLines: diff ? diff.split("\n").length : 0,
+ referenceTsBytes: referenceTs.length,
+ candidateTsBytes: candidateTs.length,
+ };
+ writeFileSync(join(HERE, "stats.json"), JSON.stringify(stats, null, 2));
+ console.log("[spike] stats:", JSON.stringify(stats, null, 2));
+
+ await pool.end();
+ console.log("[spike] done. artifacts written to", HERE);
+}
+
+main()
+ .then(() => {
+ cleanup();
+ process.exit(0);
+ })
+ .catch((err) => {
+ console.error(err);
+ cleanup();
+ process.exit(1);
+ });
diff --git a/packages/postgrest-typegen/knip.json b/packages/postgrest-typegen/knip.json
index 4d23f829b..349fd1675 100644
--- a/packages/postgrest-typegen/knip.json
+++ b/packages/postgrest-typegen/knip.json
@@ -1,5 +1,6 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
- "entry": ["test/global-setup.ts", "test/**/*.test.ts"],
+ "entry": ["test/global-setup.ts", "test/**/*.test.ts", "src/**/*.test.ts"],
+ "ignore": ["experiments/**"],
"ignoreBinaries": ["oxfmt", "oxlint"]
}
diff --git a/packages/postgrest-typegen/package.json b/packages/postgrest-typegen/package.json
index 270360185..3d0164817 100644
--- a/packages/postgrest-typegen/package.json
+++ b/packages/postgrest-typegen/package.json
@@ -50,6 +50,13 @@
"require": "./dist/generation/index.js",
"types": "./dist/generation/index.d.ts",
"default": "./dist/generation/index.js"
+ },
+ "./openapi": {
+ "bun": "./src/openapi/index.ts",
+ "import": "./dist/openapi/index.js",
+ "require": "./dist/openapi/index.js",
+ "types": "./dist/openapi/index.d.ts",
+ "default": "./dist/openapi/index.js"
}
},
"scripts": {
diff --git a/packages/postgrest-typegen/src/openapi/index.test.ts b/packages/postgrest-typegen/src/openapi/index.test.ts
new file mode 100644
index 000000000..46146ce20
--- /dev/null
+++ b/packages/postgrest-typegen/src/openapi/index.test.ts
@@ -0,0 +1,496 @@
+import { describe, expect, test } from "bun:test";
+
+import { generateTypescript } from "../generation/index.ts";
+import { parseGeneratorMetadata } from "../types.ts";
+import {
+ metadataToGeneratorMetadata,
+ openApiToGeneratorMetadata,
+} from "./index.ts";
+import type { PostgrestTypegenMetadata } from "./types.ts";
+
+/**
+ * A representative metadata block exercising the cases that distinguish the
+ * OpenAPI producer from the spike: object kinds, identity/generated columns,
+ * enums, composites, a computed scalar field, a relation-returning function,
+ * and a one-to-one relationship.
+ */
+const block: PostgrestTypegenMetadata = {
+ schemas: [{ name: "public", owner: "postgres" }],
+ enums: [
+ { schema: "public", name: "user_status", values: ["ACTIVE", "INACTIVE"] },
+ ],
+ composites: [
+ {
+ schema: "public",
+ name: "point3d",
+ attributes: [
+ { name: "x", type: "float8" },
+ { name: "y", type: "float8" },
+ { name: "z", type: "float8" },
+ ],
+ },
+ ],
+ tables: [
+ {
+ schema: "public",
+ name: "users",
+ kind: "table",
+ updatable: true,
+ columns: [
+ {
+ name: "id",
+ format: "int8",
+ is_nullable: false,
+ is_identity: true,
+ identity_generation: "BY DEFAULT",
+ is_generated: false,
+ is_updatable: true,
+ },
+ {
+ name: "name",
+ format: "text",
+ is_nullable: true,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ },
+ {
+ name: "status",
+ format: "user_status",
+ is_nullable: true,
+ default_value: "'ACTIVE'::user_status",
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ enums: ["ACTIVE", "INACTIVE"],
+ },
+ {
+ name: "home",
+ format: "point3d",
+ is_nullable: true,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ },
+ ],
+ },
+ {
+ schema: "public",
+ name: "todos",
+ kind: "table",
+ updatable: true,
+ columns: [
+ {
+ name: "id",
+ format: "int8",
+ is_nullable: false,
+ is_identity: true,
+ identity_generation: "ALWAYS",
+ is_generated: false,
+ is_updatable: true,
+ },
+ {
+ name: "user_id",
+ format: "int8",
+ is_nullable: false,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ },
+ {
+ name: "details",
+ format: "text",
+ is_nullable: true,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ },
+ ],
+ },
+ {
+ schema: "public",
+ name: "active_users",
+ kind: "view",
+ updatable: false,
+ columns: [
+ {
+ name: "id",
+ format: "int8",
+ is_nullable: true,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: false,
+ },
+ {
+ name: "name",
+ format: "text",
+ is_nullable: true,
+ is_identity: false,
+ is_generated: false,
+ is_updatable: true,
+ },
+ ],
+ },
+ ],
+ relationships: [
+ {
+ constraint_name: "todos_user_id_fkey",
+ schema: "public",
+ relation: "todos",
+ columns: ["user_id"],
+ is_one_to_one: false,
+ referenced_schema: "public",
+ referenced_relation: "users",
+ referenced_columns: ["id"],
+ },
+ ],
+ functions: [
+ {
+ schema: "public",
+ name: "add",
+ argument_types: "integer, integer",
+ args: [
+ { name: "a", type: "int4", mode: "in", has_default: false },
+ { name: "b", type: "int4", mode: "in", has_default: false },
+ ],
+ return: { type: "int4", is_set: false },
+ volatility: "IMMUTABLE",
+ },
+ {
+ // computed scalar field on `todos` (single table-row arg)
+ schema: "public",
+ name: "details_length",
+ argument_types: "todos",
+ args: [{ name: "", type: "todos", mode: "in", has_default: false }],
+ return: { type: "int4", is_set: false },
+ volatility: "STABLE",
+ },
+ {
+ // relation-returning function -> SetofOptions
+ schema: "public",
+ name: "list_users",
+ argument_types: "",
+ args: [],
+ return: {
+ type: "users",
+ is_set: true,
+ relation: { schema: "public", name: "users" },
+ rows: null,
+ },
+ volatility: "STABLE",
+ },
+ ],
+};
+
+describe("openApiToGeneratorMetadata", () => {
+ test("throws when the metadata extension is absent", () => {
+ expect(() => openApiToGeneratorMetadata({ swagger: "2.0" })).toThrow(
+ /x-postgrest-typegen-metadata/,
+ );
+ });
+
+ test("reads the metadata extension from an OpenAPI doc", () => {
+ const meta = openApiToGeneratorMetadata({
+ "x-postgrest-typegen-metadata": block,
+ });
+ expect(meta.tables.map((t) => t.name).sort()).toEqual(["todos", "users"]);
+ expect(meta.views.map((v) => v.name)).toEqual(["active_users"]);
+ });
+});
+
+describe("metadataToGeneratorMetadata", () => {
+ const meta = metadataToGeneratorMetadata(block);
+
+ test("produces a shape-valid GeneratorMetadata", () => {
+ expect(() => parseGeneratorMetadata(meta)).not.toThrow();
+ });
+
+ test("splits relations by kind", () => {
+ expect(meta.tables.map((t) => t.name).sort()).toEqual(["todos", "users"]);
+ expect(meta.views.map((v) => v.name)).toEqual(["active_users"]);
+ expect(meta.foreignTables).toEqual([]);
+ expect(meta.materializedViews).toEqual([]);
+ });
+
+ test("maps identity / nullability / enum onto columns", () => {
+ const usersId = meta.columns.find(
+ (c) => c.table === "users" && c.name === "id",
+ )!;
+ expect(usersId.is_identity).toBe(true);
+ expect(usersId.identity_generation).toBe("BY DEFAULT");
+ expect(usersId.is_nullable).toBe(false);
+
+ const status = meta.columns.find((c) => c.name === "status")!;
+ expect(status.enums).toEqual(["ACTIVE", "INACTIVE"]);
+ expect(status.default_value).toBe("'ACTIVE'::user_status");
+ });
+
+ test("keeps table_id consistent between a table and its columns", () => {
+ const todos = meta.tables.find((t) => t.name === "todos")!;
+ const todosCols = meta.columns.filter((c) => c.table_id === todos.id);
+ expect(todosCols.map((c) => c.name).sort()).toEqual([
+ "details",
+ "id",
+ "user_id",
+ ]);
+ });
+
+ test("registers enum and composite types (and not table row types) as such", () => {
+ const userStatus = meta.types.find((t) => t.name === "user_status")!;
+ expect(userStatus.enums).toEqual(["ACTIVE", "INACTIVE"]);
+ const point3d = meta.types.find((t) => t.name === "point3d")!;
+ expect(point3d.attributes.map((a) => a.name)).toEqual(["x", "y", "z"]);
+ // table row type carries a relation id but no attributes (so it is not a composite)
+ const usersType = meta.types.find((t) => t.name === "users")!;
+ expect(usersType.type_relation_id).not.toBeNull();
+ expect(usersType.attributes).toEqual([]);
+ });
+
+ test("maps a relation-returning function to return_type_relation_id", () => {
+ const listUsers = meta.functions.find((f) => f.name === "list_users")!;
+ const usersTable = meta.tables.find((t) => t.name === "users")!;
+ expect(listUsers.return_type_relation_id).toBe(usersTable.id);
+ expect(listUsers.is_set_returning_function).toBe(true);
+ });
+});
+
+describe("end-to-end generateTypescript", () => {
+ test("renders the expected Database type from the metadata block", async () => {
+ const ts = await generateTypescript(metadataToGeneratorMetadata(block), {
+ detectOneToOneRelationships: true,
+ });
+ expect(ts).toMatchInlineSnapshot(`
+ "export type Json =
+ | string
+ | number
+ | boolean
+ | null
+ | { [key: string]: Json | undefined }
+ | Json[]
+
+ export type Database = {
+ public: {
+ Tables: {
+ todos: {
+ Row: {
+ details: string | null
+ id: number
+ user_id: number
+ details_length: number | null
+ }
+ Insert: {
+ details?: string | null
+ id?: never
+ user_id: number
+ }
+ Update: {
+ details?: string | null
+ id?: never
+ user_id?: number
+ }
+ Relationships: [
+ {
+ foreignKeyName: "todos_user_id_fkey"
+ columns: ["user_id"]
+ isOneToOne: false
+ referencedRelation: "users"
+ referencedColumns: ["id"]
+ },
+ ]
+ }
+ users: {
+ Row: {
+ home: Database["public"]["CompositeTypes"]["point3d"] | null
+ id: number
+ name: string | null
+ status: Database["public"]["Enums"]["user_status"] | null
+ }
+ Insert: {
+ home?: Database["public"]["CompositeTypes"]["point3d"] | null
+ id?: number
+ name?: string | null
+ status?: Database["public"]["Enums"]["user_status"] | null
+ }
+ Update: {
+ home?: Database["public"]["CompositeTypes"]["point3d"] | null
+ id?: number
+ name?: string | null
+ status?: Database["public"]["Enums"]["user_status"] | null
+ }
+ Relationships: []
+ }
+ }
+ Views: {
+ active_users: {
+ Row: {
+ id: number | null
+ name: string | null
+ }
+ Relationships: []
+ }
+ }
+ Functions: {
+ add: { Args: { a: number; b: number }; Returns: number }
+ details_length: {
+ Args: { "": Database["public"]["Tables"]["todos"]["Row"] }
+ Returns: {
+ error: true
+ } & "the function public.details_length with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache"
+ }
+ list_users: {
+ Args: never
+ Returns: {
+ home: Database["public"]["CompositeTypes"]["point3d"] | null
+ id: number
+ name: string | null
+ status: Database["public"]["Enums"]["user_status"] | null
+ }
+ SetofOptions: {
+ from: "*"
+ to: "users"
+ isOneToOne: true
+ isSetofReturn: true
+ }
+ }
+ }
+ Enums: {
+ user_status: "ACTIVE" | "INACTIVE"
+ }
+ CompositeTypes: {
+ point3d: {
+ x: number | null
+ y: number | null
+ z: number | null
+ }
+ }
+ }
+ }
+
+ type DatabaseWithoutInternals = Omit
+
+ type DefaultSchema = DatabaseWithoutInternals[Extract]
+
+ export type Tables<
+ DefaultSchemaTableNameOrOptions extends
+ | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"])
+ | { schema: keyof DatabaseWithoutInternals },
+ TableName extends DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
+ DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])
+ : never = never,
+ > = DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] &
+ DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends {
+ Row: infer R
+ }
+ ? R
+ : never
+ : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] &
+ DefaultSchema["Views"])
+ ? (DefaultSchema["Tables"] &
+ DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends {
+ Row: infer R
+ }
+ ? R
+ : never
+ : never
+
+ export type TablesInsert<
+ DefaultSchemaTableNameOrOptions extends
+ | keyof DefaultSchema["Tables"]
+ | { schema: keyof DatabaseWithoutInternals },
+ TableName extends DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+ > = DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Insert: infer I
+ }
+ ? I
+ : never
+ : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
+ ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
+ Insert: infer I
+ }
+ ? I
+ : never
+ : never
+
+ export type TablesUpdate<
+ DefaultSchemaTableNameOrOptions extends
+ | keyof DefaultSchema["Tables"]
+ | { schema: keyof DatabaseWithoutInternals },
+ TableName extends DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"]
+ : never = never,
+ > = DefaultSchemaTableNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends {
+ Update: infer U
+ }
+ ? U
+ : never
+ : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"]
+ ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends {
+ Update: infer U
+ }
+ ? U
+ : never
+ : never
+
+ export type Enums<
+ DefaultSchemaEnumNameOrOptions extends
+ | keyof DefaultSchema["Enums"]
+ | { schema: keyof DatabaseWithoutInternals },
+ EnumName extends DefaultSchemaEnumNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"]
+ : never = never,
+ > = DefaultSchemaEnumNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName]
+ : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"]
+ ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions]
+ : never
+
+ export type CompositeTypes<
+ PublicCompositeTypeNameOrOptions extends
+ | keyof DefaultSchema["CompositeTypes"]
+ | { schema: keyof DatabaseWithoutInternals },
+ CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
+ : never = never,
+ > = PublicCompositeTypeNameOrOptions extends {
+ schema: keyof DatabaseWithoutInternals
+ }
+ ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
+ : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"]
+ ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
+ : never
+
+ export const Constants = {
+ public: {
+ Enums: {
+ user_status: ["ACTIVE", "INACTIVE"],
+ },
+ },
+ } as const
+ "
+ `);
+ });
+});
diff --git a/packages/postgrest-typegen/src/openapi/index.ts b/packages/postgrest-typegen/src/openapi/index.ts
new file mode 100644
index 000000000..c91a8277b
--- /dev/null
+++ b/packages/postgrest-typegen/src/openapi/index.ts
@@ -0,0 +1,343 @@
+/**
+ * OpenAPI producer: PostgREST OpenAPI document -> `GeneratorMetadata`.
+ *
+ * Second producer for the pluggable `GeneratorMetadata` contract, alongside
+ * `introspect(db)`. It reads the `x-postgrest-typegen-metadata` vendor extension
+ * that PostgREST emits under its opt-in `openapi-metadata` config and maps it to
+ * `GeneratorMetadata`, so the language generators can run from a PostgREST URL
+ * alone (no database connection).
+ *
+ * The block is name-keyed; OIDs are synthesized here (memoized by name) so the
+ * join keys the generators rely on (`table_id`, `type_id`,
+ * `return_type_relation_id`) stay consistent. See {@link PostgrestTypegenMetadata}
+ * for the contract and field conventions.
+ */
+import type {
+ GeneratorMetadata,
+ PostgresColumn,
+ PostgresFunction,
+ PostgresRelationship,
+ PostgresType,
+} from "../types.ts";
+import {
+ METADATA_EXTENSION_KEY,
+ type MetadataTable,
+ type OpenApiDocumentWithMetadata,
+ type PostgrestTypegenMetadata,
+} from "./types.ts";
+
+export type {
+ MetadataColumn,
+ MetadataComposite,
+ MetadataEnum,
+ MetadataFunction,
+ MetadataRelationship,
+ MetadataTable,
+ OpenApiDocumentWithMetadata,
+ PostgrestTypegenMetadata,
+} from "./types.ts";
+export { METADATA_EXTENSION_KEY } from "./types.ts";
+
+const KIND_TO_BUCKET = {
+ table: "tables",
+ foreign_table: "foreignTables",
+ view: "views",
+ materialized_view: "materializedViews",
+} as const;
+
+/**
+ * Real pg_catalog OIDs for common base/array types. The generator compares arg
+ * `type_id`s against a hardcoded OID set and sorts overloads by `type_id`, so
+ * these must be the genuine OIDs; user types (enums/composites/table rows) fall
+ * back to synthesized ids, which is fine since they're never in that set.
+ */
+const BASE_TYPE_OIDS: Record = {
+ bool: 16,
+ bytea: 17,
+ char: 18,
+ name: 19,
+ int8: 20,
+ int2: 21,
+ int4: 23,
+ text: 25,
+ oid: 26,
+ json: 114,
+ xml: 142,
+ point: 600,
+ float4: 700,
+ float8: 701,
+ bpchar: 1042,
+ varchar: 1043,
+ date: 1082,
+ time: 1083,
+ timestamp: 1114,
+ timestamptz: 1184,
+ interval: 1186,
+ timetz: 1266,
+ numeric: 1700,
+ uuid: 2950,
+ jsonb: 3802,
+ record: 2249,
+ void: 2278,
+ // common array types
+ _bool: 1000,
+ _bytea: 1001,
+ _int8: 1016,
+ _int2: 1005,
+ _int4: 1007,
+ _text: 1009,
+ _varchar: 1015,
+ _numeric: 1231,
+ _uuid: 2951,
+ _json: 199,
+ _jsonb: 3807,
+ _timestamptz: 1185,
+ _timestamp: 1115,
+ _date: 1182,
+ _float4: 1021,
+ _float8: 1022,
+};
+
+/**
+ * Extract and map the `x-postgrest-typegen-metadata` block of a PostgREST
+ * OpenAPI document into `GeneratorMetadata`.
+ *
+ * @throws if the block is absent (PostgREST without `openapi-metadata` enabled,
+ * or a non-PostgREST document).
+ */
+export function openApiToGeneratorMetadata(
+ doc: OpenApiDocumentWithMetadata,
+): GeneratorMetadata {
+ const block = doc[METADATA_EXTENSION_KEY];
+ if (!block) {
+ throw new Error(
+ `OpenAPI document has no "${METADATA_EXTENSION_KEY}" extension. ` +
+ "Enable PostgREST's `openapi-metadata` config to emit it.",
+ );
+ }
+ return metadataToGeneratorMetadata(block);
+}
+
+/** Map an already-extracted metadata block to `GeneratorMetadata`. */
+export function metadataToGeneratorMetadata(
+ block: PostgrestTypegenMetadata,
+): GeneratorMetadata {
+ // ---- OID synthesis (memoized by stable key) --------------------------------
+ let nextOid = 16384;
+ const oidByKey = new Map();
+ const oid = (key: string): number => {
+ const existing = oidByKey.get(key);
+ if (existing !== undefined) return existing;
+ const id = nextOid++;
+ oidByKey.set(key, id);
+ return id;
+ };
+ const relationId = (schema: string, name: string) =>
+ oid(`rel:${schema}.${name}`);
+
+ // ---- type registry ---------------------------------------------------------
+ // Every type referenced by a column/arg/return needs an entry so the
+ // generator's `typesById.get(type_id)` resolves and `pgTypeToTsType` can run.
+ const types: PostgresType[] = [];
+ const typeByName = new Map();
+ const ensureType = (
+ name: string,
+ opts: {
+ schema?: string;
+ enums?: string[];
+ attributes?: { name: string; type_id: number }[];
+ typeRelationId?: number | null;
+ } = {},
+ ): number => {
+ const existing = typeByName.get(name);
+ if (existing) {
+ if (opts.enums && existing.enums.length === 0)
+ existing.enums = opts.enums;
+ if (opts.attributes && existing.attributes.length === 0)
+ existing.attributes = opts.attributes;
+ if (opts.typeRelationId != null && existing.type_relation_id == null)
+ existing.type_relation_id = opts.typeRelationId;
+ return existing.id;
+ }
+ const t: PostgresType = {
+ // Use real pg_catalog OIDs for known base types: the generator checks arg
+ // types against a hardcoded OID set (VALID_UNNAMED_FUNCTION_ARG_TYPES) and
+ // sorts overloads by type_id, so synthesized ids would break both.
+ id: BASE_TYPE_OIDS[name] ?? oid(`type:${name}`),
+ name,
+ schema: opts.schema ?? "",
+ format: name,
+ enums: opts.enums ?? [],
+ attributes: opts.attributes ?? [],
+ comment: null,
+ type_relation_id: opts.typeRelationId ?? null,
+ };
+ typeByName.set(name, t);
+ types.push(t);
+ return t.id;
+ };
+
+ // Enums first (so enum-typed columns/args resolve to the Enums block).
+ for (const e of block.enums) {
+ ensureType(e.name, { schema: e.schema, enums: e.values });
+ }
+ // Table/view row types carry `type_relation_id` (no attributes -> not listed
+ // as composites) so single-table-arg functions resolve as relation types.
+ for (const t of block.tables) {
+ ensureType(t.name, {
+ schema: t.schema,
+ typeRelationId: relationId(t.schema, t.name),
+ });
+ }
+ // Standalone composite types: attributes -> CompositeTypes block, and a
+ // synthesized `type_relation_id` so they count as relation types (needed for
+ // SetofOptions on functions returning the composite).
+ for (const c of block.composites) {
+ ensureType(c.name, {
+ schema: c.schema,
+ typeRelationId: oid(`comprel:${c.schema}.${c.name}`),
+ attributes: c.attributes.map((a) => ({
+ name: a.name,
+ type_id: ensureType(a.type),
+ })),
+ });
+ }
+
+ // ---- tables / views / matviews / foreign tables + columns ------------------
+ const tables: GeneratorMetadata["tables"] = [];
+ const foreignTables: GeneratorMetadata["foreignTables"] = [];
+ const views: GeneratorMetadata["views"] = [];
+ const materializedViews: GeneratorMetadata["materializedViews"] = [];
+ const columns: PostgresColumn[] = [];
+
+ const pushTableLike = (t: MetadataTable) => {
+ const id = relationId(t.schema, t.name);
+ const bucket = KIND_TO_BUCKET[t.kind];
+
+ t.columns.forEach((col, idx) => {
+ const ordinal = idx + 1;
+ columns.push({
+ table_id: id,
+ schema: t.schema,
+ table: t.name,
+ id: `${id}.${ordinal}`,
+ ordinal_position: ordinal,
+ name: col.name,
+ default_value: col.default_value ?? null,
+ data_type: col.data_type ?? col.format,
+ format: col.format,
+ is_identity: col.is_identity,
+ identity_generation: col.identity_generation ?? null,
+ is_generated: col.is_generated,
+ is_nullable: col.is_nullable,
+ is_updatable: col.is_updatable,
+ is_unique: col.is_unique ?? false,
+ enums: col.enums ?? [],
+ check: col.check ?? null,
+ comment: col.comment ?? null,
+ });
+ });
+
+ if (bucket === "tables") {
+ tables.push({
+ id,
+ schema: t.schema,
+ name: t.name,
+ rls_enabled: false,
+ rls_forced: false,
+ replica_identity: "DEFAULT",
+ bytes: 0,
+ size: "0 bytes",
+ live_rows_estimate: 0,
+ dead_rows_estimate: 0,
+ comment: t.comment ?? null,
+ });
+ } else if (bucket === "foreignTables") {
+ foreignTables.push({
+ id,
+ schema: t.schema,
+ name: t.name,
+ comment: t.comment ?? null,
+ });
+ } else if (bucket === "views") {
+ views.push({
+ id,
+ schema: t.schema,
+ name: t.name,
+ is_updatable: t.updatable,
+ comment: t.comment ?? null,
+ });
+ } else {
+ materializedViews.push({
+ id,
+ schema: t.schema,
+ name: t.name,
+ is_populated: t.is_populated ?? true,
+ comment: t.comment ?? null,
+ });
+ }
+ };
+ for (const t of block.tables) pushTableLike(t);
+
+ // ---- relationships ---------------------------------------------------------
+ const relationships: PostgresRelationship[] = block.relationships.map(
+ (r) => ({
+ foreign_key_name: r.constraint_name,
+ schema: r.schema,
+ relation: r.relation,
+ columns: r.columns,
+ is_one_to_one: r.is_one_to_one,
+ referenced_schema: r.referenced_schema,
+ referenced_relation: r.referenced_relation,
+ referenced_columns: r.referenced_columns,
+ }),
+ );
+
+ // ---- functions (RPC + computed scalar/array fields) ------------------------
+ const functions: PostgresFunction[] = block.functions.map((fn) => {
+ const args = fn.args.map((a) => ({
+ mode: a.mode,
+ name: a.name,
+ type_id: ensureType(a.type),
+ has_default: a.has_default,
+ }));
+ const returnTypeId = ensureType(fn.return.type);
+ return {
+ id: oid(`fn:${fn.schema}.${fn.name}.${fn.argument_types}`),
+ schema: fn.schema,
+ name: fn.name,
+ language: fn.language ?? "sql",
+ definition: "",
+ complete_statement: "",
+ args,
+ argument_types: fn.argument_types,
+ identity_argument_types: fn.argument_types,
+ return_type_id: returnTypeId,
+ return_type: fn.return.type,
+ return_type_relation_id: fn.return.relation
+ ? relationId(fn.return.relation.schema, fn.return.relation.name)
+ : null,
+ is_set_returning_function: fn.return.is_set,
+ prorows: fn.return.rows ?? null,
+ behavior: fn.volatility,
+ security_definer: false,
+ config_params: null,
+ } satisfies PostgresFunction;
+ });
+
+ return {
+ schemas: block.schemas.map((s) => ({
+ id: oid(`schema:${s.name}`),
+ name: s.name,
+ owner: s.owner ?? "postgres",
+ })),
+ tables,
+ foreignTables,
+ views,
+ materializedViews,
+ columns,
+ relationships,
+ functions,
+ types,
+ };
+}
diff --git a/packages/postgrest-typegen/src/openapi/types.ts b/packages/postgrest-typegen/src/openapi/types.ts
new file mode 100644
index 000000000..2c2adb876
--- /dev/null
+++ b/packages/postgrest-typegen/src/openapi/types.ts
@@ -0,0 +1,133 @@
+/**
+ * Contract for the `x-postgrest-typegen-metadata` OpenAPI vendor extension.
+ *
+ * This is the cross-repo contract between PostgREST (which emits the block under
+ * the opt-in `openapi-metadata` config) and this package's OpenAPI producer
+ * ({@link openApiToGeneratorMetadata}). It is intentionally name-keyed (no OIDs)
+ * and shaped to mirror {@link GeneratorMetadata} so the adapter is a thin map.
+ *
+ * Field conventions are chosen to match what `introspect()` emits, so the
+ * adapter can pass values through and produce byte-identical generator output:
+ * - `column.format` is the pg_catalog short type name (`pg_type.typname`):
+ * `int8`, `_text` (arrays prefixed `_`), `user_status` (enum/composite names).
+ * - `relationship.is_one_to_one` is `true` exactly when PostgREST's cardinality
+ * is `O2O` (or a single-row computed relationship).
+ * - `function.argument_types` is the comma-joined argument *type* names, with
+ * table-row args spelled as the bare table name (so the generator can attach a
+ * single-table-arg function to that table's Row as a computed field).
+ */
+
+export type MetadataRelationKind =
+ | "table"
+ | "view"
+ | "materialized_view"
+ | "foreign_table";
+
+export interface MetadataColumn {
+ name: string;
+ /** pg_catalog short type name (`pg_type.typname`); arrays prefixed `_`. */
+ format: string;
+ /** SQL spelling (`format_type`). Cosmetic for TS generation; defaults to `format`. */
+ data_type?: string;
+ is_nullable: boolean;
+ /** Raw default expression, or null. */
+ default_value?: string | null;
+ is_identity: boolean;
+ identity_generation?: "ALWAYS" | "BY DEFAULT" | null;
+ is_generated: boolean;
+ /** Per-column updatability — required to render view Insert/Update correctly. */
+ is_updatable: boolean;
+ is_unique?: boolean;
+ /** Enum variants when the column's type is an enum (else empty/omitted). */
+ enums?: string[];
+ check?: string | null;
+ comment?: string | null;
+}
+
+export interface MetadataTable {
+ schema: string;
+ name: string;
+ kind: MetadataRelationKind;
+ /** Relation-level updatability; drives view Insert/Update generation. */
+ updatable: boolean;
+ /** Materialized-view population state; only meaningful for `materialized_view`. */
+ is_populated?: boolean;
+ comment?: string | null;
+ columns: MetadataColumn[];
+}
+
+export interface MetadataRelationship {
+ /** FK constraint name (or computed-relationship function name). */
+ constraint_name: string;
+ schema: string;
+ relation: string;
+ columns: string[];
+ is_one_to_one: boolean;
+ referenced_schema: string;
+ referenced_relation: string;
+ referenced_columns: string[];
+}
+
+export interface MetadataFunctionArg {
+ name: string;
+ /** pg_catalog short type name, same convention as column.format. */
+ type: string;
+ mode: "in" | "out" | "inout" | "variadic" | "table";
+ has_default: boolean;
+}
+
+export interface MetadataFunctionReturn {
+ /** pg_catalog short type name of the return (scalar/composite), or the relation name. */
+ type: string;
+ is_set: boolean;
+ /** When the function returns a table/view row type, its schema+name; else null. */
+ relation?: { schema: string; name: string } | null;
+ /** `prorows` estimate when set-returning; null otherwise. */
+ rows?: number | null;
+}
+
+export interface MetadataFunction {
+ schema: string;
+ name: string;
+ /** Comma-joined argument type names; bare table name for a single table-row arg. */
+ argument_types: string;
+ args: MetadataFunctionArg[];
+ return: MetadataFunctionReturn;
+ volatility: "IMMUTABLE" | "STABLE" | "VOLATILE";
+ language?: string;
+}
+
+export interface MetadataComposite {
+ schema: string;
+ name: string;
+ attributes: { name: string; type: string }[];
+}
+
+export interface MetadataEnum {
+ schema: string;
+ name: string;
+ values: string[];
+}
+
+export interface MetadataSchema {
+ name: string;
+ owner?: string;
+}
+
+export interface PostgrestTypegenMetadata {
+ schemas: MetadataSchema[];
+ tables: MetadataTable[];
+ relationships: MetadataRelationship[];
+ functions: MetadataFunction[];
+ composites: MetadataComposite[];
+ enums: MetadataEnum[];
+}
+
+/** The OpenAPI (Swagger 2.0) document, narrowed to the bit we consume. */
+export interface OpenApiDocumentWithMetadata {
+ "x-postgrest-typegen-metadata"?: PostgrestTypegenMetadata;
+ [key: string]: unknown;
+}
+
+/** The vendor-extension key carrying the metadata block. */
+export const METADATA_EXTENSION_KEY = "x-postgrest-typegen-metadata" as const;