Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions packages/core/postgrest-js/test/bigint.test-d.ts
Original file line number Diff line number Diff line change
@@ -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<ProposedDatabase>(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<TypeEqual<(typeof row.data)['big_value'], number>>(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<TypeEqual<(typeof casted.data)['big_value'], string>>(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)
}
119 changes: 119 additions & 0 deletions packages/core/postgrest-js/test/bigint.test.ts
Original file line number Diff line number Diff line change
@@ -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<Database>(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<typeof res.data, null>
const ExpectedSchema = z.object({ big_value: z.number() })
let expected: z.infer<typeof ExpectedSchema>
expectType<TypeEqual<typeof result, typeof expected>>(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<typeof res.data, null>
const ExpectedSchema = z.object({ big_value: z.string() })
let expected: z.infer<typeof ExpectedSchema>
expectType<TypeEqual<typeof result, typeof expected>>(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`, 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')
.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)
})
})
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions packages/core/postgrest-js/test/types.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down