Point-to-Point
2 nodes, active-active. Simplest setup.
hook-sync works with Node.js via the hooksync.js npm package and better-sqlite3. Trigger-based capture only (no preupdate_hook in better-sqlite3).
Install the library
npm install hooksync.js better-sqlite3Create server.js
const { attach } = require("hooksync.js");const Database = require("better-sqlite3");const http = require("http");
const db = new Database("app.db");db.pragma("journal_mode = WAL");db.exec(`CREATE TABLE IF NOT EXISTS items( id TEXT PRIMARY KEY, name TEXT, value INTEGER, created_at INTEGER, updated_at INTEGER);`);
const mgr = attach(db, { id: "node1", peers: ["http://localhost:9002"], batchMs: 50,}, ["items"]);
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; } res.writeHead(404); res.end("not found");});
server.listen(9001);Run two instances
node server.js # node1 on :9001# In another terminal, change port to 9002 and peer to 9001node server.jsWrite to node A, verify on node B
curl -X POST http://localhost:9001/api/items \ -H 'Content-Type: application/json' \ -d '{"name":"hello","value":42}'
curl http://localhost:9002/api/items# [{"id":"...","name":"hello","value":42,...}]attach(db, config, tables) → Manager| Parameter | Type | Description |
|---|---|---|
db |
Database |
better-sqlite3 instance. 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, clock_skew_ms, clock_ok }. clock_skew_ms = max observed skew vs remote updated_at. clock_ok = false if skew ≥ 1000ms. |
stop() |
Stop the background ship loop. |
http.createServerconst 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 JSON body, call mgr.applyChanges(body.changes), return { applied, ack: body.batch_id }.
Every synced table must have:
id — TEXT PRIMARY KEY (UUID, zero conflict)updated_at — INTEGER (millisecond timestamp, for last-write-wins)CREATE TABLE items( id TEXT PRIMARY KEY, name TEXT, value INTEGER, created_at INTEGER, updated_at INTEGER);When you call attach(), the library:
_meta, _changes, _dead_letter, _peer_state tablesconst mgr = attach(db, { id: "nodeA", peers: [ "http://localhost:9002", "http://localhost:9003", "http://localhost:9004", ], batchMs: 50,}, ["items"]);Each peer has its own watermark. Changes deleted from _changes only after all peers ACK. See Full Mesh.
For 8+ nodes, use a dedicated hub — a Go-only relay binary. From 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 hub setup.
UUIDv7 gives time-ordered IDs → sequential B-tree inserts. Node: uuidv7 package (1.8x faster than v4 on insert). Node 26+ will have crypto.randomUUIDv7() native (PR #62553).
const { uuidv7 } = require("uuidv7");const id = uuidv7(); // time-ordered UUIDThe 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;}better-sqlite3 satisfies this interface. Pass your existing instance — the library never opens or closes the database.
http.createServer(), Express, or any frameworkbetter-sqlite3 has no preupdate hook API. Trigger-based onlyPoint-to-Point
2 nodes, active-active. Simplest setup.
Full Mesh
3-7 nodes, all-to-all sync.
Dedicated Hub
8+ nodes. Go-only relay with Pebble KV.
Multi-Region
Hub-to-hub cross-region sync.