From f01949e3796b3a03884447b81e64874151f0948a Mon Sep 17 00:00:00 2001 From: Maliik-B Date: Wed, 17 Jun 2026 21:01:03 -0400 Subject: [PATCH 1/3] test(postgrest): add int8/bigint precision e2e over PostgREST Adds test/bigint.test.ts and a bigint_precision fixture demonstrating int8 behavior around Number.MAX_SAFE_INTEGER on read (with and without a ::text cast) and on write. Refs: supabase/postgres-meta#1083 --- .../core/postgrest-js/test/bigint.test.ts | 119 ++++++++++++++++++ .../00000000000001_bigint_precision.sql | 13 ++ .../core/postgrest-js/test/types.generated.ts | 15 +++ 3 files changed, 147 insertions(+) create mode 100644 packages/core/postgrest-js/test/bigint.test.ts create mode 100644 packages/core/postgrest-js/test/supabase/migrations/00000000000001_bigint_precision.sql diff --git a/packages/core/postgrest-js/test/bigint.test.ts b/packages/core/postgrest-js/test/bigint.test.ts new file mode 100644 index 0000000000..4e2a1883e2 --- /dev/null +++ b/packages/core/postgrest-js/test/bigint.test.ts @@ -0,0 +1,119 @@ +import { PostgrestClient } from '../src/index' +import { Database } from './types.override' +import { expectType, TypeEqual } from './types' +import { z } from 'zod' + +const REST_URL = 'http://localhost:54321/rest/v1' +const postgrest = new PostgrestClient(REST_URL) + +// 2^63 - 1, the largest int8 value, well above Number.MAX_SAFE_INTEGER (2^53 - 1). +const MAX_INT8 = '9223372036854775807' +// Another value above 2^53, used for the write-path tests. +const ABOVE_MAX_SAFE = '9007199254740993' + +describe('int8/bigint precision over PostgREST', () => { + test('reading int8 without a cast returns a lossy JS number', async () => { + const res = await postgrest.from('bigint_precision').select('big_value').eq('id', 1).single() + + // PostgREST emits int8 as a JSON number, and JSON.parse rounds it to the nearest double, so + // the exact value is already lost by the time it reaches the client. + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "big_value": 9223372036854776000, + }, + "error": null, + "status": 200, + "statusText": "OK", + "success": true, + } + `) + expect(String(res.data?.big_value)).not.toBe(MAX_INT8) + + let result: Exclude + const ExpectedSchema = z.object({ big_value: z.number() }) + let expected: z.infer + expectType>(true) + }) + + test('reading int8 cast to text is lossless', async () => { + const res = await postgrest + .from('bigint_precision') + // Casting to text makes PostgREST return a JSON string, which survives JSON.parse intact. + .select('big_value::text') + .eq('id', 1) + .single() + + expect(res).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "big_value": "9223372036854775807", + }, + "error": null, + "status": 200, + "statusText": "OK", + "success": true, + } + `) + expect(res.data?.big_value).toBe(MAX_INT8) + + let result: Exclude + const ExpectedSchema = z.object({ big_value: z.string() }) + let expected: z.infer + expectType>(true) + }) + + test('writing int8 above 2^53 as a string round-trips losslessly', async () => { + const updated = await postgrest + .from('bigint_precision') + // big_value is generated as `number`; the postgres-meta `bigint_as=string` option + // (supabase/postgres-meta#1078) would type the Insert/Update column as `string`, so this + // lossless string form would need no cast. + // @ts-expect-error intentionally exercising the string write the default `number` type rejects. + .update({ big_value: ABOVE_MAX_SAFE }) + .eq('id', 2) + .select('big_value::text') + .single() + + expect(updated).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "big_value": "9007199254740993", + }, + "error": null, + "status": 200, + "statusText": "OK", + "success": true, + } + `) + expect(updated.data?.big_value).toBe(ABOVE_MAX_SAFE) + }) + + test('writing int8 above 2^53 as a JS number loses precision before the request is sent', async () => { + // The anti-pattern: a JS number is already rounded before serialization, so PostgREST + // faithfully stores the wrong value. + const updated = await postgrest + .from('bigint_precision') + .update({ big_value: Number(ABOVE_MAX_SAFE) }) + .eq('id', 3) + .select('big_value::text') + .single() + + expect(updated).toMatchInlineSnapshot(` + { + "count": null, + "data": { + "big_value": "9007199254740992", + }, + "error": null, + "status": 200, + "statusText": "OK", + "success": true, + } + `) + expect(updated.data?.big_value).not.toBe(ABOVE_MAX_SAFE) + }) +}) diff --git a/packages/core/postgrest-js/test/supabase/migrations/00000000000001_bigint_precision.sql b/packages/core/postgrest-js/test/supabase/migrations/00000000000001_bigint_precision.sql new file mode 100644 index 0000000000..1617aee154 --- /dev/null +++ b/packages/core/postgrest-js/test/supabase/migrations/00000000000001_bigint_precision.sql @@ -0,0 +1,13 @@ +-- Fixture for the int8/bigint precision e2e (test/bigint.test.ts). It demonstrates how PostgREST +-- handles values above Number.MAX_SAFE_INTEGER (2^53 - 1) on read (with and without a ::text cast) +-- and on write. This is the runtime behavior the postgres-meta `bigint_as` typegen option describes +-- (supabase/postgres-meta#1078). +create table public.bigint_precision ( + id int primary key, + big_value int8 not null +); + +insert into public.bigint_precision (id, big_value) values + (1, 9223372036854775807), -- 2^63 - 1; read fixture, not mutated by the tests + (2, 0), -- string-write target + (3, 0); -- number-write (anti-pattern) target diff --git a/packages/core/postgrest-js/test/types.generated.ts b/packages/core/postgrest-js/test/types.generated.ts index 68a907fea2..ac86da5e93 100644 --- a/packages/core/postgrest-js/test/types.generated.ts +++ b/packages/core/postgrest-js/test/types.generated.ts @@ -149,6 +149,21 @@ export type Database = { }, ] } + bigint_precision: { + Row: { + big_value: number + id: number + } + Insert: { + big_value: number + id: number + } + Update: { + big_value?: number + id?: number + } + Relationships: [] + } booking: { Row: { hotel_id: number | null From d936dcc7ef4a79eb34182c69983ca670e8ebe559 Mon Sep 17 00:00:00 2001 From: Maliik-B Date: Thu, 18 Jun 2026 09:30:43 -0400 Subject: [PATCH 2/3] test(postgrest): aligning @ts-expect-error with tstyche message --- packages/core/postgrest-js/test/bigint.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/postgrest-js/test/bigint.test.ts b/packages/core/postgrest-js/test/bigint.test.ts index 4e2a1883e2..719fbc259a 100644 --- a/packages/core/postgrest-js/test/bigint.test.ts +++ b/packages/core/postgrest-js/test/bigint.test.ts @@ -68,10 +68,10 @@ describe('int8/bigint precision over PostgREST', () => { test('writing int8 above 2^53 as a string round-trips losslessly', async () => { const updated = await postgrest .from('bigint_precision') - // big_value is generated as `number`; the postgres-meta `bigint_as=string` option - // (supabase/postgres-meta#1078) would type the Insert/Update column as `string`, so this - // lossless string form would need no cast. - // @ts-expect-error intentionally exercising the string write the default `number` type rejects. + // big_value is generated as `number`, so the lossless string form is a type error today. + // The postgres-meta `bigint_as=string` option (supabase/postgres-meta#1078) would type the + // Insert/Update column as `string`, which is the gap this exercises. + // @ts-expect-error Type 'string' is not assignable to type 'number'. .update({ big_value: ABOVE_MAX_SAFE }) .eq('id', 2) .select('big_value::text') From 31edba5988261906f52fec76250ae34a44dfff3a Mon Sep 17 00:00:00 2001 From: Maliik-B Date: Thu, 18 Jun 2026 10:43:52 -0400 Subject: [PATCH 3/3] test(postgrest): demonstrate proposed bigint_as write-type DX --- .../core/postgrest-js/test/bigint.test-d.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/core/postgrest-js/test/bigint.test-d.ts diff --git a/packages/core/postgrest-js/test/bigint.test-d.ts b/packages/core/postgrest-js/test/bigint.test-d.ts new file mode 100644 index 0000000000..2b073a19f7 --- /dev/null +++ b/packages/core/postgrest-js/test/bigint.test-d.ts @@ -0,0 +1,63 @@ +import { PostgrestClient } from '../src' +import { Database } from './types.override' +import { expectType, TypeEqual } from './types' +import { MergeDeep } from 'type-fest' + +const REST_URL = 'http://localhost:54321' + +// Demonstrates the DX of the proposed `bigint_as=number|bigint` option for supabase/postgres-meta#1083. +// The int8 column stays `number` on the Row (the un-cast wire value is a lossy number, and `::text` +// already infers `string` on the casted column), and widens to `number | bigint` on Insert/Update, +// where `bigint` is the lossless channel (postgrest-js serializes a BigInt to a JSON string). +type ProposedDatabase = MergeDeep< + Database, + { + public: { + Tables: { + bigint_precision: { + Insert: { big_value: number | bigint } + Update: { big_value?: number | bigint } + } + } + } + } +> + +const proposed = new PostgrestClient(REST_URL) + +// Row is unchanged: big_value stays `number` on a plain select. +{ + const row = await proposed.from('bigint_precision').select('big_value').single() + if (row.error) { + throw new Error(row.error.message) + } + expectType>(true) +} + +// Casting to text returns the exact value as a `string`, as before. +{ + const casted = await proposed.from('bigint_precision').select('big_value::text').single() + if (casted.error) { + throw new Error(casted.error.message) + } + expectType>(true) +} + +// A BigInt write type-checks: the lossless channel for values above 2^53. +{ + proposed.from('bigint_precision').update({ big_value: 9007199254740993n }).eq('id', 1) +} + +// A plain number write still type-checks: the ergonomic common case. +{ + proposed.from('bigint_precision').update({ big_value: 42 }).eq('id', 1) +} + +// A string write is rejected at compile time, which is why `string` is left out of the set. +{ + proposed + .from('bigint_precision') + // @ts-expect-error Type 'string' is not assignable to type 'number | bigint | undefined'. + .update({ big_value: '9007199254740993' }) + .eq('id', 1) +}