diff --git a/src/server/constants.ts b/src/server/constants.ts index c64b45e6..6a40d12d 100644 --- a/src/server/constants.ts +++ b/src/server/constants.ts @@ -50,6 +50,7 @@ export const GENERATE_TYPES_SWIFT_ACCESS_CONTROL = process.env .PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL ? (process.env.PG_META_GENERATE_TYPES_SWIFT_ACCESS_CONTROL as AccessControl) : 'internal' +export const GENERATE_TYPES_BIGINT_AS = process.env.PG_META_GENERATE_TYPES_BIGINT_AS ?? 'number' // json/jsonb/text types export const VALID_UNNAMED_FUNCTION_ARG_TYPES = new Set([114, 3802, 25]) diff --git a/src/server/routes/generators/typescript.ts b/src/server/routes/generators/typescript.ts index 259cd141..ce721572 100644 --- a/src/server/routes/generators/typescript.ts +++ b/src/server/routes/generators/typescript.ts @@ -12,6 +12,7 @@ export default async (fastify: FastifyInstance) => { included_schemas?: string detect_one_to_one_relationships?: string postgrest_version?: string + bigint_as?: string } }>('/', async (request, reply) => { const config = createConnectionConfig(request) @@ -21,6 +22,7 @@ export default async (fastify: FastifyInstance) => { request.query.included_schemas?.split(',').map((schema) => schema.trim()) ?? [] const detectOneToOneRelationships = request.query.detect_one_to_one_relationships === 'true' const postgrestVersion = request.query.postgrest_version + const bigintAs = request.query.bigint_as ?? 'number' const pgMeta: PostgresMeta = new PostgresMeta(config) const { data: generatorMeta, error: generatorMetaError } = await getGeneratorMetadata(pgMeta, { @@ -37,6 +39,7 @@ export default async (fastify: FastifyInstance) => { ...generatorMeta, detectOneToOneRelationships, postgrestVersion, + bigintAs, }) }) } diff --git a/src/server/server.ts b/src/server/server.ts index 68fbb54c..9c64daf9 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_BIGINT_AS, GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS, GENERATE_TYPES_INCLUDED_SCHEMAS, GENERATE_TYPES_SWIFT_ACCESS_CONTROL, @@ -136,7 +137,7 @@ async function getTypeOutput(): Promise { switch (GENERATE_TYPES?.toLowerCase()) { case 'typescript': - return await applyTypescriptTemplate(config) + return await applyTypescriptTemplate({ ...config, bigintAs: GENERATE_TYPES_BIGINT_AS }) case 'swift': return await applySwiftTemplate({ ...config, diff --git a/src/server/templates/typescript.ts b/src/server/templates/typescript.ts index 352c4ddc..ecc6546e 100644 --- a/src/server/templates/typescript.ts +++ b/src/server/templates/typescript.ts @@ -31,9 +31,11 @@ export const apply = async ({ types, detectOneToOneRelationships, postgrestVersion, + bigintAs = 'number', }: GeneratorMetadata & { detectOneToOneRelationships: boolean postgrestVersion?: string + bigintAs?: string }): Promise => { schemas.sort((a, b) => a.name.localeCompare(b.name)) relationships.sort( @@ -279,6 +281,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, }) } return { name, type: tsType } @@ -314,6 +317,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, } ) ) @@ -329,6 +333,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, }) } @@ -426,6 +431,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, }) } return { name, type: tsType, has_default } @@ -445,6 +451,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, }) } return { name, type: tsType, has_default } @@ -462,6 +469,7 @@ export const apply = async ({ schemas, tables, views, + bigintAs, }) } return { name, type: tsType, has_default } @@ -503,6 +511,8 @@ export const apply = async ({ schemas: PostgresSchema[] tables: PostgresTable[] views: PostgresView[] + bigintAs: string + isWriteColumn?: boolean } ) { return `${JSON.stringify(column.name)}${column.is_optional ? '?' : ''}: ${generateNullableUnionTsType(pgTypeToTsType(schema, column.format, context), column.is_nullable)}` @@ -539,7 +549,7 @@ export type Database = { is_nullable: column.is_nullable, is_optional: false, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs } ) ), ...schemaFunctions @@ -565,7 +575,7 @@ export type Database = { column.is_identity || column.default_value !== null, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs, isWriteColumn: true } ) })} } @@ -583,7 +593,7 @@ export type Database = { is_nullable: column.is_nullable, is_optional: true, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs, isWriteColumn: true } ) })} } @@ -611,7 +621,7 @@ export type Database = { is_nullable: column.is_nullable, is_optional: false, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs } ) ), ...schemaFunctions @@ -637,7 +647,7 @@ export type Database = { is_nullable: true, is_optional: true, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs, isWriteColumn: true } ) })} } @@ -654,7 +664,7 @@ export type Database = { is_nullable: true, is_optional: true, }, - { types, schemas, tables, views } + { types, schemas, tables, views, bigintAs, isWriteColumn: true } ) })} } @@ -725,6 +735,7 @@ export type Database = { schemas, tables, views, + bigintAs, }), true )}` @@ -868,6 +879,22 @@ export const Constants = { return output } +// `bigintAs` is a pipe-delimited set of TypeScript representations for int8/numeric columns on +// Insert/Update, for example "number|bigint". Only `number` and `bigint` are accepted: `bigint` +// is the lossless write channel (postgrest-js serializes it to a JSON string), while `number` +// keeps the ergonomic path for small values. `string` is deliberately excluded, since an +// arbitrary non-numeric string would pass the type check and only fail at runtime in PostgREST. +// Unknown tokens are ignored, and an empty result falls back to "number". +const BIGINT_AS_REPRESENTATIONS = ['number', 'bigint'] +export const bigintAsToTsType = (bigintAs: string): string => { + const representations = bigintAs + .split('|') + .map((value) => value.trim()) + .filter((value) => BIGINT_AS_REPRESENTATIONS.includes(value)) + const unique = [...new Set(representations)] + return unique.length > 0 ? unique.join(' | ') : 'number' +} + // TODO: Make this more robust. Currently doesn't handle range types - returns them as unknown. export const pgTypeToTsType = ( schema: PostgresSchema, @@ -877,17 +904,26 @@ export const pgTypeToTsType = ( schemas, tables, views, + bigintAs = 'number', + isWriteColumn = false, }: { types: PostgresType[] schemas: PostgresSchema[] tables: PostgresTable[] views: PostgresView[] + bigintAs?: string + isWriteColumn?: boolean } ): string => { if (pgType === 'bool') { return 'boolean' - } else if (['int2', 'int4', 'int8', 'float4', 'float8', 'numeric'].includes(pgType)) { + } else if (['int2', 'int4', 'float4', 'float8'].includes(pgType)) { return 'number' + } else if (['int8', 'numeric'].includes(pgType)) { + // int8/numeric can exceed Number.MAX_SAFE_INTEGER. On reads they stay `number` to match the + // (lossy) JSON number PostgREST returns; cast to ::text for the exact value. On writes the + // column is widened to the `bigintAs` representations so callers can pass a lossless form. + return isWriteColumn ? bigintAsToTsType(bigintAs) : 'number' } else if ( [ 'bytea', @@ -918,6 +954,8 @@ export const pgTypeToTsType = ( schemas, tables, views, + bigintAs, + isWriteColumn, })})[]` } else { const enumTypes = types.filter((type) => type.name === pgType && type.enums.length > 0) diff --git a/test/server/typegen.ts b/test/server/typegen.ts index 50a0896b..20f091ae 100644 --- a/test/server/typegen.ts +++ b/test/server/typegen.ts @@ -3647,6 +3647,42 @@ test('typegen: typescript w/ one-to-one relationships', async () => { ) }) +test('typegen: typescript w/ bigintAs defaults to number', async () => { + const { body } = await app.inject({ method: 'GET', path: '/generators/typescript' }) + // Back-compat: without the flag, int8 and numeric generate as `number` on reads and writes. + expect(body).toContain('"user-id": number') // int8, Row + expect(body).toContain('"user-id"?: number') // int8, Insert/Update + expect(body).toContain('days_since_event: number | null') // numeric (computed), Row +}) + +test('typegen: typescript w/ bigint_as=number|bigint widens writes only', async () => { + const { body } = await app.inject({ + method: 'GET', + path: '/generators/typescript', + query: { bigint_as: 'number|bigint' }, + }) + // int8/numeric can exceed Number.MAX_SAFE_INTEGER. Insert/Update widen to the union so callers + // can pass a lossless BigInt (postgrest-js serializes it to a JSON string). + expect(body).toContain('"user-id"?: number | bigint') // int8, Update + // Reads stay `number`: PostgREST returns int8 as a lossy JSON number; cast to ::text for the exact value. + expect(body).toContain('"user-id": number') // int8, Row unchanged + expect(body).toContain('days_since_event: number | null') // numeric (computed), Row unchanged +}) + +test('typegen: typescript w/ bigint_as=bigint widens writes only', async () => { + const { body } = await app.inject({ + method: 'GET', + path: '/generators/typescript', + query: { bigint_as: 'bigint' }, + }) + // `bigint` is the lossless write channel: postgrest-js serializes a BigInt to a JSON string, + // so values past Number.MAX_SAFE_INTEGER go in intact. It still only widens writes. + expect(body).toContain('"user-id"?: bigint') // int8, Update + // Reads stay `number`: PostgREST returns int8 as a lossy JSON number; cast to ::text for the exact value. + expect(body).toContain('"user-id": number') // int8, Row unchanged + expect(body).toContain('days_since_event: number | null') // numeric (computed), Row unchanged +}) + test('typegen: typescript w/ postgrestVersion', async () => { const { body } = await app.inject({ method: 'GET',