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:

FieldTypeRequiredDescription
sourceEndpointConfigYesWhere rows are read from on push, written to on pull.
destEndpointConfigNoThe 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().
syncSyncOptionsNoWhat to sync and how to resolve conflicts.
autoSyncAutoSyncConfigNoHands-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:

FieldTypeDefaultDescription
tablesstring[]all non-excluded tablesTables to sync.
excludeTablesstring[]noneTables to skip.
excludeColumnsstring[]noneGlob patterns ("*_embedding") excluded from content hashing and transfer.
timestampColumnstring"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.
batchSizenumber100Max 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

FieldTypeDescription
smugglrPick<Smugglr, "on">Used only to subscribe to table-changed.
executorSqlExecutorDirect read/write of the persistence row. Same executor you pass to LocalEndpointConfig.
tablestringThe persistence table. Caller owns the DDL.
keystringPrimary key for this store's row. Use distinct keys when multiple stores share one table.
serialize(value) => stringDefault JSON.stringify.
deserialize(raw) => valueDefault JSON.parse.
onHydrate(hydrated | null) => voidCalled 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.