SQLite stores integers as 64-bit signed values. A JavaScript number can only represent integers exactly between -9007199254740991 and 9007199254740991 (2^53 - 1, Number.MAX_SAFE_INTEGER); anything past that boundary gets rounded. Sirannon bridges the gap the same way on every runtime with BigInt support: integers inside the safe range come back as plain number values, and anything larger comes back as an exact bigint. This page builds one file, integers.ts, that stores and reads a value past the safe boundary, then looks at how each driver behaves and what to do on Expo, where BigInt does not exist.
Store a value past the safe range
Insert a shipment whose tracking code is 9007199254740993, the smallest positive integer a double cannot hold. Write the literal with the n suffix: a plain number literal with the same digits would be rounded to 9007199254740992 before it ever reaches the driver. Start integers.ts here.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('logistics', './data/logistics.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS shipments (id INTEGER PRIMARY KEY, tracking_code INTEGER NOT NULL, parcels INTEGER NOT NULL)'
)
await db.execute('INSERT INTO shipments (tracking_code, parcels) VALUES (?, ?)', [
9007199254740993n,
12,
])Read the exact value back
Query the row and check the types of both columns.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const db = await sirannon.open('logistics', './data/logistics.db')
await db.execute(
'CREATE TABLE IF NOT EXISTS shipments (id INTEGER PRIMARY KEY, tracking_code INTEGER NOT NULL, parcels INTEGER NOT NULL)'
)
await db.execute('INSERT INTO shipments (tracking_code, parcels) VALUES (?, ?)', [
9007199254740993n,
12,
])
const rows = await db.query<{ tracking_code: bigint; parcels: number }>(
'SELECT tracking_code, parcels FROM shipments'
)
console.log(rows[0])
console.log(typeof rows[0].tracking_code, typeof rows[0].parcels){ tracking_code: 9007199254740993n, parcels: 12 }
bigint numberThe tracking code comes back as a bigint with every digit intact, and the parcel count is an ordinary number. The boundary is inclusive: 9007199254740991 itself, or its negative, is still a plain number, and the first value past either end becomes bigint.
How each driver behaves
| Driver | Behaviour |
|---|---|
@delali/sirannon-db/driver/better-sqlite3 | The driver turns on better-sqlite3's defaultSafeIntegers so that integers arrive as bigint, then narrows safe-range values back to number. |
@delali/sirannon-db/driver/node | The driver enables setReadBigInts on every statement of the built-in node:sqlite module and applies the same narrowing. |
@delali/sirannon-db/driver/bun | The driver opens bun:sqlite with safeIntegers: true and applies the same narrowing. |
@delali/sirannon-db/driver/wa-sqlite | wa-sqlite conforms on its own: every value it returns as a number is exact, and a value a double cannot hold arrives as bigint, so the driver passes results straight through. |
@delali/sirannon-db/driver/expo | expo-sqlite has no BigInt support, so a value past the safe range loses precision and returns as the nearest double. |
None of this breaks down over the network either. A server backed by any of the first four drivers wraps large integers in a tagged JSON envelope, covered in value encoding over JSON, and the client SDK unwraps them back into bigint on the other side.
Plan for Expo
expo-sqlite has no BigInt support at all. Its bind values are strings, numbers, booleans, null, and Uint8Array, so every integer it reads comes back as a double. Store 9007199254740993 on Expo and you read back 9007199254740992, the closest value a double can hold. Plain JavaScript shows the same rounding.
const stored = 9007199254740993n
console.log(Number(stored))9007199254740992The practical fix is to store the big value as text. Keep numeric columns inside the safe range, and store anything larger, such as an order reference from a payment provider, as TEXT and compare it as a string. The text survives storage, queries, and replication byte for byte. You give up numeric ordering and SQL arithmetic on that column, which rarely matters for identifiers.