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;