JSON represents two SQLite value types only with loss. Numbers pass through , so a parser rounds an integer beyond 9007199254740991 (2^53 - 1) in either direction, and binary data has no JSON representation at all. Sirannon solves both with tagged envelopes, so wherever a row or a bind parameter crosses the network, on HTTP and WebSocket alike, it wraps a large integer or a BLOB in a small tagged object. The client SDK encodes and decodes these automatically, so your code only ever handles bigint and Buffer. This page builds one file, values.ts, step by step. You will store a 64-bit reference and a binary checksum through the client SDK, read the raw JSON the server sends, and then decode the envelopes in Python without the SDK.
Sirannon writes the database file into a directory you create yourself, so make one before you run any of the code below.
mkdir -p dataStand up a ledger
The envelopes only exist on the network, so the demonstration requires a running server. Open a ledger database, give it a transfers table with an INTEGER reference and a BLOB checksum, and serve it on the default port. A server refuses SQL sent over the network unless you pass acceptSql: true, and this page sends SQL from a client, so set it here; the server page covers calling registered operations by name. Clearing the table first keeps reruns reproducible, because every run of the finished file then serves the same single row. Start values.ts here.
import { Sirannon } from '@delali/sirannon-db'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const ledger = await sirannon.open('ledger', './data/ledger.db')
await ledger.execute(
'CREATE TABLE IF NOT EXISTS transfers (id INTEGER PRIMARY KEY, reference INTEGER NOT NULL, checksum BLOB NOT NULL, amount INTEGER NOT NULL)'
)
await ledger.execute('DELETE FROM transfers')
const server = createServer(sirannon, { port: 9876, acceptSql: true })
await server.listen()Write and read through the client SDK
Connect a client and insert a transfer whose reference is 9007199254740993, the smallest positive integer a double cannot hold. Pass the reference as a bigint and the checksum as a Buffer; the transport wraps both on the way out and unwraps the row on the way back.
import { Sirannon } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const ledger = await sirannon.open('ledger', './data/ledger.db')
await ledger.execute(
'CREATE TABLE IF NOT EXISTS transfers (id INTEGER PRIMARY KEY, reference INTEGER NOT NULL, checksum BLOB NOT NULL, amount INTEGER NOT NULL)'
)
await ledger.execute('DELETE FROM transfers')
const server = createServer(sirannon, { port: 9876, acceptSql: true })
await server.listen()
const client = new SirannonClient('http://localhost:9876', { transport: 'http' })
const db = client.database('ledger')
await db.execute('INSERT INTO transfers (reference, checksum, amount) VALUES (?, ?, ?)', [
9007199254740993n,
Buffer.from([0xde, 0xad, 0xbe, 0xef]),
250,
])
const rows = await db.query<{ reference: bigint; checksum: Buffer; amount: number }>(
'SELECT reference, checksum, amount FROM transfers'
)
console.log(rows[0]){
reference: 9007199254740993n,
checksum: <Buffer de ad be ef>,
amount: 250
}The row comes back with native values: the reference as an exact bigint, the checksum as a Buffer, and the amount as a plain number, because Sirannon never wraps an integer inside the safe range. In the browser, where Buffer does not exist, blobs decode to Uint8Array, and both Buffer and Uint8Array work as bind parameters.
Look at the raw JSON
The SDK hides the envelopes, so fetch the same rows with plain fetch to see the exact JSON the server sends. The finished file keeps serving after it prints, and the decoders in the next section query it live; stop it with Ctrl+C when you are done.
import { Sirannon } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client'
import { betterSqlite3 } from '@delali/sirannon-db/driver/better-sqlite3'
import { createServer } from '@delali/sirannon-db/server'
const driver = betterSqlite3()
const sirannon = new Sirannon({ driver })
const ledger = await sirannon.open('ledger', './data/ledger.db')
await ledger.execute(
'CREATE TABLE IF NOT EXISTS transfers (id INTEGER PRIMARY KEY, reference INTEGER NOT NULL, checksum BLOB NOT NULL, amount INTEGER NOT NULL)'
)
await ledger.execute('DELETE FROM transfers')
const server = createServer(sirannon, { port: 9876, acceptSql: true })
await server.listen()
const client = new SirannonClient('http://localhost:9876', { transport: 'http' })
const db = client.database('ledger')
await db.execute('INSERT INTO transfers (reference, checksum, amount) VALUES (?, ?, ?)', [
9007199254740993n,
Buffer.from([0xde, 0xad, 0xbe, 0xef]),
250,
])
const rows = await db.query<{ reference: bigint; checksum: Buffer; amount: number }>(
'SELECT reference, checksum, amount FROM transfers'
)
console.log(rows[0])
const response = await fetch('http://localhost:9876/db/ledger/query', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sql: 'SELECT reference, checksum, amount FROM transfers' }),
})
if (!response.ok) {
throw new Error(`query failed with status ${response.status}`)
}
console.log(await response.text()){"rows":[{"reference":{"__sirannon_int":"9007199254740993"},"checksum":{"__sirannon_blob":"DEADBEEF"},"amount":250}]}The reference comes back as {"__sirannon_int":"9007199254740993"}, the checksum as {"__sirannon_blob":"DEADBEEF"}, and the amount as an ordinary JSON number.
The envelope rules
An envelope is a JSON object with exactly one key, either __sirannon_int or __sirannon_blob, whose value is a string. Envelopes only appear in positions where a column value is expected, and a stored TEXT value resembling one serialises as a JSON string, never as an object, so the two cannot collide.
- An
__sirannon_intpayload is the exact decimal representation of the integer: an optional leading minus sign followed by one to nineteen digits, with no whitespace, plus sign, or exponent. - Sirannon wraps only the integers outside the safe range. Values from -9007199254740991 to 9007199254740991 stay plain JSON numbers, and every other type keeps its natural JSON form.
- An
__sirannon_blobpayload is uppercase hexadecimal with two digits per byte, and an empty string encodes an empty BLOB. - Envelopes appear in query result rows on both transports, in the
rowandoldRowfields of change events, in bind parameters (params, per-statementparamsentries, andparamsBatch), and in subscription filter values, where an integer envelope matches rows holding that exact 64-bit value. - The server rejects a malformed envelope in bind parameters. Over HTTP the response is status 400 with code
INVALID_REQUEST, and over WebSocket it is an error with codeINVALID_MESSAGE.
Decode without the SDK
In JavaScript the client SDK already decodes every envelope, so decoding by hand only comes up outside JavaScript, where a service reads the server's plain HTTP or WebSocket API directly. Any JSON library can handle the format: decode __sirannon_int into a 64-bit signed integer, which every SQLite integer fits, and decode __sirannon_blob into a byte array. In a language without a native 64-bit integer, use an arbitrary-precision type such as Python's int. With values.ts still serving, this Python decoder runs the same query over HTTP with only the standard library and decodes the row. The script needs no status check, because urlopen raises HTTPError on any non-2xx response, so a failed query stops it before it decodes anything.
import json
from urllib import request
INT_TAG = '__sirannon_int'
BLOB_TAG = '__sirannon_blob'
def decode_value(value):
if isinstance(value, dict) and len(value) == 1:
if INT_TAG in value and isinstance(value[INT_TAG], str):
return int(value[INT_TAG])
if BLOB_TAG in value and isinstance(value[BLOB_TAG], str):
return bytes.fromhex(value[BLOB_TAG])
return value
query = request.Request(
'http://localhost:9876/db/ledger/query',
data=json.dumps({'sql': 'SELECT reference, checksum, amount FROM transfers'}).encode(),
headers={'content-type': 'application/json'},
)
with request.urlopen(query) as response:
payload = json.load(response)
rows = [{column: decode_value(value) for column, value in row.items()} for row in payload['rows']]
print(rows[0]){'reference': 9007199254740993, 'checksum': b'\xde\xad\xbe\xef', 'amount': 250}The same rules translate to any language with a 64-bit integer type. In Go the standard library is enough: net/http makes the call, strconv.ParseInt handles the integer envelope, and encoding/hex handles the blob.
package main
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
)
const intTag = "__sirannon_int"
const blobTag = "__sirannon_blob"
func decodeValue(value any) any {
envelope, ok := value.(map[string]any)
if !ok || len(envelope) != 1 {
return value
}
if payload, ok := envelope[intTag].(string); ok {
if number, err := strconv.ParseInt(payload, 10, 64); err == nil {
return number
}
}
if payload, ok := envelope[blobTag].(string); ok {
if blob, err := hex.DecodeString(payload); err == nil {
return blob
}
}
return value
}
func main() {
body := []byte(`{"sql":"SELECT reference, checksum, amount FROM transfers"}`)
response, err := http.Post("http://localhost:9876/db/ledger/query", "application/json", bytes.NewReader(body))
if err != nil {
panic(err)
}
defer response.Body.Close()
if response.StatusCode > 299 {
panic(fmt.Errorf("query failed with status %s", response.Status))
}
var payload struct {
Rows []map[string]any `json:"rows"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
panic(err)
}
row := payload.Rows[0]
for column, value := range row {
row[column] = decodeValue(value)
}
fmt.Println(row["reference"], row["checksum"], row["amount"])
}9007199254740993 [222 173 190 239] 250The same two branches cover every payload the server produces, because the envelopes are the only non-standard JSON in the protocol. When you build bind parameters without the SDK, apply the rules in reverse: wrap any integer beyond the safe range and every BLOB, and send everything else as plain JSON.
Subscription resumption shows the change-event side of the WebSocket protocol, and integer fidelity covers how each embedded driver returns large integers.