> Complete content index: https://sirannon.sondelali.com/llms.txt

# Value encoding over JSON

> Send 64-bit integers and BLOBs over HTTP and WebSocket, and decode the tagged envelopes in any language without the client SDK.

URL: https://sirannon.sondelali.com/docs/value-encoding
Section: Networked access
Version: 0.3 (@delali/sirannon-db@0.3.4, latest)

JSON represents two SQLite value types only with loss. Numbers pass through <Tooltip tip="An IEEE 754 double is the 64-bit floating-point format behind every JavaScript and JSON number. It represents integers exactly only up to 2^53 - 1.">IEEE 754 doubles</Tooltip>, 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](/docs/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.

```bash
mkdir -p data
```

## Stand 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](/docs/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.

```ts title="values.ts"
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.

```ts title="values.ts"
import { Sirannon } from '@delali/sirannon-db'
import { SirannonClient } from '@delali/sirannon-db/client' // [!code ++]
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()
// [!code ++:14]

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])
```

```ts result open
{
  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.

```ts title="values.ts"
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])
// [!code ++:10]

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())
```

```json result open
{"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_int` payload 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_blob` payload 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 `row` and `oldRow` fields of change events, in bind parameters (`params`, per-statement `params` entries, and `paramsBatch`), 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 code `INVALID_MESSAGE`.

## Decode without the SDK

In JavaScript the [client SDK](/docs/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.

```python title="decode_transfers.py"
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])
```

```python result open
{'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.

```go title="decode_transfers.go"
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"])
}
```

```txt result open
9007199254740993 [222 173 190 239] 250
```

The 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](/docs/subscription-resumption) shows the change-event side of the WebSocket protocol, and [integer fidelity](/docs/integer-fidelity) covers how each embedded driver returns large integers.
