JS Library (hooksync.js)
import { Tabs, TabItem, Aside } from ‘@astrojs/starlight/components’;
hooksync.js
Section titled “hooksync.js”SQLite replication library for Bun and Node.js. One package, both runtimes.
Install
Section titled “Install”npm install hooksync.js# orbun add hooksync.jsQuick Start
Section titled “Quick Start”import { attach } from "hooksync.js";import { Database } from "bun:sqlite"; // or: const Database = require("better-sqlite3");
const db = new Database("app.db");db.exec("PRAGMA journal_mode = WAL");
// Your table — must have `id` (TEXT PRIMARY KEY) and `updated_at` (INTEGER)db.exec(` CREATE TABLE IF NOT EXISTS items( id TEXT PRIMARY KEY, name TEXT, value INTEGER, created_at INTEGER, updated_at INTEGER );`);
// Attach sync — creates _meta, _changes, _dead_letter, _peer_state tables// and auto-generates triggers via schema introspectionconst mgr = attach(db, { id: "node1", peers: ["http://localhost:9002"], batchMs: 50,}, ["items"]);
// Writes to `items` now replicate automatically.// Sync runs in the background — never blocks the write path.
// Shutdown:mgr.stop();attach(db, config, tables) → Manager
Section titled “attach(db, config, tables) → Manager”| Parameter | Type | Description |
|---|---|---|
db |
SqliteDatabase |
SQLite instance (bun:sqlite or better-sqlite3). Caller opens it. |
config |
Config |
{ id: string, peers: string[], batchMs?: number, batchSize?: number } |
tables |
string[] |
Table names to sync. Triggers auto-generated via PRAGMA table_info. |
Returns a Manager:
| Method | Description |
|---|---|
applyChanges(changes) |
Apply received changes (LWW conflict resolution). Returns count applied. |
health() |
Returns { ok, node_id, item_count, pending_changes, dead_letter, peers }. |
stop() |
Stop the background ship loop. |
Table Requirements
Section titled “Table Requirements”Every synced table must have:
id—TEXT PRIMARY KEY(UUID, zero conflict)updated_at—INTEGER(millisecond timestamp, for last-write-wins)
Wire Protocol
Section titled “Wire Protocol”POST /sync with:
{ "batch_id": 42, "changes": [ { "op": "INSERT", "table": "items", "row": { "id": "uuid", "name": "foo", "updated_at": 1690000000 }, "old_id": null }, { "op": "DELETE", "table": "items", "row": null, "old_id": "uuid" } ]}Response:
{ "applied": 2, "ack": 42 }HTTP Server Setup (Required)
Section titled “HTTP Server Setup (Required)”Bun.serve({ port: 9001, fetch(req) { const url = new URL(req.url);
// /sync — receive changes from peers if (req.method === "POST" && url.pathname === "/sync") { return req.json().then((body) => { const applied = mgr.applyChanges(body.changes); return Response.json({ applied, ack: body.batch_id }); }); }
// /health if (req.method === "GET" && url.pathname === "/health") { return Response.json(mgr.health()); }
// ... your CRUD endpoints here return new Response("not found", { status: 404 }); },});const http = require("http");
const server = http.createServer(async (req, res) => { if (req.method === "POST" && req.url === "/sync") { const body = JSON.parse(await readBody(req)); const applied = mgr.applyChanges(body.changes); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ applied, ack: body.batch_id })); return; }
if (req.method === "GET" && req.url === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify(mgr.health())); return; }
// ... your CRUD endpoints here res.writeHead(404); res.end("not found");});
server.listen(9001);app.post("/sync", async (req, res) => { const applied = mgr.applyChanges(req.body.changes); res.json({ applied, ack: req.body.batch_id });});
app.get("/health", (req, res) => { res.json(mgr.health());});The pattern is always the same: parse the JSON body, call mgr.applyChanges(body.changes), return { applied, ack: body.batch_id }.
Multi-Peer (Full Mesh)
Section titled “Multi-Peer (Full Mesh)”const mgr = attach(db, { id: "nodeA", peers: [ "http://localhost:9002", "http://localhost:9003", "http://localhost:9004", ], batchMs: 50,}, ["items"]);Each peer has its own watermark (_peer_state table). Changes are deleted from _changes only after all peers have ACKed. Offline peers’ changes accumulate until they reconnect.
Hub Topology (Star, 8+ Nodes)
Section titled “Hub Topology (Star, 8+ Nodes)”For 8+ nodes, use a dedicated hub — a Go-only relay binary. The hub is not part of this library; it’s a separate process.
# Build from the hook-sync repocd go && go build -o ../hook-sync-hub ./cmd/hub
./hook-sync-hub -id hub1 -listen :9010 -db hub1.pebble \ -edge http://localhost:9001 \ -edge http://localhost:9002 \ -edge http://localhost:9003From the JS library’s perspective, the hub is just a peer URL:
const mgr = attach(db, { id: "edge1", peers: ["http://localhost:9010"], // hub URL — same as any peer batchMs: 50,}, ["items"]);See Dedicated Hub for full details.
SQLite Binding Compatibility
Section titled “SQLite Binding Compatibility”The library accepts a minimal SqliteDatabase interface — it never imports a binding:
interface SqliteDatabase { exec(sql: string): void; prepare(sql: string): SqliteStatement; transaction<T>(fn: T): T;}Both bun:sqlite and better-sqlite3 satisfy this interface. Pass whichever you prefer.
What This Library Does NOT Do
Section titled “What This Library Does NOT Do”- No HTTP server — caller wires
Bun.serve(),http.createServer(), or any framework - No hook capture mode — neither
bun:sqlitenorbetter-sqlite3has a preupdate hook API. Trigger-based only - No consensus — no Raft, no coordinator, no leader election. Just triggers + HTTP + ACK