Point-to-Point
2 nodes, active-active. Simplest setup.
hook-sync provides three importable Go packages. Pick capture mode at import time — both modes share the same wire protocol and sync to each other transparently.
Install the library
go get hook-sync/go/hooksyncgo get hook-sync/go/triggerAttach sync to your database
import ( "hook-sync/hooksync" "hook-sync/trigger")
db, _ := sql.Open("sqlite3", "app.db")mgr, _ := trigger.Attach(db, hooksync.Config{ ID: "node1", Peers: []string{"http://localhost:9002"}, BatchMs: 50,}, []string{"items"})defer mgr.Stop()Wire the HTTP server
http.Handle("/sync", mgr) // mgr.ServeHTTP handles parse + apply + ACKhttp.ListenAndServe(":9001", nil)Write 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,...}]trigger/ (default) |
hook/ (opt-in) |
|
|---|---|---|
| API | trigger.Attach(db, config, tables) |
hook.Open(path, config, tables) |
| Capture | SQL triggers → _changes table |
preupdate_hook → Pebble KV |
| Speed | Baseline | 35% faster (no _changes write) |
| Build tag | none | sqlite_preupdate_hook |
| CGO | standard SQLite | mattn/go-sqlite3 only |
| Cross-runtime | Yes (syncs with Bun, Node) | Go-only (syncs with trigger nodes) |
| Use when | Most apps (HTTP is bottleneck) | Write-heavy direct-SQLite (batch, analytics) |
Both share the same wire protocol — trigger nodes sync to hook nodes, no interop issues.
Cross-runtime, database-level capture. Uses SQL triggers + _changes table.
import ( "hook-sync/hooksync" "hook-sync/trigger")
db, _ := sql.Open("sqlite3", "app.db")mgr, _ := trigger.Attach(db, hooksync.Config{ ID: "node1", Peers: []string{"http://peer:9002"}, BatchMs: 50, BatchSize: 10000,}, []string{"items"})defer mgr.Stop()Connection-level capture via preupdate_hook + Pebble KV. Eliminates the _changes table write.
import ( "hook-sync/hooksync" "hook-sync/hook")
mgr, _ := hook.Open("app.db", hooksync.Config{ ID: "node1", Peers: []string{"http://peer:9002"}, BatchMs: 50, BatchSize: 10000,}, []string{"items"})defer mgr.Stop()Build with the sqlite_preupdate_hook tag:
go build -tags sqlite_preupdate_hook -o myapp ./cmd/myappGo’s trigger.Manager implements http.Handler, so /sync is one line:
http.Handle("/sync", mgr) // mgr.ServeHTTP handles parse + apply + ACKhttp.ListenAndServe(":9001", nil)The ServeHTTP method:
batch_id + changes)ApplyChange() with LWW conflict check{applied, ack: batch_id}hooksync.Configtype Config struct { ID string // node identifier Peers []string // peer URLs (e.g., "http://localhost:9002") BatchMs int // ship interval in ms (default: 50) BatchSize int // max changes per batch (default: 10000)}hooksync.Changetype Change struct { Op string // "INSERT", "UPDATE", "DELETE" Table string // table name Row map[string]any // full row values OldID *string // row ID for DELETE, nil for INSERT/UPDATE}trigger.Attach(db, config, tables) → ManagerCreates _meta, _changes, _dead_letter, _peer_state tables. Auto-generates INSERT/UPDATE/DELETE triggers via PRAGMA table_info introspection. Starts background ship loop.
hook.Open(path, config, tables) → ManagerOpens SQLite with custom driver (preupdate_hook + commit_hook + rollback_hook). Pebble KV stores pending changes. Same Manager interface as trigger.
| Method | Description |
|---|---|
ServeHTTP(w, r) |
Handles POST /sync — parse, apply, ACK |
ApplyChange(change) |
Apply single change with LWW conflict check |
ShipWithAck(...) |
Ship batch to peer, return ACK response |
Stop() |
Stop background ship loop |
Health() |
Returns HealthStatus{ OK, NodeID, ItemCount, PendingChanges, DeadLetter, Peers, Mode, ClockSkewMs, ClockOK }. ClockSkewMs = max observed skew vs remote updated_at. ClockOK = false if skew ≥ 1000ms. |
The repo includes thin wrapper binaries in go/cmd/:
| Binary | Package | Description |
|---|---|---|
hook-sync-go |
trigger/ |
Single-peer, point-to-point |
hook-sync-mesh-go |
trigger/ |
Multi-peer, full mesh |
hook-sync-hookserver |
hook/ |
Hook capture mode (requires build tag) |
hook-sync-hub |
— (standalone) | Dedicated hub relay (Pebble KV, no SQLite) |
hook-sync-multitable |
trigger/ |
Multi-table (items + categories) |
Build from source:
git clone https://github.com/maulanashalihin/hook-sync.gitcd hook-sync/gogo build -o ../hook-sync-go ./cmd/servergo build -o ../hook-sync-mesh-go ./cmd/meshgo build -tags sqlite_preupdate_hook -o ../hook-sync-hookserver ./cmd/hookservergo build -o ../hook-sync-hub ./cmd/hubThe hub binary is also available as pre-built download — see Dedicated Hub.
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);mgr, _ := trigger.Attach(db, hooksync.Config{ ID: "nodeA", Peers: []string{ "http://localhost:9002", "http://localhost:9003", "http://localhost:9004", }, BatchMs: 50,}, []string{"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 with Pebble KV. See Dedicated Hub.
Point-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.