smugglr-core compiled to WASM, wrapped in a typed TypeScript client. Same content-hashed delta engine as the CLI, same exit-code semantics, running in a browser tab or a Node process. This page covers the smugglr npm package: install, Smugglr.init(), every instance method, the table-changed event, autoSync, the OPFS local source, error handling, and the two reactive-store bridges (@smugglr/zustand, @smugglr/nanostores).
Install
npm install smugglr
Package exports two entry points: smugglr (the typed client, this page) and smugglr/wasm (the raw wasm-bindgen output, for consumers who want to control WASM loading themselves; see Custom WASM loading).
Init
import { Smugglr } from "smugglr";
const s = await Smugglr.init({
source: { url: "https://my-db.turso.io", authToken: "tok", profile: "turso" },
dest: { url: "https://api.cloudflare.com/...", authToken: "cf-tok", profile: "d1" },
sync: { tables: ["users", "posts"], conflictResolution: "local_wins" },
});
Smugglr.init(config, options?) loads the WASM module (once, lazily) and returns a Smugglr instance. config is a SmugglrConfig:
| Field | Type | Required | Description |
|---|---|---|---|
source | EndpointConfig | Yes | Where rows are read from on push, written to on pull. |
dest | EndpointConfig | No | The other side. Omit it to run in anonymous-first mode: no network, push/pull/sync throw, diff still reports a local-only inventory. Attach a dest later with updateDest(). |
sync | SyncOptions | No | What to sync and how to resolve conflicts. |
autoSync | AutoSyncConfig | No | Hands-off sync triggers. Browser-only; no-op in Node. See autoSync. |
EndpointConfig is a union of two shapes:
interface HttpEndpointConfig {
url: string;
authToken?: string;
profile?: string; // "d1", "turso", etc. -- same profiles the CLI's http-sql plugin ships
}
interface LocalEndpointConfig {
type: "local";
executor: SqlExecutor; // see OPFS / wa-sqlite below
}
Either side of a sync, source or dest, can be either shape. A browser app syncing OPFS against a remote D1 database sets source to a LocalEndpointConfig and dest to an HttpEndpointConfig.
SyncOptions mirrors config.toml's [sync] table, camelCased for JS:
| Field | Type | Default | Description |
|---|---|---|---|
tables | string[] | all non-excluded tables | Tables to sync. |
excludeTables | string[] | none | Tables to skip. |
excludeColumns | string[] | none | Glob patterns ("*_embedding") excluded from content hashing and transfer. |
timestampColumn | string | "updated_at" | Column used by newer_wins. |
conflictResolution | "local_wins" | "remote_wins" | "newer_wins" | "uuid_v7_wins" | "local_wins" | Same four strategies as the CLI. See How It Works. |
batchSize | number | 100 | Max rows per write batch. |
Custom WASM loading
By default Smugglr.init() dynamically imports the co-located WASM binary. Two InitOptions override that:
interface InitOptions {
wasmUrl?: string | URL; // point at a CDN-hosted .wasm binary
wasmModule?: unknown; // hand it a pre-imported module directly
}
const s = await Smugglr.init(config, {
wasmUrl: "https://cdn.example.com/smugglr_wasm_bg.wasm",
});
Or pre-load before calling init(), useful when you need the module ready ahead of time:
import { setWasm } from "smugglr";
import * as wasm from "smugglr/wasm";
await setWasm(wasm);
Methods
push, pull, sync
async push(options?: { dryRun?: boolean }): Promise<SyncResult>
async pull(options?: { dryRun?: boolean }): Promise<SyncResult>
async sync(options?: { dryRun?: boolean }): Promise<SyncResult>
push sends source rows to dest. pull fetches dest rows to source. sync does both, resolving conflicts per sync.conflictResolution. All three send only rows that actually changed (content-hashed delta), and all three accept the same dry-run flag the CLI does.
const preview = await s.push({ dryRun: true });
// preview.status === "dry_run"
const result = await s.push();
// result.status === "ok"
Dry-run and push parity holds in the browser client the same as the CLI: the counts push({ dryRun: true }) reports are the counts push() writes.
SyncResult:
interface SyncResult {
command: "push" | "pull" | "sync";
status: "ok" | "dry_run";
tables: TableResult[];
}
interface TableResult {
name: string;
rowsPushed?: number;
rowsPulled?: number;
}
diff
async diff(): Promise<DiffResult>
Read-only comparison. Shows what push, pull, or sync would do without moving data. Works with no dest configured (reports a local-only inventory).
interface DiffResult {
command: "diff";
status: "ok";
tables: TableDiff[];
}
interface TableDiff {
name: string;
localOnly: number;
remoteOnly: number;
localNewer: number;
remoteNewer: number;
contentDiffers: number;
identical: number;
}
Same six-category classification as the CLI's diff algorithm. See How It Works for what each category means.
updateAuth, updateDest
updateAuth(authToken: string): void
updateDest(dest: EndpointConfig): void
updateAuth rotates the dest auth token without re-initializing the WASM module or losing the metadata cache. Errors if dest is not an HTTP endpoint. updateDest replaces the entire dest endpoint and clears the dest metadata cache; the source cache and any local OPFS data survive. This is the anonymous-to-account upgrade path: init with no dest (or an anonymous ingress dest), call updateDest() once the user signs in.
Both are synchronous. Neither is safe to call while a push/pull/sync future is pending; await in-flight operations first.
eraseLocal
async eraseLocal(): Promise<{ erasedTables: string[] }>
DELETE FROM <table> against the local SQLite database for every configured sync table, then clears smugglr's in-memory metadata caches. Schema and non-synced tables are untouched. Dest is not contacted; server-side erasure is the app's concern. This is the GDPR right-to-erasure primitive for the client side.
stopAutoSync, dispose
stopAutoSync(): void
dispose(): void
stopAutoSync() cancels the auto-sync loop started by Smugglr.init({ autoSync: ... }): removes the online listener, aborts any pending retry timer. Idempotent.
dispose() releases WASM resources and stops auto-sync. The instance also implements [Symbol.dispose], so using s = await Smugglr.init(config) releases automatically at scope exit in engines that support explicit resource management.
The table-changed event
on<K extends keyof SmugglrEventMap>(
event: K,
handler: (e: SmugglrEventMap[K]) => void,
): Unsubscribe
table-changed is the only event smugglr emits today. It fires once per affected table after a pull or sync completes the local write. push and diff never emit it. This is the row-arrived-from-sync signal, and it is the primitive the reactive bridges (below) are built on.
const unsub = s.on("table-changed", (e) => {
console.log(`${e.table} changed`, e.changedPks, "via", e.source);
});
await s.sync();
unsub();
Payload:
interface TableChangedEvent {
table: string;
changedPks: string[]; // rows inserted or updated
removedPks: string[]; // reserved -- always empty until delete propagation lands
source: "pull" | "sync";
}
on() returns an Unsubscribe function (() => void). Calling it after dispose() is a safe no-op rather than a use-after-free against freed WASM memory.
autoSync
Hands-off sync, opt-in via the autoSync field on Smugglr.init(). Two triggers: hydrate on init, sync on reconnect. Browser-only: it relies on navigator.locks and the online event, and is a no-op in Node. It also has no effect when dest is not configured; there is nothing to sync against.
interface AutoSyncConfig {
onInit?: "hydrate-if-empty" | "always" | "never"; // default: "hydrate-if-empty"
onReconnect?: boolean; // default: true
backoff?: AutoSyncBackoff;
lockName?: string; // default: "smugglr:auto:<dest>"
}
interface AutoSyncBackoff {
initialMs?: number; // default: 1000
maxMs?: number; // default: 300_000 (5 min)
jitter?: boolean; // default: true
}
onInit: "hydrate-if-empty" pulls from dest only when every configured sync table has zero local rows: cheap startup when local already has data. "always" pulls on every init regardless. "never" skips the init-time pull; reconnect sync still fires if onReconnect is true.
const s = await Smugglr.init({
source: { type: "local", executor },
dest: { url: "https://my-db.turso.io", authToken: "...", profile: "turso" },
sync: { tables: ["todos"] },
autoSync: {
onInit: "hydrate-if-empty",
onReconnect: true,
},
});
// later, e.g. on unmount
s.stopAutoSync();
Multi-tab safe: auto-sync acquires a Web Lock (navigator.locks) named by lockName before running, so only the lead tab actually syncs while other open tabs wait. A failed auto-sync retries with exponential backoff and jitter, capped at backoff.maxMs.
OPFS / wa-sqlite local source
A LocalEndpointConfig puts a real SQLite database on either side of a sync, backed by wa-sqlite and OPFS in the browser. This answers the in-browser query API question directly: you bring the SQLite connection, smugglr owns the SQL. You do the wa-sqlite setup, factory, VFS registration, open_v2, and wrap the resulting handle in the SqlExecutor shape. smugglr never touches the connection directly; it generates SQL strings (hashing queries, diff reads, batched writes) and runs them through your executor.
interface SqlExecutor {
run(sql: string, params: unknown[]): Promise<{
columns: string[];
rows: unknown[][];
}>;
}
columns is column names in declaration order. rows is one array per row, values aligned with columns. Implementations bind params positionally against ? placeholders. Any SQLite runtime can satisfy this contract: wa-sqlite + OPFS in the browser, better-sqlite3 in Node, official sqlite-wasm, sql.js, or a future runtime. smugglr is SQLite-runtime-agnostic; the diff/sync engine only ever speaks SqlExecutor.run().
smugglr ships an adapter for the common browser case, createWaSqliteExecutor, wrapping a wa-sqlite database handle:
import SQLiteAsyncESMFactory from "wa-sqlite/dist/wa-sqlite-async.mjs";
import * as SQLite from "wa-sqlite";
import { OPFSCoopSyncVFS } from "wa-sqlite/src/examples/OPFSCoopSyncVFS.js";
import { Smugglr, createWaSqliteExecutor } from "smugglr";
const module = await SQLiteAsyncESMFactory();
const sqlite3 = SQLite.Factory(module);
const vfs = await OPFSCoopSyncVFS.create("opfs", module);
sqlite3.vfs_register(vfs, true);
const db = await sqlite3.open_v2(
"app.db",
SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE,
"opfs",
);
const s = await Smugglr.init({
source: { type: "local", executor: createWaSqliteExecutor(sqlite3, db) },
dest: { url: "https://my-db.turso.io", authToken: "...", profile: "turso" },
sync: { tables: ["users", "posts"] },
});
await s.sync();
createWaSqliteExecutor(sqlite3, db) takes the wa-sqlite namespace from SQLite.Factory(module) and the database handle from open_v2(). wa-sqlite setup, VFS choice, and database lifecycle stay entirely in your app's control; the adapter only translates run(sql, params) into wa-sqlite statement iteration and result collection.
Errors
class SmugglrError extends Error {
readonly code: ExitCode; // 0 | 1 | 2 | 3 | 4 | 5 | 6
readonly retryable: boolean; // true only when code === 3
}
Every method that touches the sync engine throws a SmugglrError on failure, not a raw Error. code matches the CLI's exit code semantics: 2 config, 3 network/transient, 4 conflict, 5 not found, 6 plugin error, 1 everything else. See the CLI reference's Exit Codes table for the full meaning of each code; the browser client and the CLI agree on what each number means, so error-handling logic written for one transfers to the other.
try {
await s.push();
} catch (e) {
if (e instanceof SmugglrError && e.retryable) {
// code 3: transient. Back off and retry.
} else {
throw e;
}
}
Reactive bridges
Two packages persist a reactive store to a smugglr-managed SQLite table and rehydrate it from table-changed, so a store updates itself when sync pulls in new rows. They answer the bridge-shape question directly, and the answer is different for each: @smugglr/zustand is middleware that wraps your state initializer; @smugglr/nanostores is an adapter that attaches to a store you already created. Neither ships a pre-built store.
Both packages own the same wire shape, one row per persisted value:
CREATE TABLE <table> (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT
);
Both bridges write through your SqlExecutor and read through it on hydrate. Neither creates this table. Run the CREATE TABLE yourself before the store mounts.
@smugglr/zustand
npm install @smugglr/zustand zustand smugglr
smuggl(initializer, options) is Zustand middleware. It wraps the state creator you pass to create() and returns a new StateCreator; you compose it inside create<T>()(...), same as any other Zustand middleware.
import { create } from "zustand";
import { smuggl } from "@smugglr/zustand";
const useStore = create<AppState>()(
smuggl(
(set) => ({
todos: [],
addTodo: (t) => set((s) => ({ todos: [...s.todos, t] })),
}),
{
smugglr: s,
executor,
table: "app_state",
key: "todos",
include: (s) => ({ todos: s.todos }),
},
),
);
include is an optional projector: the middleware persists include(state) instead of the full state, so you can drop ephemeral UI fields from what gets written. Defaults to identity.
@smugglr/nanostores
npm install @smugglr/nanostores nanostores smugglr
smuggl(store, options) takes an atom or map you already created with nanostores and attaches persistence to it in place. It returns a disposer, not a store.
import { atom } from "nanostores";
import { smuggl } from "@smugglr/nanostores";
const $todos = atom<Todo[]>([]);
const dispose = smuggl($todos, {
smugglr: s,
executor,
table: "app_state",
key: "todos",
});
Calling dispose() detaches the listener; it does not roll back the persisted row.
Shared option shape
| Field | Type | Description |
|---|---|---|
smugglr | Pick<Smugglr, "on"> | Used only to subscribe to table-changed. |
executor | SqlExecutor | Direct read/write of the persistence row. Same executor you pass to LocalEndpointConfig. |
table | string | The persistence table. Caller owns the DDL. |
key | string | Primary key for this store's row. Use distinct keys when multiple stores share one table. |
serialize | (value) => string | Default JSON.stringify. |
deserialize | (raw) => value | Default JSON.parse. |
onHydrate | (hydrated | null) => void | Called once after the initial hydration query completes. |
Both bridges rehydrate the same way: on table-changed, if the event's table matches options.table and options.key is in changedPks, they re-read the row and push the parsed value back into the store. A write the store itself triggers is skipped on the next hydrate cycle to avoid a redundant round-trip; a write another tab or a pull/sync produced flows in through the event.