From 80e1ccfeb2952d24b316dfbc2242932bd852ec21 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 10:25:15 +0000 Subject: [PATCH 01/13] feat: consume @supabase/postgrest-typegen for type generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrate the extracted @supabase/postgrest-typegen package as the single source of truth for type generation, replacing the embedded templates. - src/lib/generators.ts: rewrite getGeneratorMetadata as a ~30-line adapter over the package's introspect(). It wraps pgMeta.query into the package's structural Queryable (throws on {error}), preserves the Promise> contract, surfaces the first query error, and still ends the pool. Re-exports GeneratorMetadata from the package. - src/server/server.ts: getTypeOutput now calls getGeneratorMetadata + generateTypescript/Go/Python/Swift, threading GENERATE_TYPES_DEFAULT_SCHEMA, POSTGREST_VERSION, detect-1:1, and Swift access-control env values. Behavior freeze: the CLI path still only supports included schemas. - src/server/routes/generators/*.ts: swap `apply` template imports for the package's generateX; query params, headers, and error shapes unchanged. - Delete src/server/templates/*.ts and test/server/templates/go.test.ts; re-point test/types.test.ts's pgTypeToTsType import and constants.ts's AccessControl import to the package; drop the now-unused VALID_* constants. - Keep PostgresMetaRelationships.ts and src/lib/sql/*.sql.ts (they back the REST endpoints) — accepted temporary duplication. `npm run check` passes. Full-suite byte-parity validation is Phase 2.3 (PGMETA-114). The dependency is pinned to 1.0.0-alpha.1; local validation installs it from Verdaccio via an uncommitted scoped .npmrc, and the lockfile is finalized when the package is published to npm (Phase 3). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01B53a2ayu9d16u821FQ5Qqz --- package.json | 1 + src/lib/generators.ts | 164 +--- src/server/constants.ts | 6 +- src/server/routes/generators/go.ts | 4 +- src/server/routes/generators/python.ts | 4 +- src/server/routes/generators/swift.ts | 7 +- src/server/routes/generators/typescript.ts | 5 +- src/server/server.ts | 128 +-- src/server/templates/go.ts | 330 ------- src/server/templates/python.ts | 416 --------- src/server/templates/swift.ts | 421 --------- src/server/templates/typescript.ts | 974 --------------------- test/server/templates/go.test.ts | 106 --- test/types.test.ts | 2 +- 14 files changed, 71 insertions(+), 2497 deletions(-) delete mode 100644 src/server/templates/go.ts delete mode 100644 src/server/templates/python.ts delete mode 100644 src/server/templates/swift.ts delete mode 100644 src/server/templates/typescript.ts delete mode 100644 test/server/templates/go.test.ts diff --git a/package.json b/package.json index ed9b8ae2f..73f1f91b4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", + "@supabase/postgrest-typegen": "1.0.0-alpha.1", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", diff --git a/src/lib/generators.ts b/src/lib/generators.ts index 6b5f55e55..e829d37e7 100644 --- a/src/lib/generators.ts +++ b/src/lib/generators.ts @@ -1,29 +1,20 @@ +import { introspect, type GeneratorMetadata, type Queryable } from '@supabase/postgrest-typegen' import PostgresMeta from './PostgresMeta.js' -import { - PostgresColumn, - PostgresForeignTable, - PostgresFunction, - PostgresMaterializedView, - PostgresMetaResult, - PostgresRelationship, - PostgresSchema, - PostgresTable, - PostgresType, - PostgresView, -} from './types.js' - -export type GeneratorMetadata = { - schemas: PostgresSchema[] - tables: Omit[] - foreignTables: Omit[] - views: Omit[] - materializedViews: Omit[] - columns: PostgresColumn[] - relationships: PostgresRelationship[] - functions: PostgresFunction[] - types: PostgresType[] -} - +import { PostgresMetaResult } from './types.js' + +// Re-export so existing consumers can keep importing the type from here. +export type { GeneratorMetadata } + +/** + * Adapter over `@supabase/postgrest-typegen`'s `introspect()`, preserving the + * historical `getGeneratorMetadata` signature and `{ data, error }` contract. + * + * The package is driver-agnostic: it takes a structural `Queryable` whose + * `query()` resolves to `{ rows }` and throws on failure. We wrap `pgMeta.query` + * (which returns `{ data, error }`) into that shape, surface the first query + * error as the result error, and always end the pool — matching the previous + * behavior. + */ export async function getGeneratorMetadata( pgMeta: PostgresMeta, filters: { includedSchemas?: string[]; excludedSchemas?: string[] } = { @@ -31,111 +22,28 @@ export async function getGeneratorMetadata( excludedSchemas: [], } ): Promise> { - const includedSchemas = filters.includedSchemas ?? [] - const excludedSchemas = filters.excludedSchemas ?? [] - - const { data: schemas, error: schemasError } = await pgMeta.schemas.list({ - includeSystemSchemas: false, - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - }) - if (schemasError) { - return { data: null, error: schemasError } - } - - const { data: tables, error: tablesError } = await pgMeta.tables.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeColumns: false, - }) - if (tablesError) { - return { data: null, error: tablesError } - } - - const { data: foreignTables, error: foreignTablesError } = await pgMeta.foreignTables.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeColumns: false, - }) - if (foreignTablesError) { - return { data: null, error: foreignTablesError } - } - - const { data: views, error: viewsError } = await pgMeta.views.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeColumns: false, - }) - if (viewsError) { - return { data: null, error: viewsError } + const queryable: Queryable = { + query: async (sql: string) => { + const { data, error } = await pgMeta.query(sql) + if (error) { + throw error + } + return { rows: data ?? [] } + }, } - const { data: materializedViews, error: materializedViewsError } = - await pgMeta.materializedViews.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeColumns: false, + try { + const data = await introspect(queryable, { + includedSchemas: filters.includedSchemas, + excludedSchemas: filters.excludedSchemas, }) - if (materializedViewsError) { - return { data: null, error: materializedViewsError } - } - - const { data: columns, error: columnsError } = await pgMeta.columns.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeSystemSchemas: false, - }) - if (columnsError) { - return { data: null, error: columnsError } - } - - const { data: relationships, error: relationshipsError } = await pgMeta.relationships.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeSystemSchemas: false, - }) - if (relationshipsError) { - return { data: null, error: relationshipsError } - } - - const { data: functions, error: functionsError } = await pgMeta.functions.list({ - includedSchemas: includedSchemas.length > 0 ? includedSchemas : undefined, - excludedSchemas: excludedSchemas.length > 0 ? excludedSchemas : undefined, - includeSystemSchemas: false, - }) - if (functionsError) { - return { data: null, error: functionsError } - } - - const { data: types, error: typesError } = await pgMeta.types.list({ - includeTableTypes: true, - includeArrayTypes: true, - includeSystemSchemas: true, - }) - if (typesError) { - return { data: null, error: typesError } - } - - await pgMeta.end() - - return { - data: { - schemas: schemas.filter( - ({ name }) => - !excludedSchemas.includes(name) && - (includedSchemas.length === 0 || includedSchemas.includes(name)) - ), - tables, - foreignTables, - views, - materializedViews, - columns, - relationships, - functions: functions.filter( - ({ return_type }) => !['trigger', 'event_trigger'].includes(return_type) - ), - types, - }, - error: null, + return { data, error: null } + } catch (error) { + return { + data: null, + error: error as PostgresMetaResult['error'] & { message: string }, + } + } finally { + await pgMeta.end() } } diff --git a/src/server/constants.ts b/src/server/constants.ts index c64b45e61..f4996c3d8 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { PoolConfig } from '../lib/types.js' import { getSecret } from '../lib/secrets.js' -import { AccessControl } from './templates/swift.js' +import type { AccessControl } from '@supabase/postgrest-typegen' import pkg from '#package.json' with { type: 'json' } export const PG_META_HOST = process.env.PG_META_HOST || '0.0.0.0' @@ -51,10 +51,6 @@ export const GENERATE_TYPES_SWIFT_ACCESS_CONTROL = process.env ? (process.env.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL as AccessControl) : 'internal' -// json/jsonb/text types -export const VALID_UNNAMED_FUNCTION_ARG_TYPES = new Set([114, 3802, 25]) -export const VALID_FUNCTION_ARGS_MODE = new Set(['in', 'inout', 'variadic']) - export const PG_META_MAX_RESULT_SIZE = process.env.PG_META_MAX_RESULT_SIZE_MB ? // Node-postgres get a maximum size in bytes make the conversion from the env variable // from MB to Bytes diff --git a/src/server/routes/generators/go.ts b/src/server/routes/generators/go.ts index fa85fa469..7c27c239b 100644 --- a/src/server/routes/generators/go.ts +++ b/src/server/routes/generators/go.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' -import { apply as applyGoTemplate } from '../../templates/go.js' +import { generateGo } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' export default async (fastify: FastifyInstance) => { @@ -29,6 +29,6 @@ export default async (fastify: FastifyInstance) => { return { error: generatorMetaError.message } } - return applyGoTemplate(generatorMeta) + return generateGo(generatorMeta!) }) } diff --git a/src/server/routes/generators/python.ts b/src/server/routes/generators/python.ts index 706d9dd47..9c9010d7d 100644 --- a/src/server/routes/generators/python.ts +++ b/src/server/routes/generators/python.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' -import { apply as applyPyTemplate } from '../../templates/python.js' +import { generatePython } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' export default async (fastify: FastifyInstance) => { @@ -28,6 +28,6 @@ export default async (fastify: FastifyInstance) => { return { error: generatorMetaError.message } } - return applyPyTemplate(generatorMeta) + return generatePython(generatorMeta!) }) } diff --git a/src/server/routes/generators/swift.ts b/src/server/routes/generators/swift.ts index e02839fbf..34532ddf3 100644 --- a/src/server/routes/generators/swift.ts +++ b/src/server/routes/generators/swift.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' -import { apply as applySwiftTemplate, AccessControl } from '../../templates/swift.js' +import { type AccessControl, generateSwift } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' export default async (fastify: FastifyInstance) => { @@ -31,9 +31,6 @@ export default async (fastify: FastifyInstance) => { return { error: generatorMetaError.message } } - return applySwiftTemplate({ - ...generatorMeta, - accessControl, - }) + return generateSwift(generatorMeta!, { accessControl }) }) } diff --git a/src/server/routes/generators/typescript.ts b/src/server/routes/generators/typescript.ts index 259cd141a..b2b6f00b2 100644 --- a/src/server/routes/generators/typescript.ts +++ b/src/server/routes/generators/typescript.ts @@ -1,7 +1,7 @@ import type { FastifyInstance } from 'fastify' import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' -import { apply as applyTypescriptTemplate } from '../../templates/typescript.js' +import { generateTypescript } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' export default async (fastify: FastifyInstance) => { @@ -33,8 +33,7 @@ export default async (fastify: FastifyInstance) => { return { error: generatorMetaError.message } } - return applyTypescriptTemplate({ - ...generatorMeta, + return generateTypescript(generatorMeta!, { detectOneToOneRelationships, postgrestVersion, }) diff --git a/src/server/server.ts b/src/server/server.ts index 68fbb54cb..c766026bb 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -7,6 +7,7 @@ import { DEFAULT_POOL_CONFIG, EXPORT_DOCS, GENERATE_TYPES, + GENERATE_TYPES_DEFAULT_SCHEMA, GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS, GENERATE_TYPES_INCLUDED_SCHEMAS, GENERATE_TYPES_SWIFT_ACCESS_CONTROL, @@ -15,10 +16,13 @@ import { PG_META_PORT, POSTGREST_VERSION, } from './constants.js' -import { apply as applyTypescriptTemplate } from './templates/typescript.js' -import { apply as applyGoTemplate } from './templates/go.js' -import { apply as applySwiftTemplate } from './templates/swift.js' -import { apply as applyPythonTemplate } from './templates/python.js' +import { + generateGo, + generatePython, + generateSwift, + generateTypescript, +} from '@supabase/postgrest-typegen' +import { getGeneratorMetadata } from '../lib/generators.js' const logger = pino({ formatters: { @@ -37,115 +41,31 @@ async function getTypeOutput(): Promise { ...DEFAULT_POOL_CONFIG, connectionString: PG_CONNECTION, }) - const [ - { data: schemas, error: schemasError }, - { data: tables, error: tablesError }, - { data: foreignTables, error: foreignTablesError }, - { data: views, error: viewsError }, - { data: materializedViews, error: materializedViewsError }, - { data: columns, error: columnsError }, - { data: relationships, error: relationshipsError }, - { data: functions, error: functionsError }, - { data: types, error: typesError }, - ] = await Promise.all([ - pgMeta.schemas.list(), - pgMeta.tables.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - includeColumns: false, - }), - pgMeta.foreignTables.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - includeColumns: false, - }), - pgMeta.views.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - includeColumns: false, - }), - pgMeta.materializedViews.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - includeColumns: false, - }), - pgMeta.columns.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - }), - pgMeta.relationships.list(), - pgMeta.functions.list({ - includedSchemas: - GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, - }), - pgMeta.types.list({ - includeTableTypes: true, - includeArrayTypes: true, - includeSystemSchemas: true, - }), - ]) - await pgMeta.end() - - if (schemasError) { - throw new Error(schemasError.message) - } - if (tablesError) { - throw new Error(tablesError.message) - } - if (foreignTablesError) { - throw new Error(foreignTablesError.message) - } - if (viewsError) { - throw new Error(viewsError.message) - } - if (materializedViewsError) { - throw new Error(materializedViewsError.message) - } - if (columnsError) { - throw new Error(columnsError.message) - } - if (relationshipsError) { - throw new Error(relationshipsError.message) - } - if (functionsError) { - throw new Error(functionsError.message) - } - if (typesError) { - throw new Error(typesError.message) - } - - const config = { - schemas: schemas!.filter( - ({ name }) => - GENERATE_TYPES_INCLUDED_SCHEMAS.length === 0 || - GENERATE_TYPES_INCLUDED_SCHEMAS.includes(name) - ), - tables: tables!, - foreignTables: foreignTables!, - views: views!, - materializedViews: materializedViews!, - columns: columns!, - relationships: relationships!, - functions: functions!.filter( - ({ return_type }) => !['trigger', 'event_trigger'].includes(return_type) - ), - types: types!, - detectOneToOneRelationships: GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS, - postgrestVersion: POSTGREST_VERSION, + // `getGeneratorMetadata` introspects via @supabase/postgrest-typegen and ends + // the pool. Behavior freeze: the CLI path only supports included schemas. + const { data: generatorMetadata, error } = await getGeneratorMetadata(pgMeta, { + includedSchemas: + GENERATE_TYPES_INCLUDED_SCHEMAS.length > 0 ? GENERATE_TYPES_INCLUDED_SCHEMAS : undefined, + }) + if (error) { + throw new Error(error.message) } switch (GENERATE_TYPES?.toLowerCase()) { case 'typescript': - return await applyTypescriptTemplate(config) + return await generateTypescript(generatorMetadata!, { + detectOneToOneRelationships: GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS, + postgrestVersion: POSTGREST_VERSION, + defaultSchema: GENERATE_TYPES_DEFAULT_SCHEMA, + }) case 'swift': - return await applySwiftTemplate({ - ...config, + return generateSwift(generatorMetadata!, { accessControl: GENERATE_TYPES_SWIFT_ACCESS_CONTROL, }) case 'go': - return applyGoTemplate(config) + return generateGo(generatorMetadata!) case 'python': - return applyPythonTemplate(config) + return generatePython(generatorMetadata!) default: throw new Error(`Unsupported language for GENERATE_TYPES: ${GENERATE_TYPES}`) } diff --git a/src/server/templates/go.ts b/src/server/templates/go.ts deleted file mode 100644 index d2cf5b9dd..000000000 --- a/src/server/templates/go.ts +++ /dev/null @@ -1,330 +0,0 @@ -import type { - PostgresColumn, - PostgresMaterializedView, - PostgresSchema, - PostgresTable, - PostgresType, - PostgresView, -} from '../../lib/index.js' -import type { GeneratorMetadata } from '../../lib/generators.js' - -type Operation = 'Select' | 'Insert' | 'Update' - -export const apply = ({ - schemas, - tables, - views, - materializedViews, - columns, - types, -}: GeneratorMetadata): string => { - const columnsByTableId = columns - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - .reduce( - (acc, curr) => { - acc[curr.table_id] ??= [] - acc[curr.table_id].push(curr) - return acc - }, - {} as Record - ) - - const compositeTypes = types.filter((type) => type.attributes.length > 0) - - let output = ` -package database - -${tables - .filter((table) => schemas.some((schema) => schema.name === table.schema)) - .flatMap((table) => - generateTableStructsForOperations( - schemas.find((schema) => schema.name === table.schema)!, - table, - columnsByTableId[table.id], - types, - ['Select', 'Insert', 'Update'] - ) - ) - .join('\n\n')} - -${views - .filter((view) => schemas.some((schema) => schema.name === view.schema)) - .flatMap((view) => - generateTableStructsForOperations( - schemas.find((schema) => schema.name === view.schema)!, - view, - columnsByTableId[view.id], - types, - ['Select'] - ) - ) - .join('\n\n')} - -${materializedViews - .filter((materializedView) => schemas.some((schema) => schema.name === materializedView.schema)) - .flatMap((materializedView) => - generateTableStructsForOperations( - schemas.find((schema) => schema.name === materializedView.schema)!, - materializedView, - columnsByTableId[materializedView.id], - types, - ['Select'] - ) - ) - .join('\n\n')} - -${compositeTypes - .filter((compositeType) => schemas.some((schema) => schema.name === compositeType.schema)) - .map((compositeType) => - generateCompositeTypeStruct( - schemas.find((schema) => schema.name === compositeType.schema)!, - compositeType, - types - ) - ) - .join('\n\n')} -`.trim() - - return output -} - -/** - * Converts a Postgres name to PascalCase. - * - * @example - * ```ts - * formatForGoTypeName('pokedex') // Pokedex - * formatForGoTypeName('pokemon_center') // PokemonCenter - * formatForGoTypeName('victory-road') // VictoryRoad - * formatForGoTypeName('pokemon league') // PokemonLeague - * ``` - */ -function formatForGoTypeName(name: string): string { - return name - .split(/[^a-zA-Z0-9]/) - .map((word) => { - if (word) { - return `${word[0].toUpperCase()}${word.slice(1)}` - } else { - return '' - } - }) - .join('') -} - -function generateTableStruct( - schema: PostgresSchema, - table: PostgresTable | PostgresView | PostgresMaterializedView, - columns: PostgresColumn[] | undefined, - types: PostgresType[], - operation: Operation -): string { - // Storing columns as a tuple of [formattedName, type, name] rather than creating the string - // representation of the line allows us to pre-format the entries. Go formats - // struct fields to be aligned, e.g.: - // ```go - // type Pokemon struct { - // id int `json:"id"` - // name string `json:"name"` - // } - const columnEntries: [string, string, string][] = - columns?.map((column) => { - let nullable: boolean - if (operation === 'Insert') { - nullable = - column.is_nullable || column.is_identity || column.is_generated || !!column.default_value - } else if (operation === 'Update') { - nullable = true - } else { - nullable = column.is_nullable - } - return [ - formatForGoTypeName(column.name), - pgTypeToGoType(column.format, nullable, types), - column.name, - ] - }) ?? [] - - const [maxFormattedNameLength, maxTypeLength] = columnEntries.reduce( - ([maxFormattedName, maxType], [formattedName, type]) => { - return [Math.max(maxFormattedName, formattedName.length), Math.max(maxType, type.length)] - }, - [0, 0] - ) - - // Pad the formatted name and type to align the struct fields, then join - // create the final string representation of the struct fields. - const formattedColumnEntries = columnEntries.map(([formattedName, type, name]) => { - return ` ${formattedName.padEnd(maxFormattedNameLength)} ${type.padEnd( - maxTypeLength - )} \`json:"${name}"\`` - }) - - return ` -type ${formatForGoTypeName(schema.name)}${formatForGoTypeName(table.name)}${operation} struct { -${formattedColumnEntries.join('\n')} -} -`.trim() -} - -function generateTableStructsForOperations( - schema: PostgresSchema, - table: PostgresTable | PostgresView | PostgresMaterializedView, - columns: PostgresColumn[] | undefined, - types: PostgresType[], - operations: Operation[] -): string[] { - return operations.map((operation) => - generateTableStruct(schema, table, columns, types, operation) - ) -} - -function generateCompositeTypeStruct( - schema: PostgresSchema, - type: PostgresType, - types: PostgresType[] -): string { - // Use the type_id of the attributes to find the types of the attributes - const typeWithRetrievedAttributes = { - ...type, - attributes: type.attributes.map((attribute) => { - const type = types.find((type) => type.id === attribute.type_id) - return { - ...attribute, - type, - } - }), - } - const attributeEntries: [string, string, string][] = typeWithRetrievedAttributes.attributes.map( - (attribute) => [ - formatForGoTypeName(attribute.name), - pgTypeToGoType(attribute.type!.format, false), - attribute.name, - ] - ) - - const [maxFormattedNameLength, maxTypeLength] = attributeEntries.reduce( - ([maxFormattedName, maxType], [formattedName, type]) => { - return [Math.max(maxFormattedName, formattedName.length), Math.max(maxType, type.length)] - }, - [0, 0] - ) - - // Pad the formatted name and type to align the struct fields, then join - // create the final string representation of the struct fields. - const formattedAttributeEntries = attributeEntries.map(([formattedName, type, name]) => { - return ` ${formattedName.padEnd(maxFormattedNameLength)} ${type.padEnd( - maxTypeLength - )} \`json:"${name}"\`` - }) - - return ` -type ${formatForGoTypeName(schema.name)}${formatForGoTypeName(type.name)} struct { -${formattedAttributeEntries.join('\n')} -} -`.trim() -} - -// Note: the type map uses `interface{ } `, not `any`, to remain compatible with -// older versions of Go. -const GO_TYPE_MAP = { - // Bool - bool: 'bool', - - // Numbers - int2: 'int16', - int4: 'int32', - int8: 'int64', - float4: 'float32', - float8: 'float64', - numeric: 'float64', - - // Strings - bytea: '[]byte', - bpchar: 'string', - varchar: 'string', - date: 'string', - text: 'string', - citext: 'string', - time: 'string', - timetz: 'string', - timestamp: 'string', - timestamptz: 'string', - interval: 'string', - uuid: 'string', - vector: 'string', - - // JSON - json: 'interface{}', - jsonb: 'interface{}', - - // Range - int4range: 'string', - int4multirange: 'string', - int8range: 'string', - int8multirange: 'string', - numrange: 'string', - nummultirange: 'string', - tsrange: 'string', - tsmultirange: 'string', - tstzrange: 'string', - tstzmultirange: 'string', - daterange: 'string', - datemultirange: 'string', - - // Misc - void: 'interface{}', - record: 'map[string]interface{}', -} as const - -type GoType = (typeof GO_TYPE_MAP)[keyof typeof GO_TYPE_MAP] - -const GO_NULLABLE_TYPE_MAP: Record = { - string: '*string', - bool: '*bool', - int16: '*int16', - int32: '*int32', - int64: '*int64', - float32: '*float32', - float64: '*float64', - '[]byte': '[]byte', - 'interface{}': 'interface{}', - 'map[string]interface{}': 'map[string]interface{}', -} - -function pgTypeToGoType(pgType: string, nullable: boolean, types: PostgresType[] = []): string { - let goType: GoType | undefined = undefined - if (pgType in GO_TYPE_MAP) { - goType = GO_TYPE_MAP[pgType as keyof typeof GO_TYPE_MAP] - } - - // Enums - const enumType = types.find((type) => type.name === pgType && type.enums.length > 0) - if (enumType) { - goType = 'string' - } - - if (goType) { - if (nullable) { - return GO_NULLABLE_TYPE_MAP[goType] - } - return goType - } - - // Composite types - const compositeType = types.find((type) => type.name === pgType && type.attributes.length > 0) - if (compositeType) { - // TODO: generate composite types - // return formatForGoTypeName(pgType) - return 'map[string]interface{}' - } - - // Arrays - if (pgType.startsWith('_')) { - const innerType = pgTypeToGoType(pgType.slice(1), nullable, types) - return `[]${innerType} ` - } - - // Fallback - return 'interface{}' -} diff --git a/src/server/templates/python.ts b/src/server/templates/python.ts deleted file mode 100644 index 0d00f475b..000000000 --- a/src/server/templates/python.ts +++ /dev/null @@ -1,416 +0,0 @@ -import type { - PostgresColumn, - PostgresMaterializedView, - PostgresSchema, - PostgresTable, - PostgresType, - PostgresView, -} from '../../lib/index.js' -import type { GeneratorMetadata } from '../../lib/generators.js' - -export const apply = ({ - schemas, - tables, - views, - materializedViews, - columns, - types, -}: GeneratorMetadata): string => { - const ctx = new PythonContext(types, columns, schemas) - // Used for efficient lookup of types by schema name - const schemasNames = new Set(schemas.map((schema) => schema.name)) - const py_tables = tables.flatMap((table) => { - const py_class_and_methods = ctx.tableToClass(table) - return py_class_and_methods - }) - const composite_types = types - // We always include system schemas, so we need to filter out types that are not in the included schemas - .filter((type) => type.attributes.length > 0 && schemasNames.has(type.schema)) - .map((type) => ctx.typeToClass(type)) - const py_views = views.map((view) => ctx.viewToClass(view)) - const py_matviews = materializedViews.map((matview) => ctx.matViewToClass(matview)) - - let output = ` -from __future__ import annotations - -import datetime -import uuid -from typing import ( - Annotated, - Any, - List, - Literal, - NotRequired, - Optional, - TypeAlias, - TypedDict, -) - -from pydantic import BaseModel, Field, Json - -${concatLines(Object.values(ctx.user_enums))} - -${concatLines(py_tables)} - -${concatLines(py_views)} - -${concatLines(py_matviews)} - -${concatLines(composite_types)} - -`.trim() - - return output -} - -interface Serializable { - serialize(): string -} - -class PythonContext { - types: { [k: string]: PostgresType } - user_enums: { [k: string]: PythonEnum } - columns: Record - schemas: { [k: string]: PostgresSchema } - - constructor(types: PostgresType[], columns: PostgresColumn[], schemas: PostgresSchema[]) { - this.schemas = Object.fromEntries(schemas.map((schema) => [schema.name, schema])) - this.types = Object.fromEntries(types.map((type) => [type.name, type])) - this.columns = columns - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - .reduce( - (acc, curr) => { - acc[curr.table_id] ??= [] - acc[curr.table_id].push(curr) - return acc - }, - {} as Record - ) - this.user_enums = Object.fromEntries( - types.filter((type) => type.enums.length > 0).map((type) => [type.name, new PythonEnum(type)]) - ) - } - - resolveTypeName(name: string): string { - if (name in this.user_enums) { - return this.user_enums[name].name - } - if (name in PY_TYPE_MAP) { - return PY_TYPE_MAP[name] - } - if (name in this.types) { - const type = this.types[name] - const schema = type!.schema - return `${formatForPyClassName(schema)}${formatForPyClassName(type.name)}` - } - return 'Any' - } - - parsePgType(pg_type: string): PythonType { - if (pg_type.startsWith('_')) { - const inner_str = pg_type.slice(1) - const inner = this.parsePgType(inner_str) - return new PythonListType(inner) - } else { - const type_name = this.resolveTypeName(pg_type) - return new PythonSimpleType(type_name) - } - } - - typeToClass(type: PostgresType): PythonBaseModel { - const types = Object.values(this.types) - const attributes = type.attributes.map((attribute) => { - const type = types.find((type) => type.id === attribute.type_id) - return { - ...attribute, - type, - } - }) - const attributeEntries: PythonBaseModelAttr[] = attributes.map((attribute) => { - const type = this.parsePgType(attribute.type!.name) - return new PythonBaseModelAttr(attribute.name, type, false) - }) - - const schema = this.schemas[type.schema] - return new PythonBaseModel(type.name, schema, attributeEntries) - } - - columnsToClassAttrs(table_id: number): PythonBaseModelAttr[] { - const attrs = this.columns[table_id] ?? [] - return attrs.map((col) => { - const type = this.parsePgType(col.format) - return new PythonBaseModelAttr(col.name, type, col.is_nullable) - }) - } - - columnsToDictAttrs(table_id: number, not_required: boolean): PythonTypedDictAttr[] { - const attrs = this.columns[table_id] ?? [] - return attrs.map((col) => { - const type = this.parsePgType(col.format) - return new PythonTypedDictAttr( - col.name, - type, - col.is_nullable, - not_required || col.is_nullable || col.is_identity || col.default_value !== null - ) - }) - } - - tableToClass(table: PostgresTable): [PythonBaseModel, PythonTypedDict, PythonTypedDict] { - const schema = this.schemas[table.schema] - const select = new PythonBaseModel(table.name, schema, this.columnsToClassAttrs(table.id)) - const insert = new PythonTypedDict( - table.name, - 'Insert', - schema, - this.columnsToDictAttrs(table.id, false) - ) - const update = new PythonTypedDict( - table.name, - 'Update', - schema, - this.columnsToDictAttrs(table.id, true) - ) - return [select, insert, update] - } - - viewToClass(view: PostgresView): PythonBaseModel { - const attributes = this.columnsToClassAttrs(view.id) - return new PythonBaseModel(view.name, this.schemas[view.schema], attributes) - } - - matViewToClass(matview: PostgresMaterializedView): PythonBaseModel { - const attributes = this.columnsToClassAttrs(matview.id) - return new PythonBaseModel(matview.name, this.schemas[matview.schema], attributes) - } -} - -class PythonEnum implements Serializable { - name: string - variants: string[] - constructor(type: PostgresType) { - this.name = `${formatForPyClassName(type.schema)}${formatForPyClassName(type.name)}` - this.variants = type.enums - } - serialize(): string { - const variants = this.variants.map((item) => `"${item}"`).join(', ') - return `${this.name}: TypeAlias = Literal[${variants}]` - } -} - -type PythonType = PythonListType | PythonSimpleType - -class PythonSimpleType implements Serializable { - name: string - constructor(name: string) { - this.name = name - } - serialize(): string { - return this.name - } -} - -class PythonListType implements Serializable { - inner: PythonType - constructor(inner: PythonType) { - this.inner = inner - } - serialize(): string { - return `List[${this.inner.serialize()}]` - } -} - -class PythonBaseModelAttr implements Serializable { - name: string - pg_name: string - py_type: PythonType - nullable: boolean - - constructor(name: string, py_type: PythonType, nullable: boolean) { - this.name = formatForPyAttributeName(name) - this.pg_name = name - this.py_type = py_type - this.nullable = nullable - } - - serialize(): string { - const py_type = this.nullable - ? `Optional[${this.py_type.serialize()}]` - : this.py_type.serialize() - return ` ${this.name}: ${py_type} = Field(alias="${this.pg_name}")` - } -} - -class PythonBaseModel implements Serializable { - name: string - table_name: string - schema: PostgresSchema - class_attributes: PythonBaseModelAttr[] - - constructor(name: string, schema: PostgresSchema, class_attributes: PythonBaseModelAttr[]) { - this.schema = schema - this.class_attributes = class_attributes - this.table_name = name - this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` - } - serialize(): string { - const attributes = - this.class_attributes.length > 0 - ? this.class_attributes.map((attr) => attr.serialize()).join('\n') - : ' pass' - return `class ${this.name}(BaseModel):\n${attributes}` - } -} - -class PythonTypedDictAttr implements Serializable { - name: string - pg_name: string - py_type: PythonType - nullable: boolean - not_required: boolean - - constructor(name: string, py_type: PythonType, nullable: boolean, required: boolean) { - this.name = formatForPyAttributeName(name) - this.pg_name = name - this.py_type = py_type - this.nullable = nullable - this.not_required = required - } - - serialize(): string { - const py_type = this.nullable - ? `Optional[${this.py_type.serialize()}]` - : this.py_type.serialize() - const annotation = `Annotated[${py_type}, Field(alias="${this.pg_name}")]` - const rhs = this.not_required ? `NotRequired[${annotation}]` : annotation - return ` ${this.name}: ${rhs}` - } -} - -class PythonTypedDict implements Serializable { - name: string - table_name: string - parent_class: string - schema: PostgresSchema - dict_attributes: PythonTypedDictAttr[] - operation: 'Insert' | 'Update' - - constructor( - name: string, - operation: 'Insert' | 'Update', - schema: PostgresSchema, - dict_attributes: PythonTypedDictAttr[], - parent_class: string = 'BaseModel' - ) { - this.schema = schema - this.dict_attributes = dict_attributes - this.table_name = name - this.name = `${formatForPyClassName(schema.name)}${formatForPyClassName(name)}` - this.parent_class = parent_class - this.operation = operation - } - serialize(): string { - const attributes = - this.dict_attributes.length > 0 - ? this.dict_attributes.map((attr) => attr.serialize()).join('\n') - : ' pass' - return `class ${this.name}${this.operation}(TypedDict):\n${attributes}` - } -} - -function concatLines(items: Serializable[]): string { - return items.map((item) => item.serialize()).join('\n\n') -} - -const PY_TYPE_MAP: Record = { - // Bool - bool: 'bool', - - // Numbers - int2: 'int', - int4: 'int', - int8: 'int', - float4: 'float', - float8: 'float', - numeric: 'float', - - // Strings - bytea: 'bytes', - bpchar: 'str', - varchar: 'str', - string: 'str', - date: 'datetime.date', - text: 'str', - citext: 'str', - time: 'datetime.time', - timetz: 'datetime.time', - timestamp: 'datetime.datetime', - timestamptz: 'datetime.datetime', - uuid: 'uuid.UUID', - vector: 'list[Any]', - interval: 'str', - - // JSON - json: 'Json[Any]', - jsonb: 'Json[Any]', - - // Range types (can be adjusted to more complex types if needed) - int4range: 'str', - int4multirange: 'str', - int8range: 'str', - int8multirange: 'str', - numrange: 'str', - nummultirange: 'str', - tsrange: 'str', - tsmultirange: 'str', - tstzrange: 'str', - tstzmultirange: 'str', - daterange: 'str', - datemultirange: 'str', - - // Miscellaneous types - void: 'None', - record: 'dict[str, Any]', -} as const - -/** - * Converts a Postgres name to PascalCase. - * - * @example - * ```ts - * formatForPyTypeName('pokedex') // Pokedex - * formatForPyTypeName('pokemon_center') // PokemonCenter - * formatForPyTypeName('victory-road') // VictoryRoad - * formatForPyTypeName('pokemon league') // PokemonLeague - * ``` - */ - -function formatForPyClassName(name: string): string { - return name - .split(/[^a-zA-Z0-9]/) - .map((word) => { - if (word) { - return `${word[0].toUpperCase()}${word.slice(1)}` - } else { - return '' - } - }) - .join('') -} -/** - * Converts a Postgres name to snake_case. - * - * @example - * ```ts - * formatForPyTypeName('Pokedex') // pokedex - * formatForPyTypeName('PokemonCenter') // pokemon_enter - * formatForPyTypeName('victory-road') // victory_road - * formatForPyTypeName('pokemon league') // pokemon_league - * ``` - */ -function formatForPyAttributeName(name: string): string { - return name - .split(/[^a-zA-Z0-9]+/) // Split on non-alphanumeric characters (like spaces, dashes, etc.) - .map((word) => word.toLowerCase()) // Convert each word to lowercase - .join('_') // Join with underscores -} diff --git a/src/server/templates/swift.ts b/src/server/templates/swift.ts deleted file mode 100644 index 69aec816d..000000000 --- a/src/server/templates/swift.ts +++ /dev/null @@ -1,421 +0,0 @@ -import prettier from 'prettier' -import type { - PostgresColumn, - PostgresFunction, - PostgresMaterializedView, - PostgresSchema, - PostgresTable, - PostgresType, - PostgresView, -} from '../../lib/index.js' -import type { GeneratorMetadata } from '../../lib/generators.js' -import { PostgresForeignTable } from '../../lib/types.js' - -type Operation = 'Select' | 'Insert' | 'Update' -export type AccessControl = 'internal' | 'public' | 'private' | 'package' - -type SwiftGeneratorOptions = { - accessControl: AccessControl -} - -type SwiftEnumCase = { - formattedName: string - rawValue: string -} - -type SwiftEnum = { - formattedEnumName: string - protocolConformances: string[] - cases: SwiftEnumCase[] -} - -type SwiftAttribute = { - formattedAttributeName: string - formattedType: string - rawName: string - isIdentity: boolean -} - -type SwiftStruct = { - formattedStructName: string - protocolConformances: string[] - attributes: SwiftAttribute[] - codingKeysEnum: SwiftEnum | undefined -} - -function formatForSwiftSchemaName(schema: string): string { - return `${formatForSwiftTypeName(schema)}Schema` -} - -function pgEnumToSwiftEnum(pgEnum: PostgresType): SwiftEnum { - return { - formattedEnumName: formatForSwiftTypeName(pgEnum.name), - protocolConformances: ['String', 'Codable', 'Hashable', 'Sendable'], - cases: pgEnum.enums.map((case_) => { - return { formattedName: formatForSwiftPropertyName(case_), rawValue: case_ } - }), - } -} - -function pgTypeToSwiftStruct( - table: PostgresTable | PostgresForeignTable | PostgresView | PostgresMaterializedView, - columns: PostgresColumn[] | undefined, - operation: Operation, - { - types, - views, - tables, - }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } -): SwiftStruct { - const columnEntries: SwiftAttribute[] = - columns?.map((column) => { - let nullable: boolean - - if (operation === 'Insert') { - nullable = - column.is_nullable || column.is_identity || column.is_generated || !!column.default_value - } else if (operation === 'Update') { - nullable = true - } else { - nullable = column.is_nullable - } - - return { - rawName: column.name, - formattedAttributeName: formatForSwiftPropertyName(column.name), - formattedType: pgTypeToSwiftType(column.format, nullable, { types, views, tables }), - isIdentity: column.is_identity, - } - }) ?? [] - - return { - formattedStructName: `${formatForSwiftTypeName(table.name)}${operation}`, - attributes: columnEntries, - protocolConformances: ['Codable', 'Hashable', 'Sendable'], - codingKeysEnum: generateCodingKeysEnumFromAttributes(columnEntries), - } -} - -function generateCodingKeysEnumFromAttributes(attributes: SwiftAttribute[]): SwiftEnum | undefined { - return attributes.length > 0 - ? { - formattedEnumName: 'CodingKeys', - protocolConformances: ['String', 'CodingKey'], - cases: attributes.map((attribute) => { - return { - formattedName: attribute.formattedAttributeName, - rawValue: attribute.rawName, - } - }), - } - : undefined -} - -function pgCompositeTypeToSwiftStruct( - type: PostgresType, - { - types, - views, - tables, - }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } -): SwiftStruct { - const typeWithRetrievedAttributes = { - ...type, - attributes: type.attributes.map((attribute) => { - const type = types.find((type) => type.id === attribute.type_id) - return { - ...attribute, - type, - } - }), - } - - const attributeEntries: SwiftAttribute[] = typeWithRetrievedAttributes.attributes.map( - (attribute) => { - return { - formattedAttributeName: formatForSwiftTypeName(attribute.name), - formattedType: pgTypeToSwiftType(attribute.type!.format, false, { types, views, tables }), - rawName: attribute.name, - isIdentity: false, - } - } - ) - - return { - formattedStructName: formatForSwiftTypeName(type.name), - attributes: attributeEntries, - protocolConformances: ['Codable', 'Hashable', 'Sendable'], - codingKeysEnum: generateCodingKeysEnumFromAttributes(attributeEntries), - } -} - -function generateProtocolConformances(protocols: string[]): string { - return protocols.length === 0 ? '' : `: ${protocols.join(', ')}` -} - -function generateEnum( - enum_: SwiftEnum, - { accessControl, level }: SwiftGeneratorOptions & { level: number } -): string[] { - return [ - `${ident(level)}${accessControl} enum ${enum_.formattedEnumName}${generateProtocolConformances(enum_.protocolConformances)} {`, - ...enum_.cases.map( - (case_) => `${ident(level + 1)}case ${case_.formattedName} = "${case_.rawValue}"` - ), - `${ident(level)}}`, - ] -} - -function generateStruct( - struct: SwiftStruct, - { accessControl, level }: SwiftGeneratorOptions & { level: number } -): string[] { - const identity = struct.attributes.find((column) => column.isIdentity) - - let protocolConformances = struct.protocolConformances - if (identity) { - protocolConformances.push('Identifiable') - } - - let output = [ - `${ident(level)}${accessControl} struct ${struct.formattedStructName}${generateProtocolConformances(struct.protocolConformances)} {`, - ] - - if (identity && identity.formattedAttributeName !== 'id') { - output.push( - `${ident(level + 1)}${accessControl} var id: ${identity.formattedType} { ${identity.formattedAttributeName} }` - ) - } - - output.push( - ...struct.attributes.map( - (attribute) => - `${ident(level + 1)}${accessControl} let ${attribute.formattedAttributeName}: ${attribute.formattedType}` - ) - ) - - if (struct.codingKeysEnum) { - output.push(...generateEnum(struct.codingKeysEnum, { accessControl, level: level + 1 })) - } - - output.push(`${ident(level)}}`) - - return output -} - -export const apply = async ({ - schemas, - tables, - foreignTables, - views, - materializedViews, - columns, - types, - accessControl, -}: GeneratorMetadata & SwiftGeneratorOptions): Promise => { - const columnsByTableId = Object.fromEntries( - [...tables, ...foreignTables, ...views, ...materializedViews].map((t) => [t.id, []]) - ) - - columns - .filter((c) => c.table_id in columnsByTableId) - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - .forEach((c) => columnsByTableId[c.table_id].push(c)) - - let output = [ - 'import Foundation', - 'import Supabase', - '', - ...schemas - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - .flatMap((schema) => { - const schemaTables = [...tables, ...foreignTables] - .filter((table) => table.schema === schema.name) - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - - const schemaViews = [...views, ...materializedViews] - .filter((table) => table.schema === schema.name) - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - - const schemaEnums = types - .filter((type) => type.schema === schema.name && type.enums.length > 0) - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - - const schemaCompositeTypes = types - .filter((type) => type.schema === schema.name && type.attributes.length > 0) - .sort(({ name: a }, { name: b }) => a.localeCompare(b)) - - return [ - `${accessControl} enum ${formatForSwiftSchemaName(schema.name)} {`, - ...schemaEnums.flatMap((enum_) => - generateEnum(pgEnumToSwiftEnum(enum_), { accessControl, level: 1 }) - ), - ...schemaTables.flatMap((table) => - (['Select', 'Insert', 'Update'] as Operation[]) - .map((operation) => - pgTypeToSwiftStruct(table, columnsByTableId[table.id], operation, { - types, - views, - tables, - }) - ) - .flatMap((struct) => generateStruct(struct, { accessControl, level: 1 })) - ), - ...schemaViews.flatMap((view) => - generateStruct( - pgTypeToSwiftStruct(view, columnsByTableId[view.id], 'Select', { - types, - views, - tables, - }), - { accessControl, level: 1 } - ) - ), - ...schemaCompositeTypes.flatMap((type) => - generateStruct(pgCompositeTypeToSwiftStruct(type, { types, views, tables }), { - accessControl, - level: 1, - }) - ), - '}', - ] - }), - ] - - return output.join('\n') -} - -// TODO: Make this more robust. Currently doesn't handle range types - returns them as string. -const pgTypeToSwiftType = ( - pgType: string, - nullable: boolean, - { - types, - views, - tables, - }: { types: PostgresType[]; views: PostgresView[]; tables: PostgresTable[] } -): string => { - let swiftType: string - - if (pgType === 'bool') { - swiftType = 'Bool' - } else if (pgType === 'int2') { - swiftType = 'Int16' - } else if (pgType === 'int4') { - swiftType = 'Int32' - } else if (pgType === 'int8') { - swiftType = 'Int64' - } else if (pgType === 'float4') { - swiftType = 'Float' - } else if (pgType === 'float8') { - swiftType = 'Double' - } else if (['numeric', 'decimal'].includes(pgType)) { - swiftType = 'Decimal' - } else if (pgType === 'uuid') { - swiftType = 'UUID' - } else if ( - [ - 'bytea', - 'bpchar', - 'varchar', - 'date', - 'text', - 'citext', - 'time', - 'timetz', - 'timestamp', - 'timestamptz', - 'interval', - 'vector', - ].includes(pgType) - ) { - swiftType = 'String' - } else if (['json', 'jsonb'].includes(pgType)) { - swiftType = 'AnyJSON' - } else if (pgType === 'void') { - swiftType = 'Void' - } else if (pgType === 'record') { - swiftType = 'JSONObject' - } else if (pgType.startsWith('_')) { - swiftType = `[${pgTypeToSwiftType(pgType.substring(1), false, { types, views, tables })}]` - } else { - const enumType = types.find((type) => type.name === pgType && type.enums.length > 0) - - const compositeTypes = [...types, ...views, ...tables].find((type) => type.name === pgType) - - if (enumType) { - swiftType = `${formatForSwiftTypeName(enumType.name)}` - } else if (compositeTypes) { - // Append a `Select` to the composite type, as that is how is named in the generated struct. - swiftType = `${formatForSwiftTypeName(compositeTypes.name)}Select` - } else { - swiftType = 'AnyJSON' - } - } - - return `${swiftType}${nullable ? '?' : ''}` -} - -function ident(level: number, options: { width: number } = { width: 2 }): string { - return ' '.repeat(level * options.width) -} - -/** - * Converts a Postgres name to PascalCase. - * - * @example - * ```ts - * formatForSwiftTypeName('pokedex') // Pokedex - * formatForSwiftTypeName('pokemon_center') // PokemonCenter - * formatForSwiftTypeName('victory-road') // VictoryRoad - * formatForSwiftTypeName('pokemon league') // PokemonLeague - * formatForSwiftTypeName('_key_id_context') // _KeyIdContext - * ``` - */ -function formatForSwiftTypeName(name: string): string { - // Preserve the initial underscore if it exists - let prefix = '' - if (name.startsWith('_')) { - prefix = '_' - name = name.slice(1) // Remove the initial underscore for processing - } - - return ( - prefix + - name - .split(/[^a-zA-Z0-9]+/) - .map((word) => { - if (word) { - return `${word[0].toUpperCase()}${word.slice(1)}` - } else { - return '' - } - }) - .join('') - ) -} - -const SWIFT_KEYWORDS = ['in', 'default', 'case'] - -/** - * Converts a Postgres name to pascalCase. - * - * @example - * ```ts - * formatForSwiftTypeName('pokedex') // pokedex - * formatForSwiftTypeName('pokemon_center') // pokemonCenter - * formatForSwiftTypeName('victory-road') // victoryRoad - * formatForSwiftTypeName('pokemon league') // pokemonLeague - * ``` - */ -function formatForSwiftPropertyName(name: string): string { - const propertyName = name - .split(/[^a-zA-Z0-9]/) - .map((word, index) => { - const lowerWord = word.toLowerCase() - return index !== 0 ? lowerWord.charAt(0).toUpperCase() + lowerWord.slice(1) : lowerWord - }) - .join('') - - return SWIFT_KEYWORDS.includes(propertyName) ? `\`${propertyName}\`` : propertyName -} diff --git a/src/server/templates/typescript.ts b/src/server/templates/typescript.ts deleted file mode 100644 index 352c4ddc3..000000000 --- a/src/server/templates/typescript.ts +++ /dev/null @@ -1,974 +0,0 @@ -import prettier from 'prettier' -import type { GeneratorMetadata } from '../../lib/generators.js' -import type { - PostgresColumn, - PostgresFunction, - PostgresSchema, - PostgresTable, - PostgresType, - PostgresView, -} from '../../lib/index.js' -import { - GENERATE_TYPES_DEFAULT_SCHEMA, - VALID_FUNCTION_ARGS_MODE, - VALID_UNNAMED_FUNCTION_ARG_TYPES, -} from '../constants.js' - -type TsRelationship = Pick< - GeneratorMetadata['relationships'][number], - 'foreign_key_name' | 'columns' | 'is_one_to_one' | 'referenced_relation' | 'referenced_columns' -> - -export const apply = async ({ - schemas, - tables, - foreignTables, - views, - materializedViews, - columns, - relationships, - functions, - types, - detectOneToOneRelationships, - postgrestVersion, -}: GeneratorMetadata & { - detectOneToOneRelationships: boolean - postgrestVersion?: string -}): Promise => { - schemas.sort((a, b) => a.name.localeCompare(b.name)) - relationships.sort( - (a, b) => - a.foreign_key_name.localeCompare(b.foreign_key_name) || - a.referenced_relation.localeCompare(b.referenced_relation) || - JSON.stringify(a.referenced_columns).localeCompare(JSON.stringify(b.referenced_columns)) - ) - const introspectionBySchema = Object.fromEntries<{ - tables: { - table: Pick - relationships: TsRelationship[] - }[] - views: { - view: PostgresView - relationships: TsRelationship[] - }[] - functions: { fn: PostgresFunction; inArgs: PostgresFunction['args'] }[] - enums: PostgresType[] - compositeTypes: PostgresType[] - }>( - schemas.map((s) => [ - s.name, - { tables: [], views: [], functions: [], enums: [], compositeTypes: [] }, - ]) - ) - const columnsByTableId: Record = {} - const tablesNamesByTableId: Record = {} - const relationTypeByIds = new Map() - // group types by id for quicker lookup - const typesById = new Map() - const tablesLike = [...tables, ...foreignTables, ...views, ...materializedViews] - - for (const tableLike of tablesLike) { - columnsByTableId[tableLike.id] = [] - tablesNamesByTableId[tableLike.id] = tableLike.name - } - for (const column of columns) { - if (column.table_id in columnsByTableId) { - columnsByTableId[column.table_id].push(column) - } - } - for (const tableId in columnsByTableId) { - columnsByTableId[tableId].sort((a, b) => a.name.localeCompare(b.name)) - } - - for (const type of types) { - typesById.set(type.id, type) - // Save all the types that are relation types for quicker lookup - if (type.type_relation_id) { - relationTypeByIds.set(type.id, type) - } - if (type.schema in introspectionBySchema) { - if (type.enums.length > 0) { - introspectionBySchema[type.schema].enums.push(type) - } - if (type.attributes.length > 0) { - introspectionBySchema[type.schema].compositeTypes.push(type) - } - } - } - - function getRelationships( - object: { schema: string; name: string }, - relationships: GeneratorMetadata['relationships'] - ): Pick< - GeneratorMetadata['relationships'][number], - 'foreign_key_name' | 'columns' | 'is_one_to_one' | 'referenced_relation' | 'referenced_columns' - >[] { - return relationships.filter( - (relationship) => - relationship.schema === object.schema && - relationship.referenced_schema === object.schema && - relationship.relation === object.name - ) - } - - function generateRelationshiptTsDefinition(relationship: TsRelationship): string { - return `{ - foreignKeyName: ${JSON.stringify(relationship.foreign_key_name)} - columns: ${JSON.stringify(relationship.columns)}${detectOneToOneRelationships ? `\nisOneToOne: ${relationship.is_one_to_one}` : ''} - referencedRelation: ${JSON.stringify(relationship.referenced_relation)} - referencedColumns: ${JSON.stringify(relationship.referenced_columns)} - }` - } - - for (const table of tables) { - if (table.schema in introspectionBySchema) { - introspectionBySchema[table.schema].tables.push({ - table, - relationships: getRelationships(table, relationships), - }) - } - } - for (const table of foreignTables) { - if (table.schema in introspectionBySchema) { - introspectionBySchema[table.schema].tables.push({ - table, - relationships: getRelationships(table, relationships), - }) - } - } - for (const view of views) { - if (view.schema in introspectionBySchema) { - introspectionBySchema[view.schema].views.push({ - view, - relationships: getRelationships(view, relationships), - }) - } - } - for (const materializedView of materializedViews) { - if (materializedView.schema in introspectionBySchema) { - introspectionBySchema[materializedView.schema].views.push({ - view: { - ...materializedView, - is_updatable: false, - }, - relationships: getRelationships(materializedView, relationships), - }) - } - } - // Helper function to get table/view name from relation id - const getTableNameFromRelationId = ( - relationId: number | null, - returnTypeId: number | null - ): string | null => { - if (!relationId) return null - - if (tablesNamesByTableId[relationId]) return tablesNamesByTableId[relationId] - // if it's a composite type we use the type name as relation name to allow sub-selecting fields of the composite type - const reltype = returnTypeId ? relationTypeByIds.get(returnTypeId) : null - return reltype ? reltype.name : null - } - - for (const func of functions) { - if (func.schema in introspectionBySchema) { - func.args.sort((a, b) => a.name.localeCompare(b.name)) - // Get all input args (in, inout, variadic modes) - const inArgs = func.args.filter(({ mode }) => VALID_FUNCTION_ARGS_MODE.has(mode)) - - if ( - // Case 1: Function has no parameters - inArgs.length === 0 || - // Case 2: All input args are named - !inArgs.some(({ name }) => name === '') || - // Case 3: All unnamed args have default values AND are valid types - inArgs.every((arg) => { - if (arg.name === '') { - return arg.has_default && VALID_UNNAMED_FUNCTION_ARG_TYPES.has(arg.type_id) - } - return true - }) || - // Case 4: Single unnamed parameter of valid type (json, jsonb, text) - // Exclude all functions definitions that have only one single argument unnamed argument that isn't - // a json/jsonb/text as it won't be considered by PostgREST - (inArgs.length === 1 && - inArgs[0].name === '' && - (VALID_UNNAMED_FUNCTION_ARG_TYPES.has(inArgs[0].type_id) || - // OR if the function have a single unnamed args which is another table (embeded function) - (relationTypeByIds.get(inArgs[0].type_id) && - getTableNameFromRelationId(func.return_type_relation_id, func.return_type_id)) || - // OR if the function takes a table row but doesn't qualify as embedded (for error reporting) - (relationTypeByIds.get(inArgs[0].type_id) && - !getTableNameFromRelationId(func.return_type_relation_id, func.return_type_id)))) - ) { - introspectionBySchema[func.schema].functions.push({ fn: func, inArgs }) - } - } - } - for (const schema in introspectionBySchema) { - introspectionBySchema[schema].tables.sort((a, b) => a.table.name.localeCompare(b.table.name)) - introspectionBySchema[schema].views.sort((a, b) => a.view.name.localeCompare(b.view.name)) - introspectionBySchema[schema].functions.sort((a, b) => a.fn.name.localeCompare(b.fn.name)) - introspectionBySchema[schema].enums.sort((a, b) => a.name.localeCompare(b.name)) - introspectionBySchema[schema].compositeTypes.sort((a, b) => a.name.localeCompare(b.name)) - } - - const getFunctionTsReturnType = (fn: PostgresFunction, returnType: string) => { - // Determine if this function should have SetofOptions - let setofOptionsInfo = '' - - const returnTableName = getTableNameFromRelationId( - fn.return_type_relation_id, - fn.return_type_id - ) - const returnsSetOfTable = fn.is_set_returning_function && fn.return_type_relation_id !== null - const returnsMultipleRows = fn.prorows !== null && fn.prorows > 1 - // Case 1: if the function returns a table, we need to add SetofOptions to allow selecting sub fields of the table - // Those can be used in rpc to select sub fields of a table - if (returnTableName) { - setofOptionsInfo = `SetofOptions: { - from: "*" - to: ${JSON.stringify(returnTableName)} - isOneToOne: ${Boolean(!returnsMultipleRows)} - isSetofReturn: ${fn.is_set_returning_function} - }` - } - // Case 2: if the function has a single table argument, we need to add SetofOptions to allow selecting sub fields of the table - // and set the right "from" and "to" values to allow selecting from a table row - if (fn.args.length === 1) { - const relationType = relationTypeByIds.get(fn.args[0].type_id) - - // Only add SetofOptions for functions with table arguments (embedded functions) - // or specific functions that RETURNS table-name - if (relationType) { - const sourceTable = relationType.format - // Case 1: Standard embedded function with proper setof detection - if (returnsSetOfTable && returnTableName) { - setofOptionsInfo = `SetofOptions: { - from: ${JSON.stringify(sourceTable)} - to: ${JSON.stringify(returnTableName)} - isOneToOne: ${Boolean(!returnsMultipleRows)} - isSetofReturn: true - }` - } - // Case 2: Handle RETURNS table-name those are always a one to one relationship - else if (returnTableName && !returnsSetOfTable) { - const targetTable = returnTableName - setofOptionsInfo = `SetofOptions: { - from: ${JSON.stringify(sourceTable)} - to: ${JSON.stringify(targetTable)} - isOneToOne: true - isSetofReturn: false - }` - } - } - } - - return `${returnType}${fn.is_set_returning_function && returnsMultipleRows ? '[]' : ''} - ${setofOptionsInfo ? `${setofOptionsInfo}` : ''}` - } - - const getFunctionReturnType = (schema: PostgresSchema, fn: PostgresFunction): string => { - // Case 1: `returns table`. - const tableArgs = fn.args.filter(({ mode }) => mode === 'table') - if (tableArgs.length > 0) { - const argsNameAndType = tableArgs.map(({ name, type_id }) => { - const type = typesById.get(type_id) - let tsType = 'unknown' - if (type) { - tsType = pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }) - } - return { name, type: tsType } - }) - - return `{ - ${argsNameAndType.map(({ name, type }) => `${JSON.stringify(name)}: ${type}`)} - }` - } - - // Case 2: returns a relation's row type. - const relation = - introspectionBySchema[schema.name]?.tables.find( - ({ table: { id } }) => id === fn.return_type_relation_id - )?.table || - introspectionBySchema[schema.name]?.views.find( - ({ view: { id } }) => id === fn.return_type_relation_id - )?.view - if (relation) { - return `{ - ${columnsByTableId[relation.id] - .map((column) => - generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: column.is_nullable, - is_optional: false, - }, - { - types, - schemas, - tables, - views, - } - ) - ) - .join(',\n')} - }` - } - - // Case 3: returns base/array/composite/enum type. - const type = typesById.get(fn.return_type_id) - if (type) { - return pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }) - } - - return 'unknown' - } - // Special error case for functions that take table row but don't qualify as embedded functions - const hasTableRowError = (fn: PostgresFunction, inArgs: PostgresFunction['args']) => { - if ( - inArgs.length === 1 && - inArgs[0].name === '' && - relationTypeByIds.get(inArgs[0].type_id) && - !getTableNameFromRelationId(fn.return_type_relation_id, fn.return_type_id) - ) { - return true - } - return false - } - - // Check for generic conflict cases that need error reporting - const getConflictError = ( - schema: PostgresSchema, - fns: Array<{ fn: PostgresFunction; inArgs: PostgresFunction['args'] }>, - fn: PostgresFunction, - inArgs: PostgresFunction['args'] - ) => { - // If there is a single function definition, there is no conflict - if (fns.length <= 1) return null - - // Generic conflict detection patterns - // Pattern 1: No-args vs default-args conflicts - if (inArgs.length === 0) { - const conflictingFns = fns.filter(({ fn: otherFn, inArgs: otherInArgs }) => { - if (otherFn === fn) return false - return otherInArgs.length === 1 && otherInArgs[0].name === '' && otherInArgs[0].has_default - }) - - if (conflictingFns.length > 0) { - const conflictingFn = conflictingFns[0] - const returnTypeName = typesById.get(conflictingFn.fn.return_type_id)?.name || 'unknown' - return `Could not choose the best candidate function between: ${schema.name}.${fn.name}(), ${schema.name}.${fn.name}( => ${returnTypeName}). Try renaming the parameters or the function itself in the database so function overloading can be resolved` - } - } - - // Pattern 2: Same parameter name but different types (unresolvable overloads) - if (inArgs.length === 1 && inArgs[0].name !== '') { - const conflictingFns = fns.filter(({ fn: otherFn, inArgs: otherInArgs }) => { - if (otherFn === fn) return false - return ( - otherInArgs.length === 1 && - otherInArgs[0].name === inArgs[0].name && - otherInArgs[0].type_id !== inArgs[0].type_id - ) - }) - - if (conflictingFns.length > 0) { - const allConflictingFunctions = [{ fn, inArgs }, ...conflictingFns] - const conflictList = allConflictingFunctions - .sort((a, b) => { - const aArgs = a.inArgs - const bArgs = b.inArgs - return (aArgs[0]?.type_id || 0) - (bArgs[0]?.type_id || 0) - }) - .map((f) => { - const args = f.inArgs - return `${schema.name}.${fn.name}(${args.map((a) => `${a.name || ''} => ${typesById.get(a.type_id)?.name || 'unknown'}`).join(', ')})` - }) - .join(', ') - - return `Could not choose the best candidate function between: ${conflictList}. Try renaming the parameters or the function itself in the database so function overloading can be resolved` - } - } - - return null - } - - const getFunctionSignatures = ( - schema: PostgresSchema, - fns: Array<{ fn: PostgresFunction; inArgs: PostgresFunction['args'] }> - ) => { - return fns - .map(({ fn, inArgs }) => { - let argsType = 'never' - let returnType = getFunctionReturnType(schema, fn) - - // Check for specific error cases - const conflictError = getConflictError(schema, fns, fn, inArgs) - if (conflictError) { - if (inArgs.length > 0) { - const argsNameAndType = inArgs.map(({ name, type_id, has_default }) => { - const type = typesById.get(type_id) - let tsType = 'unknown' - if (type) { - tsType = pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }) - } - return { name, type: tsType, has_default } - }) - argsType = `{ ${argsNameAndType.map(({ name, type, has_default }) => `${JSON.stringify(name)}${has_default ? '?' : ''}: ${type}`)} }` - } - returnType = `{ error: true } & ${JSON.stringify(conflictError)}` - } else if (hasTableRowError(fn, inArgs)) { - // Special case for computed fields returning scalars functions - if (inArgs.length > 0) { - const argsNameAndType = inArgs.map(({ name, type_id, has_default }) => { - const type = typesById.get(type_id) - let tsType = 'unknown' - if (type) { - tsType = pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }) - } - return { name, type: tsType, has_default } - }) - argsType = `{ ${argsNameAndType.map(({ name, type, has_default }) => `${JSON.stringify(name)}${has_default ? '?' : ''}: ${type}`)} }` - } - returnType = `{ error: true } & ${JSON.stringify(`the function ${schema.name}.${fn.name} with parameter or with a single unnamed json/jsonb parameter, but no matches were found in the schema cache`)}` - } else if (inArgs.length > 0) { - const argsNameAndType = inArgs.map(({ name, type_id, has_default }) => { - const type = typesById.get(type_id) - let tsType = 'unknown' - if (type) { - tsType = pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }) - } - return { name, type: tsType, has_default } - }) - argsType = `{ ${argsNameAndType.map(({ name, type, has_default }) => `${JSON.stringify(name)}${has_default ? '?' : ''}: ${type}`)} }` - } - - return `{ Args: ${argsType}; Returns: ${getFunctionTsReturnType(fn, returnType)} }` - }) - .join(' |\n') - } - - const internal_supabase_schema = postgrestVersion - ? `// Allows to automatically instantiate createClient with right options - // instead of createClient(URL, KEY) - __InternalSupabase: { - PostgrestVersion: '${postgrestVersion}' - }` - : '' - - function generateNullableUnionTsType(tsType: string, isNullable: boolean) { - // Only add the null union if the type is not unknown as unknown already includes null - if (tsType === 'unknown' || tsType === 'any' || !isNullable) { - return tsType - } - return `${tsType} | null` - } - - function generateColumnTsDefinition( - schema: PostgresSchema, - column: { - name: string - format: string - is_nullable: boolean - is_optional: boolean - }, - context: { - types: PostgresType[] - schemas: PostgresSchema[] - tables: PostgresTable[] - views: PostgresView[] - } - ) { - return `${JSON.stringify(column.name)}${column.is_optional ? '?' : ''}: ${generateNullableUnionTsType(pgTypeToTsType(schema, column.format, context), column.is_nullable)}` - } - - let output = ` -export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[] - -export type Database = { - ${internal_supabase_schema} - ${schemas.map((schema) => { - const { - tables: schemaTables, - views: schemaViews, - functions: schemaFunctions, - enums: schemaEnums, - compositeTypes: schemaCompositeTypes, - } = introspectionBySchema[schema.name] - return `${JSON.stringify(schema.name)}: { - Tables: { - ${ - schemaTables.length === 0 - ? '[_ in never]: never' - : schemaTables.map( - ({ table, relationships }) => `${JSON.stringify(table.name)}: { - Row: { - ${[ - ...columnsByTableId[table.id].map((column) => - generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: column.is_nullable, - is_optional: false, - }, - { types, schemas, tables, views } - ) - ), - ...schemaFunctions - .filter(({ fn }) => fn.argument_types === table.name) - .map(({ fn }) => { - return `${JSON.stringify(fn.name)}: ${generateNullableUnionTsType(getFunctionReturnType(schema, fn), true)}` - }), - ]} - } - Insert: { - ${columnsByTableId[table.id].map((column) => { - if (column.identity_generation === 'ALWAYS') { - return `${JSON.stringify(column.name)}?: never` - } - return generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: column.is_nullable, - is_optional: - column.is_nullable || - column.is_identity || - column.default_value !== null, - }, - { types, schemas, tables, views } - ) - })} - } - Update: { - ${columnsByTableId[table.id].map((column) => { - if (column.identity_generation === 'ALWAYS') { - return `${JSON.stringify(column.name)}?: never` - } - - return generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: column.is_nullable, - is_optional: true, - }, - { types, schemas, tables, views } - ) - })} - } - Relationships: [ - ${relationships.map(generateRelationshiptTsDefinition)} - ] - }` - ) - } - } - Views: { - ${ - schemaViews.length === 0 - ? '[_ in never]: never' - : schemaViews.map( - ({ view, relationships }) => `${JSON.stringify(view.name)}: { - Row: { - ${[ - ...columnsByTableId[view.id].map((column) => - generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: column.is_nullable, - is_optional: false, - }, - { types, schemas, tables, views } - ) - ), - ...schemaFunctions - .filter(({ fn }) => fn.argument_types === view.name) - .map( - ({ fn }) => - `${JSON.stringify(fn.name)}: ${generateNullableUnionTsType(getFunctionReturnType(schema, fn), true)}` - ), - ]} - } - ${ - view.is_updatable - ? `Insert: { - ${columnsByTableId[view.id].map((column) => { - if (!column.is_updatable) { - return `${JSON.stringify(column.name)}?: never` - } - return generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: true, - is_optional: true, - }, - { types, schemas, tables, views } - ) - })} - } - Update: { - ${columnsByTableId[view.id].map((column) => { - if (!column.is_updatable) { - return `${JSON.stringify(column.name)}?: never` - } - return generateColumnTsDefinition( - schema, - { - name: column.name, - format: column.format, - is_nullable: true, - is_optional: true, - }, - { types, schemas, tables, views } - ) - })} - } - ` - : '' - }Relationships: [ - ${relationships.map(generateRelationshiptTsDefinition)} - ] - }` - ) - } - } - Functions: { - ${(() => { - if (schemaFunctions.length === 0) { - return '[_ in never]: never' - } - const schemaFunctionsGroupedByName = schemaFunctions.reduce( - (acc, curr) => { - acc[curr.fn.name] ??= [] - acc[curr.fn.name].push(curr) - return acc - }, - {} as Record - ) - for (const fnName in schemaFunctionsGroupedByName) { - schemaFunctionsGroupedByName[fnName].sort( - (a, b) => - a.fn.argument_types.localeCompare(b.fn.argument_types) || - a.fn.return_type.localeCompare(b.fn.return_type) - ) - } - - return Object.entries(schemaFunctionsGroupedByName) - .map(([fnName, fns]) => { - const functionSignatures = getFunctionSignatures(schema, fns) - return `${JSON.stringify(fnName)}:\n${functionSignatures}` - }) - .join(',\n') - })()} - } - Enums: { - ${ - schemaEnums.length === 0 - ? '[_ in never]: never' - : schemaEnums.map( - (enum_) => - `${JSON.stringify(enum_.name)}: ${enum_.enums - .map((variant) => JSON.stringify(variant)) - .join('|')}` - ) - } - } - CompositeTypes: { - ${ - schemaCompositeTypes.length === 0 - ? '[_ in never]: never' - : schemaCompositeTypes.map( - ({ name, attributes }) => - `${JSON.stringify(name)}: { - ${attributes.map(({ name, type_id }) => { - const type = typesById.get(type_id) - let tsType = 'unknown' - if (type) { - tsType = `${generateNullableUnionTsType( - pgTypeToTsType(schema, type.name, { - types, - schemas, - tables, - views, - }), - true - )}` - } - return `${JSON.stringify(name)}: ${tsType}` - })} - }` - ) - } - } - }` - })} -} - -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 = { - ${schemas.map((schema) => { - const schemaEnums = introspectionBySchema[schema.name].enums - return `${JSON.stringify(schema.name)}: { - Enums: { - ${schemaEnums.map( - (enum_) => - `${JSON.stringify(enum_.name)}: [${enum_.enums - .map((variant) => JSON.stringify(variant)) - .join(', ')}]` - )} - } - }` - })} -} as const -` - - output = await prettier.format(output, { - parser: 'typescript', - semi: false, - }) - return output -} - -// TODO: Make this more robust. Currently doesn't handle range types - returns them as unknown. -export const pgTypeToTsType = ( - schema: PostgresSchema, - pgType: string, - { - types, - schemas, - tables, - views, - }: { - types: PostgresType[] - schemas: PostgresSchema[] - tables: PostgresTable[] - views: PostgresView[] - } -): string => { - if (pgType === 'bool') { - return 'boolean' - } else if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric'].includes(pgType)) { - return 'number' - } else if ( - [ - 'bytea', - 'bpchar', - 'varchar', - 'date', - 'text', - 'citext', - 'time', - 'timetz', - 'timestamp', - 'timestamptz', - 'uuid', - 'vector', - 'interval', - ].includes(pgType) - ) { - return 'string' - } else if (['json', 'jsonb'].includes(pgType)) { - return 'Json' - } else if (pgType === 'void') { - return 'undefined' - } else if (pgType === 'record') { - return 'Record' - } else if (pgType.startsWith('_')) { - return `(${pgTypeToTsType(schema, pgType.substring(1), { - types, - schemas, - tables, - views, - })})[]` - } else { - const enumTypes = types.filter((type) => type.name === pgType && type.enums.length > 0) - if (enumTypes.length > 0) { - const enumType = enumTypes.find((type) => type.schema === schema.name) || enumTypes[0] - if (schemas.some(({ name }) => name === enumType.schema)) { - return `Database[${JSON.stringify(enumType.schema)}]['Enums'][${JSON.stringify( - enumType.name - )}]` - } - return enumType.enums.map((variant) => JSON.stringify(variant)).join('|') - } - - const compositeTypes = types.filter( - (type) => type.name === pgType && type.attributes.length > 0 - ) - if (compositeTypes.length > 0) { - const compositeType = - compositeTypes.find((type) => type.schema === schema.name) || compositeTypes[0] - if (schemas.some(({ name }) => name === compositeType.schema)) { - return `Database[${JSON.stringify( - compositeType.schema - )}]['CompositeTypes'][${JSON.stringify(compositeType.name)}]` - } - return 'unknown' - } - - const tableRowTypes = tables.filter((table) => table.name === pgType) - if (tableRowTypes.length > 0) { - const tableRowType = - tableRowTypes.find((type) => type.schema === schema.name) || tableRowTypes[0] - if (schemas.some(({ name }) => name === tableRowType.schema)) { - return `Database[${JSON.stringify(tableRowType.schema)}]['Tables'][${JSON.stringify( - tableRowType.name - )}]['Row']` - } - return 'unknown' - } - - const viewRowTypes = views.filter((view) => view.name === pgType) - if (viewRowTypes.length > 0) { - const viewRowType = - viewRowTypes.find((type) => type.schema === schema.name) || viewRowTypes[0] - if (schemas.some(({ name }) => name === viewRowType.schema)) { - return `Database[${JSON.stringify(viewRowType.schema)}]['Views'][${JSON.stringify( - viewRowType.name - )}]['Row']` - } - return 'unknown' - } - - return 'unknown' - } -} diff --git a/test/server/templates/go.test.ts b/test/server/templates/go.test.ts deleted file mode 100644 index f1be6b500..000000000 --- a/test/server/templates/go.test.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { describe, expect, test } from 'vitest' - -import { apply } from '../../../src/server/templates/go' -import type { GeneratorMetadata } from '../../../src/lib/generators' -import type { - PostgresColumn, - PostgresSchema, - PostgresTable, - PostgresType, -} from '../../../src/lib/types' - -const baseSchema: PostgresSchema = { - id: 1, - name: 'public', - owner: 'postgres', -} - -const baseTable = { - id: 1, - schema: 'public', - name: 'tickets', - rls_enabled: false, - rls_forced: false, - replica_identity: 'DEFAULT', - bytes: 0, - size: '0 bytes', - live_rows_estimate: 0, - dead_rows_estimate: 0, - comment: null, - primary_keys: [], - relationships: [], -} as unknown as Omit - -const userStatusEnum: PostgresType = { - id: 100, - name: 'user_status', - schema: 'public', - format: 'user_status', - enums: ['ACTIVE', 'INACTIVE'], - attributes: [], - comment: null, - type_relation_id: null, -} - -const baseColumn = (overrides: Partial): PostgresColumn => - ({ - table_id: 1, - schema: 'public', - table: 'tickets', - id: '1.1', - ordinal_position: 1, - name: 'col', - default_value: null, - data_type: 'text', - format: 'text', - is_identity: false, - identity_generation: null, - is_generated: false, - is_nullable: false, - is_updatable: true, - is_unique: false, - enums: [], - check: null, - comment: null, - ...overrides, - }) as PostgresColumn - -const buildMetadata = (columns: PostgresColumn[]): GeneratorMetadata => ({ - schemas: [baseSchema], - tables: [baseTable], - foreignTables: [], - views: [], - materializedViews: [], - columns, - relationships: [], - functions: [], - types: [userStatusEnum], -}) - -describe('go typegen pgTypeToGoType array fallback', () => { - test('non-nullable array of enum resolves to []string, not []interface{}', () => { - const result = apply( - buildMetadata([baseColumn({ name: 'tags', format: '_user_status', is_nullable: false })]) - ) - - expect(result).toMatch(/Tags\s+\[]string\b/) - expect(result).not.toMatch(/Tags\s+\[]interface\{\}/) - }) - - test('nullable array of enum resolves to []*string, not []interface{}', () => { - const result = apply( - buildMetadata([baseColumn({ name: 'tags', format: '_user_status', is_nullable: true })]) - ) - - expect(result).toMatch(/Tags\s+\[]\*string\b/) - expect(result).not.toMatch(/Tags\s+\[]interface\{\}/) - }) - - test('plain text array still resolves to []string', () => { - const result = apply( - buildMetadata([baseColumn({ name: 'tags', format: '_text', is_nullable: false })]) - ) - - expect(result).toMatch(/Tags\s+\[]string\b/) - }) -}) diff --git a/test/types.test.ts b/test/types.test.ts index 8a213902c..961d9e7ff 100644 --- a/test/types.test.ts +++ b/test/types.test.ts @@ -1,7 +1,7 @@ import { expect, test, describe } from 'vitest' import { build } from '../src/server/app.js' import { TEST_CONNECTION_STRING } from './lib/utils.js' -import { pgTypeToTsType } from '../src/server/templates/typescript' +import { pgTypeToTsType } from '@supabase/postgrest-typegen' describe('server/routes/types', () => { test('should list types', async () => { From 3809e61b6ca69828bb02e3ef38193c6eb872ae03 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 09:51:20 +0000 Subject: [PATCH 02/13] chore: point @supabase/postgrest-typegen at pkg-pr-new preview build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package isn't published to npm yet; pin the dependency to the pkg.pr.new preview build for pg-toolbelt PR #302 so CI can install it. The lockfile must be regenerated (`npm install`) in an environment with network access to pkg.pr.new — the remote sandbox's egress allowlist blocks that host. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01B53a2ayu9d16u821FQ5Qqz --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 73f1f91b4..991027659 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "1.0.0-alpha.1", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", From 38f46512248f0f441102e25a0caef43243a72312 Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 20 Jun 2026 13:26:04 +0200 Subject: [PATCH 03/13] chore: update lockfile for postgrest-typegen pkg-pr-new build --- package-lock.json | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/package-lock.json b/package-lock.json index 220f26251..6ab0e8dd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", @@ -63,6 +64,21 @@ "node": ">=6.0.0" } }, + "node_modules/@ark/schema": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz", + "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.0" + } + }, + "node_modules/@ark/util": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz", + "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", + "license": "MIT" + }, "node_modules/@babel/helper-string-parser": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", @@ -1875,6 +1891,20 @@ "integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==", "license": "MIT" }, + "node_modules/@supabase/postgrest-typegen": { + "version": "1.0.0-alpha.1", + "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", + "integrity": "sha512-5Qdv0ePAocVhPtBWijkpfnd0UsBC/hCrgRSP31YmeMzXpM3yIW7szltpVlr2CT6RC6fKCT62FRh27BxtLU7z/Q==", + "license": "MIT", + "dependencies": { + "arktype": "2.2.1", + "pg-format": "1.0.4", + "prettier": "3.5.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", @@ -2271,6 +2301,26 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/arkregex": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.6.tgz", + "integrity": "sha512-9mvuMKQuibfWhBrsNYhsKhNb6k9oEHoAJ/FvDiqe8h+E9Siwe0/cro1WVOGgpajXQ9ZHd24yCOf2k35Q/QqUQw==", + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.0" + } + }, + "node_modules/arktype": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.1.tgz", + "integrity": "sha512-CWPJxNoSxrS+NYGB3ufwc/blFonESEW5vBQyYPVS0rf4STu8VWoAWfKJSl5vVVm56h4yxpwbODeYwy6XFKvojA==", + "license": "MIT", + "dependencies": { + "@ark/schema": "0.56.0", + "@ark/util": "0.56.0", + "arkregex": "0.0.6" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", From 1a669e6312c5a11419275de3a44261453d25fa8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 12:04:26 +0000 Subject: [PATCH 04/13] fix: sort generator metadata for deterministic type generation The Go/Python/Swift generators emit objects in GeneratorMetadata order, so output depended on the order introspection returned rows (environment-dependent heap order). Apply the package's new sortGeneratorMetadata pass in the getGeneratorMetadata adapter so all four generators receive canonically-ordered metadata. Regenerate the typegen go/python snapshots accordingly: only ordering changes (the `a_view` view moves to its canonical oid position); struct/class contents are byte-identical. TypeScript and Swift sort internally and are unaffected. Requires @supabase/postgrest-typegen with sortGeneratorMetadata (supabase/pg-toolbelt#302). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01B53a2ayu9d16u821FQ5Qqz --- src/lib/generators.ts | 19 ++++++++++++++----- test/server/typegen.ts | 14 +++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/src/lib/generators.ts b/src/lib/generators.ts index e829d37e7..8030de70b 100644 --- a/src/lib/generators.ts +++ b/src/lib/generators.ts @@ -1,4 +1,9 @@ -import { introspect, type GeneratorMetadata, type Queryable } from '@supabase/postgrest-typegen' +import { + introspect, + sortGeneratorMetadata, + type GeneratorMetadata, + type Queryable, +} from '@supabase/postgrest-typegen' import PostgresMeta from './PostgresMeta.js' import { PostgresMetaResult } from './types.js' @@ -33,10 +38,14 @@ export async function getGeneratorMetadata( } try { - const data = await introspect(queryable, { - includedSchemas: filters.includedSchemas, - excludedSchemas: filters.excludedSchemas, - }) + // The generators emit objects in metadata order, so apply the package's + // canonical sort pass before returning (and before any generator runs). + const data = sortGeneratorMetadata( + await introspect(queryable, { + includedSchemas: filters.includedSchemas, + excludedSchemas: filters.excludedSchemas, + }) + ) return { data, error: null } } catch (error) { return { diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 50a0896bf..acef48be5 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -5511,10 +5511,6 @@ test('typegen: go', async () => { Status *string \`json:"status"\` } - type PublicAViewSelect struct { - Id *int64 \`json:"id"\` - } - type PublicTodosViewSelect struct { Details *string \`json:"details"\` Id *int64 \`json:"id"\` @@ -5537,6 +5533,10 @@ test('typegen: go', async () => { UserStatus *string \`json:"user_status"\` } + type PublicAViewSelect struct { + Id *int64 \`json:"id"\` + } + type PublicUsersViewWithMultipleRefsToUsersSelect struct { InitialId *int64 \`json:"initial_id"\` InitialName *string \`json:"initial_name"\` @@ -6850,9 +6850,6 @@ test('typegen: python', async () => { name: NotRequired[Annotated[str, Field(alias="name")]] status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] - class PublicAView(BaseModel): - id: Optional[int] = Field(alias="id") - class PublicTodosView(BaseModel): details: Optional[str] = Field(alias="details") id: Optional[int] = Field(alias="id") @@ -6872,6 +6869,9 @@ test('typegen: python', async () => { user_name: Optional[str] = Field(alias="user_name") user_status: Optional[PublicUserStatus] = Field(alias="user_status") + class PublicAView(BaseModel): + id: Optional[int] = Field(alias="id") + class PublicUsersViewWithMultipleRefsToUsers(BaseModel): initial_id: Optional[int] = Field(alias="initial_id") initial_name: Optional[str] = Field(alias="initial_name") From e111af823646d1182bfbb3bcd40c6755dd3cd321 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 12:20:09 +0000 Subject: [PATCH 05/13] test: regenerate typegen snapshots for semantic metadata ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the sortGeneratorMetadata semantic-key change (supabase/pg-toolbelt#302): the canonical order is now schema+name based, so the Go/Python typegen snapshots are regenerated to alphabetical order. Pure reorder — struct/class contents are byte-identical. TypeScript/Swift sort internally and are unaffected. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01B53a2ayu9d16u821FQ5Qqz --- test/server/typegen.ts | 448 ++++++++++++++++++++--------------------- 1 file changed, 224 insertions(+), 224 deletions(-) diff --git a/test/server/typegen.ts b/test/server/typegen.ts index acef48be5..39f87745a 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -5268,82 +5268,19 @@ test('typegen: go', async () => { expect(body).toMatchInlineSnapshot(` "package database - type PublicUsersSelect struct { - Decimal *float64 \`json:"decimal"\` - Id int64 \`json:"id"\` - Name *string \`json:"name"\` - Status *string \`json:"status"\` - UserUuid *string \`json:"user_uuid"\` - } - - type PublicUsersInsert struct { - Decimal *float64 \`json:"decimal"\` - Id *int64 \`json:"id"\` - Name *string \`json:"name"\` - Status *string \`json:"status"\` - UserUuid *string \`json:"user_uuid"\` - } - - type PublicUsersUpdate struct { - Decimal *float64 \`json:"decimal"\` - Id *int64 \`json:"id"\` - Name *string \`json:"name"\` - Status *string \`json:"status"\` - UserUuid *string \`json:"user_uuid"\` - } - - type PublicTodosSelect struct { - Details *string \`json:"details"\` - Id int64 \`json:"id"\` - UserId int64 \`json:"user-id"\` - } - - type PublicTodosInsert struct { - Details *string \`json:"details"\` - Id *int64 \`json:"id"\` - UserId int64 \`json:"user-id"\` - } - - type PublicTodosUpdate struct { - Details *string \`json:"details"\` - Id *int64 \`json:"id"\` - UserId *int64 \`json:"user-id"\` - } - - type PublicUsersAuditSelect struct { - CreatedAt *string \`json:"created_at"\` - Id int64 \`json:"id"\` - PreviousValue interface{} \`json:"previous_value"\` - UserId *int64 \`json:"user_id"\` - } - - type PublicUsersAuditInsert struct { - CreatedAt *string \`json:"created_at"\` - Id *int64 \`json:"id"\` - PreviousValue interface{} \`json:"previous_value"\` - UserId *int64 \`json:"user_id"\` - } - - type PublicUsersAuditUpdate struct { - CreatedAt *string \`json:"created_at"\` - Id *int64 \`json:"id"\` - PreviousValue interface{} \`json:"previous_value"\` - UserId *int64 \`json:"user_id"\` - } - - type PublicUserDetailsSelect struct { - Details *string \`json:"details"\` - UserId int64 \`json:"user_id"\` + type PublicCategorySelect struct { + Id int32 \`json:"id"\` + Name string \`json:"name"\` } - type PublicUserDetailsInsert struct { - Details *string \`json:"details"\` - UserId int64 \`json:"user_id"\` + type PublicCategoryInsert struct { + Id *int32 \`json:"id"\` + Name string \`json:"name"\` } - type PublicUserDetailsUpdate struct { - Details *string \`json:"details"\` - UserId *int64 \`json:"user_id"\` + type PublicCategoryUpdate struct { + Id *int32 \`json:"id"\` + Name *string \`json:"name"\` } type PublicEmptySelect struct { @@ -5358,36 +5295,6 @@ test('typegen: go', async () => { } - type PublicTableWithOtherTablesRowTypeSelect struct { - Col1 interface{} \`json:"col1"\` - Col2 interface{} \`json:"col2"\` - } - - type PublicTableWithOtherTablesRowTypeInsert struct { - Col1 interface{} \`json:"col1"\` - Col2 interface{} \`json:"col2"\` - } - - type PublicTableWithOtherTablesRowTypeUpdate struct { - Col1 interface{} \`json:"col1"\` - Col2 interface{} \`json:"col2"\` - } - - type PublicTableWithPrimaryKeyOtherThanIdSelect struct { - Name *string \`json:"name"\` - OtherId int64 \`json:"other_id"\` - } - - type PublicTableWithPrimaryKeyOtherThanIdInsert struct { - Name *string \`json:"name"\` - OtherId *int64 \`json:"other_id"\` - } - - type PublicTableWithPrimaryKeyOtherThanIdUpdate struct { - Name *string \`json:"name"\` - OtherId *int64 \`json:"other_id"\` - } - type PublicEventsSelect struct { CreatedAt string \`json:"created_at"\` Data interface{} \`json:"data"\` @@ -5469,21 +5376,6 @@ test('typegen: go', async () => { Id *int64 \`json:"id"\` } - type PublicCategorySelect struct { - Id int32 \`json:"id"\` - Name string \`json:"name"\` - } - - type PublicCategoryInsert struct { - Id *int32 \`json:"id"\` - Name string \`json:"name"\` - } - - type PublicCategoryUpdate struct { - Id *int32 \`json:"id"\` - Name *string \`json:"name"\` - } - type PublicMemesSelect struct { Category *int32 \`json:"category"\` CreatedAt string \`json:"created_at"\` @@ -5511,13 +5403,86 @@ test('typegen: go', async () => { Status *string \`json:"status"\` } - type PublicTodosViewSelect struct { + type PublicTableWithOtherTablesRowTypeSelect struct { + Col1 interface{} \`json:"col1"\` + Col2 interface{} \`json:"col2"\` + } + + type PublicTableWithOtherTablesRowTypeInsert struct { + Col1 interface{} \`json:"col1"\` + Col2 interface{} \`json:"col2"\` + } + + type PublicTableWithOtherTablesRowTypeUpdate struct { + Col1 interface{} \`json:"col1"\` + Col2 interface{} \`json:"col2"\` + } + + type PublicTableWithPrimaryKeyOtherThanIdSelect struct { + Name *string \`json:"name"\` + OtherId int64 \`json:"other_id"\` + } + + type PublicTableWithPrimaryKeyOtherThanIdInsert struct { + Name *string \`json:"name"\` + OtherId *int64 \`json:"other_id"\` + } + + type PublicTableWithPrimaryKeyOtherThanIdUpdate struct { + Name *string \`json:"name"\` + OtherId *int64 \`json:"other_id"\` + } + + type PublicTodosSelect struct { + Details *string \`json:"details"\` + Id int64 \`json:"id"\` + UserId int64 \`json:"user-id"\` + } + + type PublicTodosInsert struct { + Details *string \`json:"details"\` + Id *int64 \`json:"id"\` + UserId int64 \`json:"user-id"\` + } + + type PublicTodosUpdate struct { Details *string \`json:"details"\` Id *int64 \`json:"id"\` UserId *int64 \`json:"user-id"\` } - type PublicUsersViewSelect struct { + type PublicUserDetailsSelect struct { + Details *string \`json:"details"\` + UserId int64 \`json:"user_id"\` + } + + type PublicUserDetailsInsert struct { + Details *string \`json:"details"\` + UserId int64 \`json:"user_id"\` + } + + type PublicUserDetailsUpdate struct { + Details *string \`json:"details"\` + UserId *int64 \`json:"user_id"\` + } + + type PublicUsersSelect struct { + Decimal *float64 \`json:"decimal"\` + Id int64 \`json:"id"\` + Name *string \`json:"name"\` + Status *string \`json:"status"\` + UserUuid *string \`json:"user_uuid"\` + } + + type PublicUsersInsert struct { + Decimal *float64 \`json:"decimal"\` + Id *int64 \`json:"id"\` + Name *string \`json:"name"\` + Status *string \`json:"status"\` + UserUuid *string \`json:"user_uuid"\` + } + + type PublicUsersUpdate struct { Decimal *float64 \`json:"decimal"\` Id *int64 \`json:"id"\` Name *string \`json:"name"\` @@ -5525,6 +5490,37 @@ test('typegen: go', async () => { UserUuid *string \`json:"user_uuid"\` } + type PublicUsersAuditSelect struct { + CreatedAt *string \`json:"created_at"\` + Id int64 \`json:"id"\` + PreviousValue interface{} \`json:"previous_value"\` + UserId *int64 \`json:"user_id"\` + } + + type PublicUsersAuditInsert struct { + CreatedAt *string \`json:"created_at"\` + Id *int64 \`json:"id"\` + PreviousValue interface{} \`json:"previous_value"\` + UserId *int64 \`json:"user_id"\` + } + + type PublicUsersAuditUpdate struct { + CreatedAt *string \`json:"created_at"\` + Id *int64 \`json:"id"\` + PreviousValue interface{} \`json:"previous_value"\` + UserId *int64 \`json:"user_id"\` + } + + type PublicAViewSelect struct { + Id *int64 \`json:"id"\` + } + + type PublicTodosViewSelect struct { + Details *string \`json:"details"\` + Id *int64 \`json:"id"\` + UserId *int64 \`json:"user-id"\` + } + type PublicUserTodosSummaryViewSelect struct { TodoCount *int64 \`json:"todo_count"\` TodoDetails []*string \`json:"todo_details"\` @@ -5533,8 +5529,12 @@ test('typegen: go', async () => { UserStatus *string \`json:"user_status"\` } - type PublicAViewSelect struct { - Id *int64 \`json:"id"\` + type PublicUsersViewSelect struct { + Decimal *float64 \`json:"decimal"\` + Id *int64 \`json:"id"\` + Name *string \`json:"name"\` + Status *string \`json:"status"\` + UserUuid *string \`json:"user_uuid"\` } type PublicUsersViewWithMultipleRefsToUsersSelect struct { @@ -6642,75 +6642,21 @@ test('typegen: python', async () => { from pydantic import BaseModel, Field, Json - PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] - PublicMemeStatus: TypeAlias = Literal["new", "old", "retired"] - class PublicUsers(BaseModel): - decimal: Optional[float] = Field(alias="decimal") - id: int = Field(alias="id") - name: Optional[str] = Field(alias="name") - status: Optional[PublicUserStatus] = Field(alias="status") - user_uuid: Optional[uuid.UUID] = Field(alias="user_uuid") - - class PublicUsersInsert(TypedDict): - decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] - id: NotRequired[Annotated[int, Field(alias="id")]] - name: NotRequired[Annotated[Optional[str], Field(alias="name")]] - status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] - user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] - - class PublicUsersUpdate(TypedDict): - decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] - id: NotRequired[Annotated[int, Field(alias="id")]] - name: NotRequired[Annotated[Optional[str], Field(alias="name")]] - status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] - user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] - - class PublicTodos(BaseModel): - details: Optional[str] = Field(alias="details") - id: int = Field(alias="id") - user_id: int = Field(alias="user-id") - - class PublicTodosInsert(TypedDict): - details: NotRequired[Annotated[Optional[str], Field(alias="details")]] - id: NotRequired[Annotated[int, Field(alias="id")]] - user_id: Annotated[int, Field(alias="user-id")] - - class PublicTodosUpdate(TypedDict): - details: NotRequired[Annotated[Optional[str], Field(alias="details")]] - id: NotRequired[Annotated[int, Field(alias="id")]] - user_id: NotRequired[Annotated[int, Field(alias="user-id")]] + PublicUserStatus: TypeAlias = Literal["ACTIVE", "INACTIVE"] - class PublicUsersAudit(BaseModel): - created_at: Optional[datetime.datetime] = Field(alias="created_at") + class PublicCategory(BaseModel): id: int = Field(alias="id") - previous_value: Optional[Json[Any]] = Field(alias="previous_value") - user_id: Optional[int] = Field(alias="user_id") + name: str = Field(alias="name") - class PublicUsersAuditInsert(TypedDict): - created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + class PublicCategoryInsert(TypedDict): id: NotRequired[Annotated[int, Field(alias="id")]] - previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] - user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] + name: Annotated[str, Field(alias="name")] - class PublicUsersAuditUpdate(TypedDict): - created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + class PublicCategoryUpdate(TypedDict): id: NotRequired[Annotated[int, Field(alias="id")]] - previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] - user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] - - class PublicUserDetails(BaseModel): - details: Optional[str] = Field(alias="details") - user_id: int = Field(alias="user_id") - - class PublicUserDetailsInsert(TypedDict): - details: NotRequired[Annotated[Optional[str], Field(alias="details")]] - user_id: Annotated[int, Field(alias="user_id")] - - class PublicUserDetailsUpdate(TypedDict): - details: NotRequired[Annotated[Optional[str], Field(alias="details")]] - user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + name: NotRequired[Annotated[str, Field(alias="name")]] class PublicEmpty(BaseModel): pass @@ -6721,30 +6667,6 @@ test('typegen: python', async () => { class PublicEmptyUpdate(TypedDict): pass - class PublicTableWithOtherTablesRowType(BaseModel): - col1: Optional[PublicUserDetails] = Field(alias="col1") - col2: Optional[PublicAView] = Field(alias="col2") - - class PublicTableWithOtherTablesRowTypeInsert(TypedDict): - col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] - col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] - - class PublicTableWithOtherTablesRowTypeUpdate(TypedDict): - col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] - col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] - - class PublicTableWithPrimaryKeyOtherThanId(BaseModel): - name: Optional[str] = Field(alias="name") - other_id: int = Field(alias="other_id") - - class PublicTableWithPrimaryKeyOtherThanIdInsert(TypedDict): - name: NotRequired[Annotated[Optional[str], Field(alias="name")]] - other_id: NotRequired[Annotated[int, Field(alias="other_id")]] - - class PublicTableWithPrimaryKeyOtherThanIdUpdate(TypedDict): - name: NotRequired[Annotated[Optional[str], Field(alias="name")]] - other_id: NotRequired[Annotated[int, Field(alias="other_id")]] - class PublicEvents(BaseModel): created_at: datetime.datetime = Field(alias="created_at") data: Optional[Json[Any]] = Field(alias="data") @@ -6814,18 +6736,6 @@ test('typegen: python', async () => { duration_required: NotRequired[Annotated[str, Field(alias="duration_required")]] id: NotRequired[Annotated[int, Field(alias="id")]] - class PublicCategory(BaseModel): - id: int = Field(alias="id") - name: str = Field(alias="name") - - class PublicCategoryInsert(TypedDict): - id: NotRequired[Annotated[int, Field(alias="id")]] - name: Annotated[str, Field(alias="name")] - - class PublicCategoryUpdate(TypedDict): - id: NotRequired[Annotated[int, Field(alias="id")]] - name: NotRequired[Annotated[str, Field(alias="name")]] - class PublicMemes(BaseModel): category: Optional[int] = Field(alias="category") created_at: datetime.datetime = Field(alias="created_at") @@ -6850,18 +6760,104 @@ test('typegen: python', async () => { name: NotRequired[Annotated[str, Field(alias="name")]] status: NotRequired[Annotated[Optional[PublicMemeStatus], Field(alias="status")]] - class PublicTodosView(BaseModel): + class PublicTableWithOtherTablesRowType(BaseModel): + col1: Optional[PublicUserDetails] = Field(alias="col1") + col2: Optional[PublicAView] = Field(alias="col2") + + class PublicTableWithOtherTablesRowTypeInsert(TypedDict): + col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] + col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] + + class PublicTableWithOtherTablesRowTypeUpdate(TypedDict): + col1: NotRequired[Annotated[Optional[PublicUserDetails], Field(alias="col1")]] + col2: NotRequired[Annotated[Optional[PublicAView], Field(alias="col2")]] + + class PublicTableWithPrimaryKeyOtherThanId(BaseModel): + name: Optional[str] = Field(alias="name") + other_id: int = Field(alias="other_id") + + class PublicTableWithPrimaryKeyOtherThanIdInsert(TypedDict): + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + + class PublicTableWithPrimaryKeyOtherThanIdUpdate(TypedDict): + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + other_id: NotRequired[Annotated[int, Field(alias="other_id")]] + + class PublicTodos(BaseModel): details: Optional[str] = Field(alias="details") - id: Optional[int] = Field(alias="id") - user_id: Optional[int] = Field(alias="user-id") + id: int = Field(alias="id") + user_id: int = Field(alias="user-id") - class PublicUsersView(BaseModel): + class PublicTodosInsert(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: Annotated[int, Field(alias="user-id")] + + class PublicTodosUpdate(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + user_id: NotRequired[Annotated[int, Field(alias="user-id")]] + + class PublicUserDetails(BaseModel): + details: Optional[str] = Field(alias="details") + user_id: int = Field(alias="user_id") + + class PublicUserDetailsInsert(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + user_id: Annotated[int, Field(alias="user_id")] + + class PublicUserDetailsUpdate(TypedDict): + details: NotRequired[Annotated[Optional[str], Field(alias="details")]] + user_id: NotRequired[Annotated[int, Field(alias="user_id")]] + + class PublicUsers(BaseModel): decimal: Optional[float] = Field(alias="decimal") - id: Optional[int] = Field(alias="id") + id: int = Field(alias="id") name: Optional[str] = Field(alias="name") status: Optional[PublicUserStatus] = Field(alias="status") user_uuid: Optional[uuid.UUID] = Field(alias="user_uuid") + class PublicUsersInsert(TypedDict): + decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] + + class PublicUsersUpdate(TypedDict): + decimal: NotRequired[Annotated[Optional[float], Field(alias="decimal")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + name: NotRequired[Annotated[Optional[str], Field(alias="name")]] + status: NotRequired[Annotated[Optional[PublicUserStatus], Field(alias="status")]] + user_uuid: NotRequired[Annotated[Optional[uuid.UUID], Field(alias="user_uuid")]] + + class PublicUsersAudit(BaseModel): + created_at: Optional[datetime.datetime] = Field(alias="created_at") + id: int = Field(alias="id") + previous_value: Optional[Json[Any]] = Field(alias="previous_value") + user_id: Optional[int] = Field(alias="user_id") + + class PublicUsersAuditInsert(TypedDict): + created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] + + class PublicUsersAuditUpdate(TypedDict): + created_at: NotRequired[Annotated[Optional[datetime.datetime], Field(alias="created_at")]] + id: NotRequired[Annotated[int, Field(alias="id")]] + previous_value: NotRequired[Annotated[Optional[Json[Any]], Field(alias="previous_value")]] + user_id: NotRequired[Annotated[Optional[int], Field(alias="user_id")]] + + class PublicAView(BaseModel): + id: Optional[int] = Field(alias="id") + + class PublicTodosView(BaseModel): + details: Optional[str] = Field(alias="details") + id: Optional[int] = Field(alias="id") + user_id: Optional[int] = Field(alias="user-id") + class PublicUserTodosSummaryView(BaseModel): todo_count: Optional[int] = Field(alias="todo_count") todo_details: Optional[List[str]] = Field(alias="todo_details") @@ -6869,8 +6865,12 @@ test('typegen: python', async () => { user_name: Optional[str] = Field(alias="user_name") user_status: Optional[PublicUserStatus] = Field(alias="user_status") - class PublicAView(BaseModel): + class PublicUsersView(BaseModel): + decimal: Optional[float] = Field(alias="decimal") id: Optional[int] = Field(alias="id") + name: Optional[str] = Field(alias="name") + status: Optional[PublicUserStatus] = Field(alias="status") + user_uuid: Optional[uuid.UUID] = Field(alias="user_uuid") class PublicUsersViewWithMultipleRefsToUsers(BaseModel): initial_id: Optional[int] = Field(alias="initial_id") From 830a2fdbe6f314ddae5282e0c3f4ed80a029411f Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 20 Jun 2026 14:49:08 +0200 Subject: [PATCH 06/13] chore: refresh lockfile for semantic-key sort build --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6ab0e8dd0..639912d70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", @@ -1893,8 +1893,8 @@ }, "node_modules/@supabase/postgrest-typegen": { "version": "1.0.0-alpha.1", - "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", - "integrity": "sha512-5Qdv0ePAocVhPtBWijkpfnd0UsBC/hCrgRSP31YmeMzXpM3yIW7szltpVlr2CT6RC6fKCT62FRh27BxtLU7z/Q==", + "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", + "integrity": "sha512-viUdfDQZhUmX1RjWQyeG2QyOXsC2HGehBOomfCve3/BU200oPulCTJ/u5gOURULgzuYLShDFdn0i7hqrWDOb1w==", "license": "MIT", "dependencies": { "arktype": "2.2.1", diff --git a/package.json b/package.json index 991027659..d95b5a1b8 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@302", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", From 1b195b11d77c18175ef1cbf6d53da236bcd71e52 Mon Sep 17 00:00:00 2001 From: avallete Date: Sat, 20 Jun 2026 15:38:56 +0200 Subject: [PATCH 07/13] chore: refresh lockfile for semantic-key sort build --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 639912d70..0d37b94dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", @@ -1893,8 +1893,8 @@ }, "node_modules/@supabase/postgrest-typegen": { "version": "1.0.0-alpha.1", - "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", - "integrity": "sha512-viUdfDQZhUmX1RjWQyeG2QyOXsC2HGehBOomfCve3/BU200oPulCTJ/u5gOURULgzuYLShDFdn0i7hqrWDOb1w==", + "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", + "integrity": "sha512-GaWcxqVKJVBrcNYWZLXjwcd4jMzJwLc3SpdwfinwiSFMtWqgpHwmVv/igIH7jCm1w2bTobMGUMmBx+MDDpzUag==", "license": "MIT", "dependencies": { "arktype": "2.2.1", diff --git a/package.json b/package.json index d95b5a1b8..31f6a4bf4 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@1aa77eb", + "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", From 847b63b08d2061172b81bd794312886034b28af6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 10:42:35 +0200 Subject: [PATCH 08/13] chore: repin @supabase/postgrest-typegen to released ^0.1.0 --- package-lock.json | 44 ++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/package-lock.json b/package-lock.json index 0d37b94dd..4edf16a8f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", + "@supabase/postgrest-typegen": "^0.1.0", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", @@ -65,18 +65,18 @@ } }, "node_modules/@ark/schema": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.0.tgz", - "integrity": "sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==", + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.2.tgz", + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", "license": "MIT", "dependencies": { - "@ark/util": "0.56.0" + "@ark/util": "0.56.2" } }, "node_modules/@ark/util": { - "version": "0.56.0", - "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.0.tgz", - "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.2.tgz", + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==", "license": "MIT" }, "node_modules/@babel/helper-string-parser": { @@ -1892,12 +1892,12 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-typegen": { - "version": "1.0.0-alpha.1", - "resolved": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", - "integrity": "sha512-GaWcxqVKJVBrcNYWZLXjwcd4jMzJwLc3SpdwfinwiSFMtWqgpHwmVv/igIH7jCm1w2bTobMGUMmBx+MDDpzUag==", + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-typegen/-/postgrest-typegen-0.1.0.tgz", + "integrity": "sha512-j0R9IVPlinTCVNx3u6irZFWGsL6Hg/B0pE8zxBV8VWhw/vmH+cbeD6BmyU5wBAh4sJ3Xk145HTgVg7kVnEccZA==", "license": "MIT", "dependencies": { - "arktype": "2.2.1", + "arktype": "2.2.3", "pg-format": "1.0.4", "prettier": "3.5.3" }, @@ -2302,23 +2302,23 @@ "license": "Python-2.0" }, "node_modules/arkregex": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.6.tgz", - "integrity": "sha512-9mvuMKQuibfWhBrsNYhsKhNb6k9oEHoAJ/FvDiqe8h+E9Siwe0/cro1WVOGgpajXQ9ZHd24yCOf2k35Q/QqUQw==", + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.8.tgz", + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", "license": "MIT", "dependencies": { - "@ark/util": "0.56.0" + "@ark/util": "0.56.2" } }, "node_modules/arktype": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.1.tgz", - "integrity": "sha512-CWPJxNoSxrS+NYGB3ufwc/blFonESEW5vBQyYPVS0rf4STu8VWoAWfKJSl5vVVm56h4yxpwbODeYwy6XFKvojA==", + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.3.tgz", + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", "license": "MIT", "dependencies": { - "@ark/schema": "0.56.0", - "@ark/util": "0.56.0", - "arkregex": "0.0.6" + "@ark/schema": "0.56.2", + "@ark/util": "0.56.2", + "arkregex": "0.0.8" } }, "node_modules/array-buffer-byte-length": { diff --git a/package.json b/package.json index 31f6a4bf4..2f3090e66 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "https://pkg.pr.new/supabase/pg-toolbelt/@supabase/postgrest-typegen@11ff444", + "@supabase/postgrest-typegen": "^0.1.0", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", From 5ecdf2865bb600638aca08cce5efeb6fdd6501b6 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 11:26:55 +0200 Subject: [PATCH 09/13] fix: honor PG_META_GENERATE_TYPES_DEFAULT_SCHEMA in the typescript generator route --- CLAUDE.md | 31 +++++++++++++++++++--- src/lib/generators.ts | 6 ++--- src/server/routes/generators/typescript.ts | 2 ++ test/server/typegen.ts | 21 +++++++++++++++ 4 files changed, 53 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 85d2c42ce..a59ee058c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,7 @@ postgres-meta is a RESTful API for managing PostgreSQL databases. It provides a ## Development Commands ### Development Server + ```bash npm run dev # Start dev server with Docker DB (auto-cleans on exit) npm run dev:code # Start dev server without DB setup (if DB already running) @@ -17,6 +18,7 @@ npm run dev:code # Start dev server without DB setup (if DB already runn The dev server uses nodemon with ts-node/esm loader and pipes output through pino-pretty for readable logs. ### Building + ```bash npm run build # Compile TypeScript + copy SQL files to dist/ npm run clean # Remove dist/ and tsconfig.tsbuildinfo @@ -26,6 +28,7 @@ npm run check # Type-check without emitting files Build process: TypeScript compilation (tsc) + copying `src/lib/sql/*.sql` files to `dist/lib/sql/` via cpy-cli. ### Testing + ```bash npm test # Full test: db:clean -> db:run -> test:run -> db:clean npm run test:run # Run tests only (DB must be running) @@ -37,12 +40,14 @@ Tests use Vitest with snapshot testing. Test files are split between `test/lib/` **Important**: Tests require Docker. The test DB is managed via `test/db/docker-compose.yml` and runs on port 5432. Tests run sequentially (`maxConcurrency: 1`) and use `pool: 'forks'` to avoid memory issues. ### Database Management + ```bash npm run db:run # Start test DB in Docker (detached, with healthcheck) npm run db:clean # Stop and remove test DB containers ``` ### Type Generation + ```bash npm run gen:types:typescript # Generate TypeScript types from DB schema npm run gen:types:python # Generate Python types (Pydantic models) @@ -56,6 +61,7 @@ PG_META_DB_URL=postgresql://... npm run gen:types:typescript Type generation is controlled by `PG_META_GENERATE_TYPES` env var and runs the server in special mode (exits after generating types to stdout). ### Code Quality + ```bash npm run format # Format code with Prettier ``` @@ -76,11 +82,12 @@ npm run format # Format code with Prettier - `app.ts`: Main Fastify app with routes, CORS, Swagger docs - `admin-app.ts`: Admin server (runs on PG_META_PORT + 1) for metrics - `routes/*.ts`: REST endpoints mapping to library methods - - `templates/*.ts`: Type generation templates for different languages + - `routes/generators/*.ts`: Type generation endpoints backed by `@supabase/postgrest-typegen` ### Object Manager Pattern Each PostgreSQL object type has a dedicated manager class following this pattern: + - `PostgresMetaTables`, `PostgresMetaColumns`, `PostgresMetaFunctions`, etc. - Each manager has methods: `list()`, `retrieve()`, `create()`, `update()`, `remove()` - Managers compose SQL from `src/lib/sql/*.sql.ts` templates @@ -89,6 +96,7 @@ Each PostgreSQL object type has a dedicated manager class following this pattern ### SQL Query Organization SQL queries are defined in `src/lib/sql/*.sql.ts` as TypeScript template literals: + - Queries use `pg-format` for safe parameterization - Complex queries join against `pg_catalog` and `information_schema` - Build process copies SQL files to `dist/lib/sql/` for runtime access @@ -108,6 +116,7 @@ SQL queries are defined in `src/lib/sql/*.sql.ts` as TypeScript template literal ### Error Handling Database errors are formatted to mimic `psql` output: + - Includes severity, error code, message, line number, position marker - Position calculation accounts for injected `SET statement_timeout` prefix - Returns structured errors: `{ code, message, formattedError, position, detail, hint }` @@ -115,12 +124,14 @@ Database errors are formatted to mimic `psql` output: ### Type Generation Type generation (`npm run gen:types:*`) works by: + 1. Connecting to a database (test DB or custom via `PG_META_DB_URL`) -2. Fetching all schemas, tables, columns, relationships, functions, types -3. Passing data to language-specific templates in `src/server/templates/*.ts` -4. Templates output type definitions to stdout +2. Introspecting schemas, tables, columns, relationships, functions, and types via `@supabase/postgrest-typegen`'s `introspect()` (wrapped by `src/lib/generators.ts`) +3. Passing the metadata to the package's language generators (`generateTypescript`, `generateGo`, `generateSwift`, `generatePython`) +4. Generators output type definitions to stdout Environment variables: + - `PG_META_GENERATE_TYPES`: Language (typescript, python, go, swift) - `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS`: Comma-separated schemas to include - `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS`: Enable 1:1 relationship detection @@ -129,6 +140,7 @@ Environment variables: ## Environment Variables Required for server operation: + ```bash PG_META_HOST=0.0.0.0 # Server host PG_META_PORT=8080 # Server port (admin runs on +1) @@ -141,11 +153,13 @@ PG_META_DB_SSL_MODE=disable # SSL mode (disable, require, verify-ful ``` Alternative connection: + ```bash PG_META_DB_URL=postgresql://... # Full connection string (overrides individual params) ``` Performance tuning: + ```bash PG_CONN_TIMEOUT_SECS=15 # Connection timeout (default: 15) PG_QUERY_TIMEOUT_SECS=55 # Query timeout (default: 55) @@ -155,6 +169,7 @@ PG_META_SHUTDOWN_GRACE_PERIOD_SECS=10 # Shutdown wait on in-flight work before ``` Type generation formatting (opt-in): + ```bash PG_META_FORMAT_IN_WORKER=true # Format generated types on a worker thread (default: false) PG_META_FORMAT_POOL_SIZE=1 # Worker threads used for formatting (default: 1) @@ -172,7 +187,9 @@ exits. ## Testing Notes ### Snapshot Testing + Tests use Vitest inline snapshots (`toMatchInlineSnapshot`). When fixing bugs: + 1. Add test case reproducing the bug 2. Fix the bug 3. Run `npm run test:update` (adds `-u` flag to vitest) @@ -180,13 +197,16 @@ Tests use Vitest inline snapshots (`toMatchInlineSnapshot`). When fixing bugs: 5. Remove `-u` flag before committing (or use `npm run test` directly) ### Test Structure + - `test/lib/*.ts`: Direct library tests using `pgMeta` instance - `test/server/*.ts`: HTTP API tests using Fastify test utils - `test/index.test.ts`: Main entry point that imports all test modules - `test/db/`: Docker Compose config for test database with SSL enabled ### Custom Database Connection + To test against a different database: + ```bash PG_META_DB_URL=postgresql://user:pass@host:port/dbname npm run dev:code ``` @@ -194,6 +214,7 @@ PG_META_DB_URL=postgresql://user:pass@host:port/dbname npm run dev:code ## Module System This project uses **ESM** (ES Modules): + - `"type": "module"` in package.json - Import statements use `.js` extension (TypeScript convention for ESM) - Node >=20 required @@ -203,6 +224,7 @@ This project uses **ESM** (ES Modules): ## Special Imports The `#package.json` import is defined in package.json `imports` field: + ```json "imports": { "#package.json": "./package.json" @@ -214,6 +236,7 @@ This allows importing package.json metadata in ESM without path resolution issue ## OpenAPI Documentation Generate OpenAPI spec: + ```bash npm run docs:export > openapi.json ``` diff --git a/src/lib/generators.ts b/src/lib/generators.ts index 8030de70b..abfe41468 100644 --- a/src/lib/generators.ts +++ b/src/lib/generators.ts @@ -16,9 +16,9 @@ export type { GeneratorMetadata } * * The package is driver-agnostic: it takes a structural `Queryable` whose * `query()` resolves to `{ rows }` and throws on failure. We wrap `pgMeta.query` - * (which returns `{ data, error }`) into that shape, surface the first query - * error as the result error, and always end the pool — matching the previous - * behavior. + * (which returns `{ data, error }`) into that shape and surface the first query + * error as the result error. Unlike the previous implementation, the pool is + * ended on error paths too, not just on success. */ export async function getGeneratorMetadata( pgMeta: PostgresMeta, diff --git a/src/server/routes/generators/typescript.ts b/src/server/routes/generators/typescript.ts index b2b6f00b2..186666a27 100644 --- a/src/server/routes/generators/typescript.ts +++ b/src/server/routes/generators/typescript.ts @@ -3,6 +3,7 @@ import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' import { generateTypescript } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' +import { GENERATE_TYPES_DEFAULT_SCHEMA } from '../../constants.js' export default async (fastify: FastifyInstance) => { fastify.get<{ @@ -36,6 +37,7 @@ export default async (fastify: FastifyInstance) => { return generateTypescript(generatorMeta!, { detectOneToOneRelationships, postgrestVersion, + defaultSchema: GENERATE_TYPES_DEFAULT_SCHEMA, }) }) } diff --git a/test/server/typegen.ts b/test/server/typegen.ts index c377f32a7..c4778dfd4 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -6996,3 +6996,24 @@ test('typegen: python w/ excluded/included schemas', async () => { }) } }) + +test('typegen: typescript honors PG_META_GENERATE_TYPES_DEFAULT_SCHEMA', async () => { + // The default schema is read from the environment when constants.ts is + // evaluated, so the module graph has to be re-imported with the stubbed env. + const { vi } = await import('vitest') + vi.stubEnv('PG_META_GENERATE_TYPES_DEFAULT_SCHEMA', 'custom_default_schema') + vi.resetModules() + try { + const { build } = await import('../../src/server/app') + const freshApp = build() + const { body } = await freshApp.inject({ method: 'GET', path: '/generators/typescript' }) + // Prettier may wrap the DefaultSchema line, so only pin the parts that + // prove the env var reached the generator. + expect(body).toContain('type DefaultSchema = DatabaseWithoutInternals[Extract<') + expect(body).toContain('"custom_default_schema"') + await freshApp.close() + } finally { + vi.unstubAllEnvs() + vi.resetModules() + } +}) From 4ed00838adfe874416fd02245b0c751e3adb4c8f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 10:07:24 +0000 Subject: [PATCH 10/13] fix: restore worker-thread type generation on top of postgrest-typegen The postgrest-typegen refactor dropped the worker path added in #1102: the route calls generateTypescript() directly, the package formats with prettier inline and exposes no format hook, so format-pool, format-worker, the piscina dependency and the 503 load-shedding path were all left dead while CLAUDE.md still documented the feature. Rather than wait for a format hook upstream, hand the whole generateTypescript call to the worker. Measured on a synthetic public schema (12 columns and 2 foreign keys per table), 400 tables: wall clock 1059ms on a worker vs 1065ms inline, with the longest main-thread block dropping from ~1000ms to 21ms. Metadata crosses the boundary as a structured clone, which is plain JSON and costs nothing measurable. This also covers the ~10% of the cost that is string building rather than prettier, which a format-only hook would have left on the main thread. format-pool/format-worker are renamed to typegen-pool/typegen-worker since they no longer format, keeping the admission control, per-task timeout, idle pool and 503 shedding as they were. The PG_META_FORMAT_* env vars keep their names so existing deployments do not need reconfiguring. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0192KEBCPggQgUT7JLPc3Cz1 --- CLAUDE.md | 28 ++- package.json | 2 +- src/server/routes/generators/typescript.ts | 25 ++- src/server/server.ts | 6 +- .../{format-pool.ts => typegen-pool.ts} | 63 +++--- .../{format-worker.js => typegen-worker.js} | 11 +- test/index.test.ts | 2 +- test/server/format-pool.ts | 122 ----------- test/server/typegen-pool.ts | 193 ++++++++++++++++++ 9 files changed, 281 insertions(+), 171 deletions(-) rename src/server/{format-pool.ts => typegen-pool.ts} (52%) rename src/server/{format-worker.js => typegen-worker.js} (60%) delete mode 100644 test/server/format-pool.ts create mode 100644 test/server/typegen-pool.ts diff --git a/CLAUDE.md b/CLAUDE.md index a59ee058c..4636525d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -71,6 +71,7 @@ npm run format # Format code with Prettier ### Two-Layer Design 1. **Library Layer** (`src/lib/`): Core PostgreSQL introspection logic + - `PostgresMeta.ts`: Main class that aggregates all metadata managers - `PostgresMeta*.ts`: Individual managers for each database object type (columns, tables, functions, etc.) - `sql/*.sql.ts`: SQL query templates for fetching metadata from system catalogs @@ -168,21 +169,28 @@ PG_META_MAX_BODY_LIMIT_MB=3 # Max request body size in MB (default: PG_META_SHUTDOWN_GRACE_PERIOD_SECS=10 # Shutdown wait on in-flight work before force-exit (default: 10) ``` -Type generation formatting (opt-in): +TypeScript generation on a worker thread (opt-in): ```bash -PG_META_FORMAT_IN_WORKER=true # Format generated types on a worker thread (default: false) -PG_META_FORMAT_POOL_SIZE=1 # Worker threads used for formatting (default: 1) -PG_META_FORMAT_MAX_QUEUE=20 # Max format calls in flight before returning 503 (default: 20) -PG_META_FORMAT_TIMEOUT_SECS=60 # Per-format timeout (default: 60) +PG_META_FORMAT_IN_WORKER=true # Generate TypeScript types on a worker thread (default: false) +PG_META_FORMAT_POOL_SIZE=1 # Worker threads used for generation (default: 1) +PG_META_FORMAT_MAX_QUEUE=20 # Max generation calls in flight before returning 503 (default: 20) +PG_META_FORMAT_TIMEOUT_SECS=60 # Per-generation timeout (default: 60) PG_META_FORMAT_IDLE_TIMEOUT_SECS=30 # Idle time before a worker exits (default: 30) ``` -Prettier is CPU-bound and synchronous, so formatting a large schema blocks the -event loop for seconds and the server cannot answer anything else, health checks -included. `PG_META_FORMAT_IN_WORKER=true` moves it to a worker thread. Always -off during type generation (`PG_META_GENERATE_TYPES`), which generates once and -exits. +`generateTypescript` is CPU-bound and synchronous: on a large schema it blocks +the event loop for seconds and the server cannot answer anything else, health +checks included. Prettier is the bulk of it (~90% on a 400-table schema) but the +string building ahead of it costs too, and `@supabase/postgrest-typegen` formats +internally with no hook to intercept, so `PG_META_FORMAT_IN_WORKER=true` moves +the whole call to a worker thread (`src/server/typegen-pool.ts`). Metadata +crosses the thread boundary as a structured clone, which is plain JSON and does +not measurably change wall-clock time. Always off during type generation +(`PG_META_GENERATE_TYPES`), which generates once and exits. + +The env vars keep their `PG_META_FORMAT_*` names from when the worker formatted +only, so existing deployments do not need reconfiguring. ## Testing Notes diff --git a/package.json b/package.json index b56ddd7f9..3712192cb 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "check": "tsc -p tsconfig.json --noEmit", "clean": "rimraf dist tsconfig.tsbuildinfo", "format": "prettier --write '{src,test}/**/*.ts' '*.ts'", - "build": "tsc -p tsconfig.json && cpy 'src/lib/sql/*.sql' dist/lib/sql && cpy 'src/server/format-worker.js' dist/server --flat", + "build": "tsc -p tsconfig.json && cpy 'src/lib/sql/*.sql' dist/lib/sql && cpy 'src/server/typegen-worker.js' dist/server --flat", "docs:export": "PG_META_EXPORT_DOCS=true node --loader ts-node/esm src/server/server.ts > openapi.json", "gen:types:typescript": "PG_META_GENERATE_TYPES=typescript node --loader ts-node/esm src/server/server.ts", "gen:types:go": "PG_META_GENERATE_TYPES=go node --loader ts-node/esm src/server/server.ts", diff --git a/src/server/routes/generators/typescript.ts b/src/server/routes/generators/typescript.ts index 186666a27..e1cb5ee4e 100644 --- a/src/server/routes/generators/typescript.ts +++ b/src/server/routes/generators/typescript.ts @@ -1,9 +1,9 @@ import type { FastifyInstance } from 'fastify' import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' -import { generateTypescript } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../../../lib/generators.js' import { GENERATE_TYPES_DEFAULT_SCHEMA } from '../../constants.js' +import { generateTypescriptTypes, TypegenQueueFullError } from '../../typegen-pool.js' export default async (fastify: FastifyInstance) => { fastify.get<{ @@ -34,10 +34,23 @@ export default async (fastify: FastifyInstance) => { return { error: generatorMetaError.message } } - return generateTypescript(generatorMeta!, { - detectOneToOneRelationships, - postgrestVersion, - defaultSchema: GENERATE_TYPES_DEFAULT_SCHEMA, - }) + try { + return await generateTypescriptTypes(generatorMeta!, { + detectOneToOneRelationships, + postgrestVersion, + defaultSchema: GENERATE_TYPES_DEFAULT_SCHEMA, + }) + } catch (error) { + // Anything else is a genuine failure and is already logged and turned + // into a 500 by the app-level error handler. + if (!(error instanceof TypegenQueueFullError)) { + throw error + } + // Load shedding, not a fault: 503 tells the caller it is transient and + // worth retrying. + request.log.warn({ error, request: extractRequestForLogging(request) }) + reply.code(503) + return { error: error.message } + } }) } diff --git a/src/server/server.ts b/src/server/server.ts index 395fa20fb..65832fedd 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -24,7 +24,7 @@ import { generateTypescript, } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../lib/generators.js' -import { destroyFormatPool } from './format-pool.js' +import { destroyTypegenPool } from './typegen-pool.js' const logger = pino({ formatters: { @@ -90,8 +90,8 @@ if (EXPORT_DOCS) { await app.close().catch((err) => app.log.error({ err }, 'Failed to close app')) await adminApp.close().catch((err) => app.log.error({ err }, 'Failed to close adminApp')) // worker threads keep the event loop alive, so the process would not exit - await destroyFormatPool().catch((err) => - app.log.error({ err }, 'Failed to destroy format pool') + await destroyTypegenPool().catch((err) => + app.log.error({ err }, 'Failed to destroy typegen pool') ) }) diff --git a/src/server/format-pool.ts b/src/server/typegen-pool.ts similarity index 52% rename from src/server/format-pool.ts rename to src/server/typegen-pool.ts index 5228c9382..72ed953eb 100644 --- a/src/server/format-pool.ts +++ b/src/server/typegen-pool.ts @@ -1,5 +1,9 @@ import { Piscina } from 'piscina' -import prettier from 'prettier' +import { + generateTypescript, + type GeneratorMetadata, + type GenerateTypescriptOptions, +} from '@supabase/postgrest-typegen' import { FORMAT_IDLE_TIMEOUT_MS, FORMAT_IN_WORKER, @@ -7,26 +11,27 @@ import { FORMAT_POOL_SIZE, FORMAT_TIMEOUT_MS, } from './constants.js' -type FormatTask = { - code: string - options: prettier.Options + +type TypescriptTask = { + metadata: GeneratorMetadata + options: GenerateTypescriptOptions } /** - * Raised when the formatting backlog is full. Callers should surface this as a + * Raised when the generation backlog is full. Callers should surface this as a * 503 rather than a 500: the server is shedding load, not broken. */ -export class FormatQueueFullError extends Error { +export class TypegenQueueFullError extends Error { constructor() { super('Type generation is busy, try again shortly') - this.name = 'FormatQueueFullError' + this.name = 'TypegenQueueFullError' } } -export class FormatTimeoutError extends Error { +export class TypegenTimeoutError extends Error { constructor() { - super(`Formatting generated types timed out after ${FORMAT_TIMEOUT_MS}ms`) - this.name = 'FormatTimeoutError' + super(`Generating types timed out after ${FORMAT_TIMEOUT_MS}ms`) + this.name = 'TypegenTimeoutError' } } @@ -36,10 +41,10 @@ let inFlight = 0 const getPool = (): Piscina => { if (!pool) { pool = new Piscina({ - // format-worker is real JavaScript rather than TypeScript: the worker is + // typegen-worker is real JavaScript rather than TypeScript: the worker is // a fresh thread with no module transform pipeline, so it must be a file // that exists on disk as-is under dev, tests and dist alike. - filename: new URL('./format-worker.js', import.meta.url).href, + filename: new URL('./typegen-worker.js', import.meta.url).href, minThreads: 0, maxThreads: FORMAT_POOL_SIZE, idleTimeout: FORMAT_IDLE_TIMEOUT_MS, @@ -48,24 +53,34 @@ const getPool = (): Piscina => { return pool } -/** Number of format calls currently running or waiting for a worker. */ +/** Number of generation calls currently running or waiting for a worker. */ export const inFlightCount = (): number => inFlight /** - * Whether a worker pool has been created, i.e. formatting actually ran off the + * Whether a worker pool has been created, i.e. generation actually ran off the * main thread rather than inline. The pool is created lazily on first use. */ -export const isFormatPoolActive = (): boolean => pool !== null +export const isTypegenPoolActive = (): boolean => pool !== null /** - * Formats generated code with prettier, on a worker thread when enabled. + * Generates TypeScript types, on a worker thread when enabled. + * + * The whole of `generateTypescript` is handed to the worker rather than just + * the prettier pass: prettier is the bulk of the cost (~90% on a 400-table + * schema), but the string building ahead of it is CPU-bound too, and the + * package formats internally with no hook to intercept. Metadata crosses the + * thread boundary as a structured clone, which is plain JSON here and does not + * measurably change wall-clock time. * - * Falls back to formatting inline when workers are disabled (type-generation + * Falls back to generating inline when workers are disabled (type-generation * CLI mode, or PG_META_FORMAT_IN_WORKER=false), where blocking is harmless. */ -export const format = async (code: string, options: prettier.Options): Promise => { +export const generateTypescriptTypes = async ( + metadata: GeneratorMetadata, + options: GenerateTypescriptOptions +): Promise => { if (!FORMAT_IN_WORKER) { - return prettier.format(code, options) + return generateTypescript(metadata, options) } // Admission control is done here rather than with piscina's own maxQueue, @@ -77,18 +92,18 @@ export const format = async (code: string, options: prettier.Options): Promise= FORMAT_MAX_QUEUE) { - throw new FormatQueueFullError() + throw new TypegenQueueFullError() } - const task: FormatTask = { code, options } + const task: TypescriptTask = { metadata, options } inFlight++ try { return await getPool().run(task, { signal: AbortSignal.timeout(FORMAT_TIMEOUT_MS) }) } catch (error: any) { if (error?.name === 'AbortError' || error?.name === 'TimeoutError') { - throw new FormatTimeoutError() + throw new TypegenTimeoutError() } throw error } finally { @@ -96,7 +111,7 @@ export const format = async (code: string, options: prettier.Options): Promise => { +export const destroyTypegenPool = async (): Promise => { if (pool) { const previous = pool pool = null diff --git a/src/server/format-worker.js b/src/server/typegen-worker.js similarity index 60% rename from src/server/format-worker.js rename to src/server/typegen-worker.js index 13196e48a..99a2d2e05 100644 --- a/src/server/format-worker.js +++ b/src/server/typegen-worker.js @@ -7,12 +7,15 @@ // tests and the built output all load the exact same file. It is copied to // dist/ by the build script alongside the .sql files. -import prettier from 'prettier' +import { generateTypescript } from '@supabase/postgrest-typegen' /** - * @param {{ code: string, options: import('prettier').Options }} task + * @param {{ + * metadata: import('@supabase/postgrest-typegen').GeneratorMetadata, + * options: import('@supabase/postgrest-typegen').GenerateTypescriptOptions, + * }} task * @returns {Promise} */ -export default async function format({ code, options }) { - return prettier.format(code, options) +export default async function generate({ metadata, options }) { + return generateTypescript(metadata, options) } diff --git a/test/index.test.ts b/test/index.test.ts index ba38ddb11..0e6bc93a3 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,7 +16,7 @@ import './lib/types' import './lib/version' import './lib/views' import './server/column-privileges' -import './server/format-pool' +import './server/typegen-pool' import './server/indexes' import './server/materialized-views' import './server/query' diff --git a/test/server/format-pool.ts b/test/server/format-pool.ts deleted file mode 100644 index 3977a5d5a..000000000 --- a/test/server/format-pool.ts +++ /dev/null @@ -1,122 +0,0 @@ -import prettier from 'prettier' -import { afterEach, expect, test, vi } from 'vitest' - -// These tests exercise the worker-thread formatting path, which is opt-in via -// PG_META_FORMAT_IN_WORKER and therefore never hit by the rest of the suite. -// -// The env vars are read once when constants.ts is evaluated, so each test stubs -// the env and re-imports the module graph via vi.resetModules() to pick them up. -const loadFormatPool = async (env: Record) => { - vi.resetModules() - for (const [key, value] of Object.entries(env)) { - vi.stubEnv(key, value) - } - return import('../../src/server/format-pool.js') -} - -const SOURCE = `export type Foo={a:number;b:string};export const bar={x:1,y:[1,2,3]} as const` -const OPTIONS: prettier.Options = { parser: 'typescript', semi: false } - -afterEach(() => { - vi.unstubAllEnvs() -}) - -test('formats on a worker thread with identical output to formatting inline', async () => { - const { format, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ - PG_META_FORMAT_IN_WORKER: 'true', - }) - - try { - expect(isFormatPoolActive()).toBe(false) - - const viaWorker = await format(SOURCE, OPTIONS) - - // without this the test would still pass if formatting silently fell back - // to running inline, which is the thing being changed - expect(isFormatPoolActive()).toBe(true) - expect(viaWorker).toBe(await prettier.format(SOURCE, OPTIONS)) - } finally { - await destroyFormatPool() - } -}) - -test('formats inline when the worker is not enabled', async () => { - const { format, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ - PG_META_FORMAT_IN_WORKER: 'false', - }) - - try { - expect(await format(SOURCE, OPTIONS)).toBe(await prettier.format(SOURCE, OPTIONS)) - // no pool was ever created, so formatting ran on the main thread - expect(isFormatPoolActive()).toBe(false) - } finally { - await destroyFormatPool() - } -}) - -test('never formats on a worker in type-generation mode', async () => { - const { format, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ - PG_META_FORMAT_IN_WORKER: 'true', - PG_META_GENERATE_TYPES: 'typescript', - }) - - try { - // one-shot CLI generation has no event loop to protect, and a pool would - // keep the process alive after it is done - expect(await format(SOURCE, OPTIONS)).toBe(await prettier.format(SOURCE, OPTIONS)) - expect(isFormatPoolActive()).toBe(false) - } finally { - await destroyFormatPool() - } -}) - -test('sheds load with FormatQueueFullError once the in-flight limit is reached', async () => { - const { format, destroyFormatPool, FormatQueueFullError } = await loadFormatPool({ - PG_META_FORMAT_IN_WORKER: 'true', - PG_META_FORMAT_POOL_SIZE: '1', - PG_META_FORMAT_MAX_QUEUE: '2', - }) - - // big enough that formatting takes long enough for calls to overlap - let big = '' - for (let i = 0; i < 1000; i++) { - big += `export type T${i} = { a: number; b: string; c: Array<{ x: number; y: string }> }\n` - } - - try { - const results = await Promise.allSettled(Array.from({ length: 6 }, () => format(big, OPTIONS))) - - const rejected = results.filter((r) => r.status === 'rejected') - // 2 admitted, the rest shed immediately rather than queueing - expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(2) - expect(rejected).toHaveLength(4) - for (const result of rejected) { - expect((result as PromiseRejectedResult).reason).toBeInstanceOf(FormatQueueFullError) - } - } finally { - await destroyFormatPool() - } -}) - -test('counts a format call while it is in flight and releases it afterwards', async () => { - const { format, destroyFormatPool, inFlightCount } = await loadFormatPool({ - PG_META_FORMAT_IN_WORKER: 'true', - PG_META_FORMAT_MAX_QUEUE: '2', - }) - - try { - expect(inFlightCount()).toBe(0) - - const pending = format(SOURCE, OPTIONS) - // observed before awaiting: checking only afterwards would pass even if the - // counter were never incremented at all - expect(inFlightCount()).toBe(1) - await pending - - // a leaked counter would make the pool refuse work forever after a burst - expect(inFlightCount()).toBe(0) - await expect(format(SOURCE, OPTIONS)).resolves.toBeTypeOf('string') - } finally { - await destroyFormatPool() - } -}) diff --git a/test/server/typegen-pool.ts b/test/server/typegen-pool.ts new file mode 100644 index 000000000..e3db3bdd4 --- /dev/null +++ b/test/server/typegen-pool.ts @@ -0,0 +1,193 @@ +import { + GENERATOR_METADATA_VERSION, + generateTypescript, + type GeneratorMetadata, +} from '@supabase/postgrest-typegen' +import { afterEach, expect, test, vi } from 'vitest' + +// These tests exercise the worker-thread generation path, which is opt-in via +// PG_META_FORMAT_IN_WORKER and therefore never hit by the rest of the suite. +// +// The env vars are read once when constants.ts is evaluated, so each test stubs +// the env and re-imports the module graph via vi.resetModules() to pick them up. +const loadTypegenPool = async (env: Record) => { + vi.resetModules() + for (const [key, value] of Object.entries(env)) { + vi.stubEnv(key, value) + } + return import('../../src/server/typegen-pool.js') +} + +const column = (tableId: number, table: string, position: number) => ({ + table_id: tableId, + schema: 'public', + table, + id: `${tableId}.${position}`, + ordinal_position: position, + name: `col_${position}`, + default_value: null, + data_type: 'text', + format: position === 0 ? 'int8' : 'text', + type_schema: 'pg_catalog', + is_identity: position === 0, + identity_generation: null, + is_generated: false, + is_nullable: position !== 0, + is_updatable: true, + is_unique: position === 0, + enums: [], + check: null, + comment: null, +}) + +const metadata = (tableCount: number): GeneratorMetadata => { + const tables = [] + const columns = [] + const primaryKeys = [] + for (let i = 0; i < tableCount; i++) { + const name = `table_${i}` + const id = 10000 + i + tables.push({ + id, + schema: 'public', + name, + rls_enabled: false, + rls_forced: false, + replica_identity: 'DEFAULT' as const, + bytes: 0, + size: '0 bytes', + live_rows_estimate: 0, + dead_rows_estimate: 0, + comment: null, + }) + primaryKeys.push({ schema: 'public', table_name: name, name: 'col_0', table_id: id }) + for (let position = 0; position < 4; position++) columns.push(column(id, name, position)) + } + return { + version: GENERATOR_METADATA_VERSION, + schemas: [{ id: 2200, name: 'public', owner: 'postgres' }], + tables, + foreignTables: [], + views: [], + materializedViews: [], + columns, + primaryKeys, + relationships: [], + functions: [], + types: [], + } +} + +const METADATA = metadata(1) +const OPTIONS = { detectOneToOneRelationships: true } + +afterEach(() => { + vi.unstubAllEnvs() +}) + +test('generates on a worker thread with identical output to generating inline', async () => { + const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = + await loadTypegenPool({ + PG_META_FORMAT_IN_WORKER: 'true', + }) + + try { + expect(isTypegenPoolActive()).toBe(false) + + const viaWorker = await generateTypescriptTypes(METADATA, OPTIONS) + + // without this the test would still pass if generation silently fell back + // to running inline, which is the thing being changed + expect(isTypegenPoolActive()).toBe(true) + expect(viaWorker).toBe(await generateTypescript(METADATA, OPTIONS)) + } finally { + await destroyTypegenPool() + } +}) + +test('generates inline when the worker is not enabled', async () => { + const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = + await loadTypegenPool({ + PG_META_FORMAT_IN_WORKER: 'false', + }) + + try { + expect(await generateTypescriptTypes(METADATA, OPTIONS)).toBe( + await generateTypescript(METADATA, OPTIONS) + ) + // no pool was ever created, so generation ran on the main thread + expect(isTypegenPoolActive()).toBe(false) + } finally { + await destroyTypegenPool() + } +}) + +test('never generates on a worker in type-generation mode', async () => { + const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = + await loadTypegenPool({ + PG_META_FORMAT_IN_WORKER: 'true', + PG_META_GENERATE_TYPES: 'typescript', + }) + + try { + // one-shot CLI generation has no event loop to protect, and a pool would + // keep the process alive after it is done + expect(await generateTypescriptTypes(METADATA, OPTIONS)).toBe( + await generateTypescript(METADATA, OPTIONS) + ) + expect(isTypegenPoolActive()).toBe(false) + } finally { + await destroyTypegenPool() + } +}) + +test('sheds load with TypegenQueueFullError once the in-flight limit is reached', async () => { + const { generateTypescriptTypes, destroyTypegenPool, TypegenQueueFullError } = + await loadTypegenPool({ + PG_META_FORMAT_IN_WORKER: 'true', + PG_META_FORMAT_POOL_SIZE: '1', + PG_META_FORMAT_MAX_QUEUE: '2', + }) + + // big enough that generating takes long enough for calls to overlap + const big = metadata(50) + + try { + const results = await Promise.allSettled( + Array.from({ length: 6 }, () => generateTypescriptTypes(big, OPTIONS)) + ) + + const rejected = results.filter((r) => r.status === 'rejected') + // 2 admitted, the rest shed immediately rather than queueing + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(2) + expect(rejected).toHaveLength(4) + for (const result of rejected) { + expect((result as PromiseRejectedResult).reason).toBeInstanceOf(TypegenQueueFullError) + } + } finally { + await destroyTypegenPool() + } +}) + +test('counts a generation call while it is in flight and releases it afterwards', async () => { + const { generateTypescriptTypes, destroyTypegenPool, inFlightCount } = await loadTypegenPool({ + PG_META_FORMAT_IN_WORKER: 'true', + PG_META_FORMAT_MAX_QUEUE: '2', + }) + + try { + expect(inFlightCount()).toBe(0) + + const pending = generateTypescriptTypes(METADATA, OPTIONS) + // observed before awaiting: checking only afterwards would pass even if the + // counter were never incremented at all + expect(inFlightCount()).toBe(1) + await pending + + // a leaked counter would make the pool refuse work forever after a burst + expect(inFlightCount()).toBe(0) + await expect(generateTypescriptTypes(METADATA, OPTIONS)).resolves.toBeTypeOf('string') + } finally { + await destroyTypegenPool() + } +}) From 7f1dd7d3e2c66ae269c0df323402faa653155060 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 12:36:19 +0200 Subject: [PATCH 11/13] chore: drop unrelated CLAUDE.md reflow and keep format callbacks off the worker boundary --- CLAUDE.md | 24 ------------------------ src/server/typegen-pool.ts | 10 ++++++++-- 2 files changed, 8 insertions(+), 26 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4636525d4..4be7269df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,7 +9,6 @@ postgres-meta is a RESTful API for managing PostgreSQL databases. It provides a ## Development Commands ### Development Server - ```bash npm run dev # Start dev server with Docker DB (auto-cleans on exit) npm run dev:code # Start dev server without DB setup (if DB already running) @@ -18,7 +17,6 @@ npm run dev:code # Start dev server without DB setup (if DB already runn The dev server uses nodemon with ts-node/esm loader and pipes output through pino-pretty for readable logs. ### Building - ```bash npm run build # Compile TypeScript + copy SQL files to dist/ npm run clean # Remove dist/ and tsconfig.tsbuildinfo @@ -28,7 +26,6 @@ npm run check # Type-check without emitting files Build process: TypeScript compilation (tsc) + copying `src/lib/sql/*.sql` files to `dist/lib/sql/` via cpy-cli. ### Testing - ```bash npm test # Full test: db:clean -> db:run -> test:run -> db:clean npm run test:run # Run tests only (DB must be running) @@ -40,14 +37,12 @@ Tests use Vitest with snapshot testing. Test files are split between `test/lib/` **Important**: Tests require Docker. The test DB is managed via `test/db/docker-compose.yml` and runs on port 5432. Tests run sequentially (`maxConcurrency: 1`) and use `pool: 'forks'` to avoid memory issues. ### Database Management - ```bash npm run db:run # Start test DB in Docker (detached, with healthcheck) npm run db:clean # Stop and remove test DB containers ``` ### Type Generation - ```bash npm run gen:types:typescript # Generate TypeScript types from DB schema npm run gen:types:python # Generate Python types (Pydantic models) @@ -61,7 +56,6 @@ PG_META_DB_URL=postgresql://... npm run gen:types:typescript Type generation is controlled by `PG_META_GENERATE_TYPES` env var and runs the server in special mode (exits after generating types to stdout). ### Code Quality - ```bash npm run format # Format code with Prettier ``` @@ -71,7 +65,6 @@ npm run format # Format code with Prettier ### Two-Layer Design 1. **Library Layer** (`src/lib/`): Core PostgreSQL introspection logic - - `PostgresMeta.ts`: Main class that aggregates all metadata managers - `PostgresMeta*.ts`: Individual managers for each database object type (columns, tables, functions, etc.) - `sql/*.sql.ts`: SQL query templates for fetching metadata from system catalogs @@ -88,7 +81,6 @@ npm run format # Format code with Prettier ### Object Manager Pattern Each PostgreSQL object type has a dedicated manager class following this pattern: - - `PostgresMetaTables`, `PostgresMetaColumns`, `PostgresMetaFunctions`, etc. - Each manager has methods: `list()`, `retrieve()`, `create()`, `update()`, `remove()` - Managers compose SQL from `src/lib/sql/*.sql.ts` templates @@ -97,7 +89,6 @@ Each PostgreSQL object type has a dedicated manager class following this pattern ### SQL Query Organization SQL queries are defined in `src/lib/sql/*.sql.ts` as TypeScript template literals: - - Queries use `pg-format` for safe parameterization - Complex queries join against `pg_catalog` and `information_schema` - Build process copies SQL files to `dist/lib/sql/` for runtime access @@ -117,7 +108,6 @@ SQL queries are defined in `src/lib/sql/*.sql.ts` as TypeScript template literal ### Error Handling Database errors are formatted to mimic `psql` output: - - Includes severity, error code, message, line number, position marker - Position calculation accounts for injected `SET statement_timeout` prefix - Returns structured errors: `{ code, message, formattedError, position, detail, hint }` @@ -125,14 +115,12 @@ Database errors are formatted to mimic `psql` output: ### Type Generation Type generation (`npm run gen:types:*`) works by: - 1. Connecting to a database (test DB or custom via `PG_META_DB_URL`) 2. Introspecting schemas, tables, columns, relationships, functions, and types via `@supabase/postgrest-typegen`'s `introspect()` (wrapped by `src/lib/generators.ts`) 3. Passing the metadata to the package's language generators (`generateTypescript`, `generateGo`, `generateSwift`, `generatePython`) 4. Generators output type definitions to stdout Environment variables: - - `PG_META_GENERATE_TYPES`: Language (typescript, python, go, swift) - `PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS`: Comma-separated schemas to include - `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS`: Enable 1:1 relationship detection @@ -141,7 +129,6 @@ Environment variables: ## Environment Variables Required for server operation: - ```bash PG_META_HOST=0.0.0.0 # Server host PG_META_PORT=8080 # Server port (admin runs on +1) @@ -154,13 +141,11 @@ PG_META_DB_SSL_MODE=disable # SSL mode (disable, require, verify-ful ``` Alternative connection: - ```bash PG_META_DB_URL=postgresql://... # Full connection string (overrides individual params) ``` Performance tuning: - ```bash PG_CONN_TIMEOUT_SECS=15 # Connection timeout (default: 15) PG_QUERY_TIMEOUT_SECS=55 # Query timeout (default: 55) @@ -170,7 +155,6 @@ PG_META_SHUTDOWN_GRACE_PERIOD_SECS=10 # Shutdown wait on in-flight work before ``` TypeScript generation on a worker thread (opt-in): - ```bash PG_META_FORMAT_IN_WORKER=true # Generate TypeScript types on a worker thread (default: false) PG_META_FORMAT_POOL_SIZE=1 # Worker threads used for generation (default: 1) @@ -195,9 +179,7 @@ only, so existing deployments do not need reconfiguring. ## Testing Notes ### Snapshot Testing - Tests use Vitest inline snapshots (`toMatchInlineSnapshot`). When fixing bugs: - 1. Add test case reproducing the bug 2. Fix the bug 3. Run `npm run test:update` (adds `-u` flag to vitest) @@ -205,16 +187,13 @@ Tests use Vitest inline snapshots (`toMatchInlineSnapshot`). When fixing bugs: 5. Remove `-u` flag before committing (or use `npm run test` directly) ### Test Structure - - `test/lib/*.ts`: Direct library tests using `pgMeta` instance - `test/server/*.ts`: HTTP API tests using Fastify test utils - `test/index.test.ts`: Main entry point that imports all test modules - `test/db/`: Docker Compose config for test database with SSL enabled ### Custom Database Connection - To test against a different database: - ```bash PG_META_DB_URL=postgresql://user:pass@host:port/dbname npm run dev:code ``` @@ -222,7 +201,6 @@ PG_META_DB_URL=postgresql://user:pass@host:port/dbname npm run dev:code ## Module System This project uses **ESM** (ES Modules): - - `"type": "module"` in package.json - Import statements use `.js` extension (TypeScript convention for ESM) - Node >=20 required @@ -232,7 +210,6 @@ This project uses **ESM** (ES Modules): ## Special Imports The `#package.json` import is defined in package.json `imports` field: - ```json "imports": { "#package.json": "./package.json" @@ -244,7 +221,6 @@ This allows importing package.json metadata in ESM without path resolution issue ## OpenAPI Documentation Generate OpenAPI spec: - ```bash npm run docs:export > openapi.json ``` diff --git a/src/server/typegen-pool.ts b/src/server/typegen-pool.ts index 72ed953eb..fd27e84b6 100644 --- a/src/server/typegen-pool.ts +++ b/src/server/typegen-pool.ts @@ -12,9 +12,15 @@ import { FORMAT_TIMEOUT_MS, } from './constants.js' +// `format` is excluded because a function cannot cross the worker boundary: +// piscina transfers the task via structured clone, which throws on callbacks. +// Newer postgrest-typegen versions accept a `format` hook; if it is ever +// needed here it must be constructed inside typegen-worker.js instead. +type WorkerSafeOptions = Omit + type TypescriptTask = { metadata: GeneratorMetadata - options: GenerateTypescriptOptions + options: WorkerSafeOptions } /** @@ -77,7 +83,7 @@ export const isTypegenPoolActive = (): boolean => pool !== null */ export const generateTypescriptTypes = async ( metadata: GeneratorMetadata, - options: GenerateTypescriptOptions + options: WorkerSafeOptions ): Promise => { if (!FORMAT_IN_WORKER) { return generateTypescript(metadata, options) From 5171302edc23d27455bc68de51c9f5b205dc89d9 Mon Sep 17 00:00:00 2001 From: Lukas Klingsbo Date: Mon, 31 Aug 2026 14:39:08 +0200 Subject: [PATCH 12/13] chore: bump @supabase/postgrest-typegen to ^0.2.0 0.2.0 formats with oxfmt instead of prettier (typescript snapshots regenerated; the only output change is oxfmt parenthesizing conditional types in generic-default positions), runs its introspection queries concurrently, and accepts a format hook, which stays unused here since the whole generateTypescript call already runs on the worker. --- CLAUDE.md | 10 +- package-lock.json | 400 ++++++++++++++++++++++++++++++++++++- package.json | 2 +- src/server/typegen-pool.ts | 16 +- test/server/typegen.ts | 80 ++++---- 5 files changed, 448 insertions(+), 60 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4be7269df..6781649f1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,11 +164,11 @@ PG_META_FORMAT_IDLE_TIMEOUT_SECS=30 # Idle time before a worker exits (defau ``` `generateTypescript` is CPU-bound and synchronous: on a large schema it blocks -the event loop for seconds and the server cannot answer anything else, health -checks included. Prettier is the bulk of it (~90% on a 400-table schema) but the -string building ahead of it costs too, and `@supabase/postgrest-typegen` formats -internally with no hook to intercept, so `PG_META_FORMAT_IN_WORKER=true` moves -the whole call to a worker thread (`src/server/typegen-pool.ts`). Metadata +the event loop and the server cannot answer anything else, health checks +included. Formatting is the bulk of it (oxfmt since postgrest-typegen 0.2.0, +much faster than the prettier it replaced but still synchronous) and the string +building ahead of it costs too, so `PG_META_FORMAT_IN_WORKER=true` moves the +whole call to a worker thread (`src/server/typegen-pool.ts`). Metadata crosses the thread boundary as a structured clone, which is plain JSON and does not measurably change wall-clock time. Always off during type generation (`PG_META_GENERATE_TYPES`), which generates once and exits. diff --git a/package-lock.json b/package-lock.json index 77c6fa761..af918c2a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "^0.1.0", + "@supabase/postgrest-typegen": "^0.2.0", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", @@ -1777,6 +1777,334 @@ "@opentelemetry/api": "^1.1.0" } }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz", + "integrity": "sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz", + "integrity": "sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz", + "integrity": "sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz", + "integrity": "sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz", + "integrity": "sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz", + "integrity": "sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz", + "integrity": "sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz", + "integrity": "sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz", + "integrity": "sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz", + "integrity": "sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz", + "integrity": "sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz", + "integrity": "sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz", + "integrity": "sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz", + "integrity": "sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz", + "integrity": "sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz", + "integrity": "sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz", + "integrity": "sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz", + "integrity": "sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz", + "integrity": "sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, "node_modules/@pgsql/types": { "version": "17.6.1", "resolved": "https://registry.npmjs.org/@pgsql/types/-/types-17.6.1.tgz", @@ -2198,19 +2526,79 @@ "license": "MIT" }, "node_modules/@supabase/postgrest-typegen": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/@supabase/postgrest-typegen/-/postgrest-typegen-0.1.0.tgz", - "integrity": "sha512-j0R9IVPlinTCVNx3u6irZFWGsL6Hg/B0pE8zxBV8VWhw/vmH+cbeD6BmyU5wBAh4sJ3Xk145HTgVg7kVnEccZA==", + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-typegen/-/postgrest-typegen-0.2.0.tgz", + "integrity": "sha512-y+dQsjV0D9IVQ2wW0WBl48owyD/88X8dh78XE4rSo1s8MegCOc5/ZNAOkBVICwTySm/hIk2Iw+0zPqJiEh0XQg==", "license": "MIT", "dependencies": { "arktype": "2.2.3", - "pg-format": "1.0.4", - "prettier": "3.5.3" + "oxfmt": "0.65.0", + "pg-format": "1.0.4" }, "engines": { "node": ">=20.0.0" } }, + "node_modules/@supabase/postgrest-typegen/node_modules/oxfmt": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.65.0.tgz", + "integrity": "sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==", + "license": "MIT", + "dependencies": { + "tinypool": "2.1.0" + }, + "bin": { + "oxfmt": "bin/oxfmt" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxfmt/binding-android-arm-eabi": "0.65.0", + "@oxfmt/binding-android-arm64": "0.65.0", + "@oxfmt/binding-darwin-arm64": "0.65.0", + "@oxfmt/binding-darwin-x64": "0.65.0", + "@oxfmt/binding-freebsd-x64": "0.65.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.65.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.65.0", + "@oxfmt/binding-linux-arm64-gnu": "0.65.0", + "@oxfmt/binding-linux-arm64-musl": "0.65.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.65.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.65.0", + "@oxfmt/binding-linux-riscv64-musl": "0.65.0", + "@oxfmt/binding-linux-s390x-gnu": "0.65.0", + "@oxfmt/binding-linux-x64-gnu": "0.65.0", + "@oxfmt/binding-linux-x64-musl": "0.65.0", + "@oxfmt/binding-openharmony-arm64": "0.65.0", + "@oxfmt/binding-win32-arm64-msvc": "0.65.0", + "@oxfmt/binding-win32-ia32-msvc": "0.65.0", + "@oxfmt/binding-win32-x64-msvc": "0.65.0" + }, + "peerDependencies": { + "svelte": "^5.0.0", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "svelte": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/@supabase/postgrest-typegen/node_modules/tinypool": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", + "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "license": "MIT", + "engines": { + "node": "^20.0.0 || >=22.0.0" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/package.json b/package.json index 3712192cb..0d3162812 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@sentry/node": "^9.12.0", "@sentry/profiling-node": "^9.12.0", "@sinclair/typebox": "^0.31.25", - "@supabase/postgrest-typegen": "^0.1.0", + "@supabase/postgrest-typegen": "^0.2.0", "close-with-grace": "^2.1.0", "crypto-js": "^4.0.0", "fastify": "^4.24.3", diff --git a/src/server/typegen-pool.ts b/src/server/typegen-pool.ts index fd27e84b6..345eb0893 100644 --- a/src/server/typegen-pool.ts +++ b/src/server/typegen-pool.ts @@ -14,8 +14,8 @@ import { // `format` is excluded because a function cannot cross the worker boundary: // piscina transfers the task via structured clone, which throws on callbacks. -// Newer postgrest-typegen versions accept a `format` hook; if it is ever -// needed here it must be constructed inside typegen-worker.js instead. +// If a custom `format` hook is ever needed here it must be constructed inside +// typegen-worker.js instead. type WorkerSafeOptions = Omit type TypescriptTask = { @@ -71,12 +71,12 @@ export const isTypegenPoolActive = (): boolean => pool !== null /** * Generates TypeScript types, on a worker thread when enabled. * - * The whole of `generateTypescript` is handed to the worker rather than just - * the prettier pass: prettier is the bulk of the cost (~90% on a 400-table - * schema), but the string building ahead of it is CPU-bound too, and the - * package formats internally with no hook to intercept. Metadata crosses the - * thread boundary as a structured clone, which is plain JSON here and does not - * measurably change wall-clock time. + * The whole of `generateTypescript` is handed to the worker rather than a + * worker-backed `format` hook: formatting is the bulk of the cost (~90% under + * prettier on a 400-table schema; oxfmt, the default since 0.2.0, is much + * faster but still synchronous), and the string building ahead of it is + * CPU-bound too. Metadata crosses the thread boundary as a structured clone, + * which is plain JSON here and does not measurably change wall-clock time. * * Falls back to generating inline when workers are disabled (type-generation * CLI mode, or PG_META_FORMAT_IN_WORKER=false), where blocking is harmless. diff --git a/test/server/typegen.ts b/test/server/typegen.ts index c4778dfd4..0e8f93d99 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -1083,12 +1083,12 @@ test('typegen: typescript', async () => { DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -1112,11 +1112,11 @@ test('typegen: typescript', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -1137,11 +1137,11 @@ test('typegen: typescript', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -1162,11 +1162,11 @@ test('typegen: typescript', async () => { DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { + EnumName extends (DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, + : never) = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -1179,11 +1179,11 @@ test('typegen: typescript', async () => { PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + : never) = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -2316,12 +2316,12 @@ test('typegen w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -2345,11 +2345,11 @@ test('typegen w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -2370,11 +2370,11 @@ test('typegen w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -2395,11 +2395,11 @@ test('typegen w/ one-to-one relationships', async () => { DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { + EnumName extends (DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, + : never) = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -2412,11 +2412,11 @@ test('typegen w/ one-to-one relationships', async () => { PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + : never) = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -3549,12 +3549,12 @@ test('typegen: typescript w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -3578,11 +3578,11 @@ test('typegen: typescript w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -3603,11 +3603,11 @@ test('typegen: typescript w/ one-to-one relationships', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -3628,11 +3628,11 @@ test('typegen: typescript w/ one-to-one relationships', async () => { DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { + EnumName extends (DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, + : never) = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -3645,11 +3645,11 @@ test('typegen: typescript w/ one-to-one relationships', async () => { PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + : never) = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -4787,12 +4787,12 @@ test('typegen: typescript w/ postgrestVersion', async () => { DefaultSchemaTableNameOrOptions extends | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -4816,11 +4816,11 @@ test('typegen: typescript w/ postgrestVersion', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -4841,11 +4841,11 @@ test('typegen: typescript w/ postgrestVersion', async () => { DefaultSchemaTableNameOrOptions extends | keyof DefaultSchema["Tables"] | { schema: keyof DatabaseWithoutInternals }, - TableName extends DefaultSchemaTableNameOrOptions extends { + TableName extends (DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] - : never = never, + : never) = never, > = DefaultSchemaTableNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -4866,11 +4866,11 @@ test('typegen: typescript w/ postgrestVersion', async () => { DefaultSchemaEnumNameOrOptions extends | keyof DefaultSchema["Enums"] | { schema: keyof DatabaseWithoutInternals }, - EnumName extends DefaultSchemaEnumNameOrOptions extends { + EnumName extends (DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] - : never = never, + : never) = never, > = DefaultSchemaEnumNameOrOptions extends { schema: keyof DatabaseWithoutInternals } @@ -4883,11 +4883,11 @@ test('typegen: typescript w/ postgrestVersion', async () => { PublicCompositeTypeNameOrOptions extends | keyof DefaultSchema["CompositeTypes"] | { schema: keyof DatabaseWithoutInternals }, - CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + CompositeTypeName extends (PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] - : never = never, + : never) = never, > = PublicCompositeTypeNameOrOptions extends { schema: keyof DatabaseWithoutInternals } From 8e1f6d598e36e41cd4c8839403b7b6c1f2809da3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 13:31:32 +0000 Subject: [PATCH 13/13] chore: restore master's format-pool naming to minimize the diff The typegen-pool/typegen-worker rename made the route's unchanged 503 load-shedding block show up as a diff against master because the error class inside it changed name. Keep master's file and identifier names (format-pool, format-worker, FormatQueueFullError, destroyFormatPool, isFormatPoolActive) so the only diff left in these files is the delegation itself: the worker task carries generator metadata into @supabase/postgrest-typegen's generateTypescript instead of a prettier payload, and the exported entry point is generateTypescriptTypes instead of format(code, options). No behavior change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0192KEBCPggQgUT7JLPc3Cz1 --- CLAUDE.md | 2 +- package.json | 2 +- .../{typegen-pool.ts => format-pool.ts} | 47 ++++++++------- .../{typegen-worker.js => format-worker.js} | 0 src/server/routes/generators/typescript.ts | 4 +- src/server/server.ts | 6 +- test/index.test.ts | 2 +- .../{typegen-pool.ts => format-pool.ts} | 58 +++++++++---------- 8 files changed, 59 insertions(+), 62 deletions(-) rename src/server/{typegen-pool.ts => format-pool.ts} (68%) rename src/server/{typegen-worker.js => format-worker.js} (100%) rename test/server/{typegen-pool.ts => format-pool.ts} (78%) diff --git a/CLAUDE.md b/CLAUDE.md index 6781649f1..17d10bbae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -168,7 +168,7 @@ the event loop and the server cannot answer anything else, health checks included. Formatting is the bulk of it (oxfmt since postgrest-typegen 0.2.0, much faster than the prettier it replaced but still synchronous) and the string building ahead of it costs too, so `PG_META_FORMAT_IN_WORKER=true` moves the -whole call to a worker thread (`src/server/typegen-pool.ts`). Metadata +whole call to a worker thread (`src/server/format-pool.ts`). Metadata crosses the thread boundary as a structured clone, which is plain JSON and does not measurably change wall-clock time. Always off during type generation (`PG_META_GENERATE_TYPES`), which generates once and exits. diff --git a/package.json b/package.json index 0d3162812..bc0a3ec57 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "check": "tsc -p tsconfig.json --noEmit", "clean": "rimraf dist tsconfig.tsbuildinfo", "format": "prettier --write '{src,test}/**/*.ts' '*.ts'", - "build": "tsc -p tsconfig.json && cpy 'src/lib/sql/*.sql' dist/lib/sql && cpy 'src/server/typegen-worker.js' dist/server --flat", + "build": "tsc -p tsconfig.json && cpy 'src/lib/sql/*.sql' dist/lib/sql && cpy 'src/server/format-worker.js' dist/server --flat", "docs:export": "PG_META_EXPORT_DOCS=true node --loader ts-node/esm src/server/server.ts > openapi.json", "gen:types:typescript": "PG_META_GENERATE_TYPES=typescript node --loader ts-node/esm src/server/server.ts", "gen:types:go": "PG_META_GENERATE_TYPES=go node --loader ts-node/esm src/server/server.ts", diff --git a/src/server/typegen-pool.ts b/src/server/format-pool.ts similarity index 68% rename from src/server/typegen-pool.ts rename to src/server/format-pool.ts index 345eb0893..e215c058c 100644 --- a/src/server/typegen-pool.ts +++ b/src/server/format-pool.ts @@ -15,29 +15,29 @@ import { // `format` is excluded because a function cannot cross the worker boundary: // piscina transfers the task via structured clone, which throws on callbacks. // If a custom `format` hook is ever needed here it must be constructed inside -// typegen-worker.js instead. +// format-worker.js instead. type WorkerSafeOptions = Omit -type TypescriptTask = { +type FormatTask = { metadata: GeneratorMetadata options: WorkerSafeOptions } /** - * Raised when the generation backlog is full. Callers should surface this as a + * Raised when the formatting backlog is full. Callers should surface this as a * 503 rather than a 500: the server is shedding load, not broken. */ -export class TypegenQueueFullError extends Error { +export class FormatQueueFullError extends Error { constructor() { super('Type generation is busy, try again shortly') - this.name = 'TypegenQueueFullError' + this.name = 'FormatQueueFullError' } } -export class TypegenTimeoutError extends Error { +export class FormatTimeoutError extends Error { constructor() { - super(`Generating types timed out after ${FORMAT_TIMEOUT_MS}ms`) - this.name = 'TypegenTimeoutError' + super(`Formatting generated types timed out after ${FORMAT_TIMEOUT_MS}ms`) + this.name = 'FormatTimeoutError' } } @@ -47,10 +47,10 @@ let inFlight = 0 const getPool = (): Piscina => { if (!pool) { pool = new Piscina({ - // typegen-worker is real JavaScript rather than TypeScript: the worker is + // format-worker is real JavaScript rather than TypeScript: the worker is // a fresh thread with no module transform pipeline, so it must be a file // that exists on disk as-is under dev, tests and dist alike. - filename: new URL('./typegen-worker.js', import.meta.url).href, + filename: new URL('./format-worker.js', import.meta.url).href, minThreads: 0, maxThreads: FORMAT_POOL_SIZE, idleTimeout: FORMAT_IDLE_TIMEOUT_MS, @@ -59,24 +59,23 @@ const getPool = (): Piscina => { return pool } -/** Number of generation calls currently running or waiting for a worker. */ +/** Number of format calls currently running or waiting for a worker. */ export const inFlightCount = (): number => inFlight /** - * Whether a worker pool has been created, i.e. generation actually ran off the + * Whether a worker pool has been created, i.e. formatting actually ran off the * main thread rather than inline. The pool is created lazily on first use. */ -export const isTypegenPoolActive = (): boolean => pool !== null +export const isFormatPoolActive = (): boolean => pool !== null /** - * Generates TypeScript types, on a worker thread when enabled. + * Generates and formats TypeScript types, on a worker thread when enabled. * * The whole of `generateTypescript` is handed to the worker rather than a - * worker-backed `format` hook: formatting is the bulk of the cost (~90% under - * prettier on a 400-table schema; oxfmt, the default since 0.2.0, is much - * faster but still synchronous), and the string building ahead of it is - * CPU-bound too. Metadata crosses the thread boundary as a structured clone, - * which is plain JSON here and does not measurably change wall-clock time. + * worker-backed `format` hook: formatting is the bulk of the cost, and the + * string building ahead of it is CPU-bound too. Metadata crosses the thread + * boundary as a structured clone, which is plain JSON here and does not + * measurably change wall-clock time. * * Falls back to generating inline when workers are disabled (type-generation * CLI mode, or PG_META_FORMAT_IN_WORKER=false), where blocking is harmless. @@ -98,18 +97,18 @@ export const generateTypescriptTypes = async ( // pendingCapacity()` and varies with how many workers happen to be spawning. // // Past the limit we shed load immediately, rather than letting callers queue - // up behind a slow generation and time out one by one. + // up behind a slow format and time out one by one. if (inFlight >= FORMAT_MAX_QUEUE) { - throw new TypegenQueueFullError() + throw new FormatQueueFullError() } - const task: TypescriptTask = { metadata, options } + const task: FormatTask = { metadata, options } inFlight++ try { return await getPool().run(task, { signal: AbortSignal.timeout(FORMAT_TIMEOUT_MS) }) } catch (error: any) { if (error?.name === 'AbortError' || error?.name === 'TimeoutError') { - throw new TypegenTimeoutError() + throw new FormatTimeoutError() } throw error } finally { @@ -117,7 +116,7 @@ export const generateTypescriptTypes = async ( } } -export const destroyTypegenPool = async (): Promise => { +export const destroyFormatPool = async (): Promise => { if (pool) { const previous = pool pool = null diff --git a/src/server/typegen-worker.js b/src/server/format-worker.js similarity index 100% rename from src/server/typegen-worker.js rename to src/server/format-worker.js diff --git a/src/server/routes/generators/typescript.ts b/src/server/routes/generators/typescript.ts index e1cb5ee4e..d307e86fa 100644 --- a/src/server/routes/generators/typescript.ts +++ b/src/server/routes/generators/typescript.ts @@ -3,7 +3,7 @@ import { PostgresMeta } from '../../../lib/index.js' import { createConnectionConfig, extractRequestForLogging } from '../../utils.js' import { getGeneratorMetadata } from '../../../lib/generators.js' import { GENERATE_TYPES_DEFAULT_SCHEMA } from '../../constants.js' -import { generateTypescriptTypes, TypegenQueueFullError } from '../../typegen-pool.js' +import { generateTypescriptTypes, FormatQueueFullError } from '../../format-pool.js' export default async (fastify: FastifyInstance) => { fastify.get<{ @@ -43,7 +43,7 @@ export default async (fastify: FastifyInstance) => { } catch (error) { // Anything else is a genuine failure and is already logged and turned // into a 500 by the app-level error handler. - if (!(error instanceof TypegenQueueFullError)) { + if (!(error instanceof FormatQueueFullError)) { throw error } // Load shedding, not a fault: 503 tells the caller it is transient and diff --git a/src/server/server.ts b/src/server/server.ts index 65832fedd..395fa20fb 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -24,7 +24,7 @@ import { generateTypescript, } from '@supabase/postgrest-typegen' import { getGeneratorMetadata } from '../lib/generators.js' -import { destroyTypegenPool } from './typegen-pool.js' +import { destroyFormatPool } from './format-pool.js' const logger = pino({ formatters: { @@ -90,8 +90,8 @@ if (EXPORT_DOCS) { await app.close().catch((err) => app.log.error({ err }, 'Failed to close app')) await adminApp.close().catch((err) => app.log.error({ err }, 'Failed to close adminApp')) // worker threads keep the event loop alive, so the process would not exit - await destroyTypegenPool().catch((err) => - app.log.error({ err }, 'Failed to destroy typegen pool') + await destroyFormatPool().catch((err) => + app.log.error({ err }, 'Failed to destroy format pool') ) }) diff --git a/test/index.test.ts b/test/index.test.ts index 0e6bc93a3..ba38ddb11 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -16,7 +16,7 @@ import './lib/types' import './lib/version' import './lib/views' import './server/column-privileges' -import './server/typegen-pool' +import './server/format-pool' import './server/indexes' import './server/materialized-views' import './server/query' diff --git a/test/server/typegen-pool.ts b/test/server/format-pool.ts similarity index 78% rename from test/server/typegen-pool.ts rename to test/server/format-pool.ts index e3db3bdd4..518fdbb67 100644 --- a/test/server/typegen-pool.ts +++ b/test/server/format-pool.ts @@ -10,12 +10,12 @@ import { afterEach, expect, test, vi } from 'vitest' // // The env vars are read once when constants.ts is evaluated, so each test stubs // the env and re-imports the module graph via vi.resetModules() to pick them up. -const loadTypegenPool = async (env: Record) => { +const loadFormatPool = async (env: Record) => { vi.resetModules() for (const [key, value] of Object.entries(env)) { vi.stubEnv(key, value) } - return import('../../src/server/typegen-pool.js') + return import('../../src/server/format-pool.js') } const column = (tableId: number, table: string, position: number) => ({ @@ -86,48 +86,45 @@ afterEach(() => { }) test('generates on a worker thread with identical output to generating inline', async () => { - const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = - await loadTypegenPool({ - PG_META_FORMAT_IN_WORKER: 'true', - }) + const { generateTypescriptTypes, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ + PG_META_FORMAT_IN_WORKER: 'true', + }) try { - expect(isTypegenPoolActive()).toBe(false) + expect(isFormatPoolActive()).toBe(false) const viaWorker = await generateTypescriptTypes(METADATA, OPTIONS) // without this the test would still pass if generation silently fell back // to running inline, which is the thing being changed - expect(isTypegenPoolActive()).toBe(true) + expect(isFormatPoolActive()).toBe(true) expect(viaWorker).toBe(await generateTypescript(METADATA, OPTIONS)) } finally { - await destroyTypegenPool() + await destroyFormatPool() } }) test('generates inline when the worker is not enabled', async () => { - const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = - await loadTypegenPool({ - PG_META_FORMAT_IN_WORKER: 'false', - }) + const { generateTypescriptTypes, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ + PG_META_FORMAT_IN_WORKER: 'false', + }) try { expect(await generateTypescriptTypes(METADATA, OPTIONS)).toBe( await generateTypescript(METADATA, OPTIONS) ) // no pool was ever created, so generation ran on the main thread - expect(isTypegenPoolActive()).toBe(false) + expect(isFormatPoolActive()).toBe(false) } finally { - await destroyTypegenPool() + await destroyFormatPool() } }) test('never generates on a worker in type-generation mode', async () => { - const { generateTypescriptTypes, destroyTypegenPool, isTypegenPoolActive } = - await loadTypegenPool({ - PG_META_FORMAT_IN_WORKER: 'true', - PG_META_GENERATE_TYPES: 'typescript', - }) + const { generateTypescriptTypes, destroyFormatPool, isFormatPoolActive } = await loadFormatPool({ + PG_META_FORMAT_IN_WORKER: 'true', + PG_META_GENERATE_TYPES: 'typescript', + }) try { // one-shot CLI generation has no event loop to protect, and a pool would @@ -135,19 +132,20 @@ test('never generates on a worker in type-generation mode', async () => { expect(await generateTypescriptTypes(METADATA, OPTIONS)).toBe( await generateTypescript(METADATA, OPTIONS) ) - expect(isTypegenPoolActive()).toBe(false) + expect(isFormatPoolActive()).toBe(false) } finally { - await destroyTypegenPool() + await destroyFormatPool() } }) -test('sheds load with TypegenQueueFullError once the in-flight limit is reached', async () => { - const { generateTypescriptTypes, destroyTypegenPool, TypegenQueueFullError } = - await loadTypegenPool({ +test('sheds load with FormatQueueFullError once the in-flight limit is reached', async () => { + const { generateTypescriptTypes, destroyFormatPool, FormatQueueFullError } = await loadFormatPool( + { PG_META_FORMAT_IN_WORKER: 'true', PG_META_FORMAT_POOL_SIZE: '1', PG_META_FORMAT_MAX_QUEUE: '2', - }) + } + ) // big enough that generating takes long enough for calls to overlap const big = metadata(50) @@ -162,15 +160,15 @@ test('sheds load with TypegenQueueFullError once the in-flight limit is reached' expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(2) expect(rejected).toHaveLength(4) for (const result of rejected) { - expect((result as PromiseRejectedResult).reason).toBeInstanceOf(TypegenQueueFullError) + expect((result as PromiseRejectedResult).reason).toBeInstanceOf(FormatQueueFullError) } } finally { - await destroyTypegenPool() + await destroyFormatPool() } }) test('counts a generation call while it is in flight and releases it afterwards', async () => { - const { generateTypescriptTypes, destroyTypegenPool, inFlightCount } = await loadTypegenPool({ + const { generateTypescriptTypes, destroyFormatPool, inFlightCount } = await loadFormatPool({ PG_META_FORMAT_IN_WORKER: 'true', PG_META_FORMAT_MAX_QUEUE: '2', }) @@ -188,6 +186,6 @@ test('counts a generation call while it is in flight and releases it afterwards' expect(inFlightCount()).toBe(0) await expect(generateTypescriptTypes(METADATA, OPTIONS)).resolves.toBeTypeOf('string') } finally { - await destroyTypegenPool() + await destroyFormatPool() } })