Github|...

Client config

SyncedDbConfig, the object you hand to new SyncedDb(): connection, local store, log level and sync timings.

SyncedDbConfig is the client-side counterpart to sp00ky.yml. It is passed to new SyncedDb(...) in your app and controls the database connection, where data is cached locally, and how aggressively sync batches its work. Older docs sometimes call it Sp00kyConfig.

Configuration Interface

interface SyncedDbConfig<Schema> {
  /** The runtime schema generated by the CLI */
  schema: Schema;

  /** The raw SURQL schema string (also generated) */
  schemaSurql: string;

  /** Logging verbosity level */
  logLevel: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'fatal';

  /** Database connection configuration */
  database: {
    /** The SurrealDB endpoint URL */
    endpoint?: string;

    /** The namespace to use */
    namespace: string;

    /** The database name */
    database: string;

    /** The local store: 'memory' (default) or 'indexeddb' (persistent) */
    store?: 'memory' | 'indexeddb';

    /** Authentication token (optional) */
    token?: string;
  };

  /** Persistence client: 'surrealdb' (default), 'localstorage', or custom */
  persistenceClient?: 'surrealdb' | 'localstorage' | PersistenceClient;

  /** A pino browser transmit object for forwarding logs (e.g. via @spooky-sync/core/otel) */
  otelTransmit?: PinoTransmit;

  /** Minimum gap between re-materializations of one query; the first change is immediate (default: 50ms) */
  streamDebounceTime?: number;

  /** Preload the Loro engine at startup for CRDT fields (default: false) */
  crdt?: boolean;

  /** Wait before the first edit after a quiet spell is sent (default: 50ms) */
  crdtDebounceMs?: number;

  /** Shortest gap between two sends of one CRDT field while typing (default: 200ms) */
  crdtSendIntervalMs?: number;

  /** Carry changed rows on the LIVE notification instead of fetching them (default: false) */
  liveInlineBodies?: boolean;

  /**
   * Cadence (ms) for the `_00_list_ref` poll that catches cross-session
   * UPDATEs missed by the SurrealDB v3 LIVE-permission gap. Defaults
   * to 500ms; non-positive values fall back to the default.
   */
  refSyncIntervalMs?: number;

  /** Snapshot the in-browser circuit's store for reloads (default: true on the sqlite engine) */
  persistCircuit?: boolean;

  /** Snapshot checkpoint interval in ms (default: 30000) */
  circuitCheckpointMs?: number;

  /** Keep only evaluated fields per row in the in-browser circuit (default: true) */
  circuitProjection?: boolean;

  /**
   * Allow useRemote / remoteQuery / queryRaw while the generated schema's
   * policy.queryAllowlist is 'warn' or 'enforce' (default: false)
   */
  allowRawRemote?: boolean;
}

Example Configuration

import { SyncedDb, type SyncedDbConfig } from '@spooky-sync/client-solid';
import { schema, SURQL_SCHEMA } from './schema.gen';

import { createOtelTransmit } from '@spooky-sync/core/otel';

const dbConfig: SyncedDbConfig<typeof schema> = {
  logLevel: 'info',
  schema: schema,
  schemaSurql: SURQL_SCHEMA,
  database: {
    namespace: 'main',
    database: 'main',
    endpoint: 'ws://localhost:8666/rpc',
    store: 'indexeddb', // or 'memory' for transient storage
  },
  // Optional: forward logs to OpenTelemetry
  otelTransmit: createOtelTransmit('http://localhost:4318/v1/logs'),
};

export const db = new SyncedDb<typeof schema>(dbConfig);

Options Detail

database.endpoint

The WebSocket URL for your SurrealDB instance (e.g., ws://localhost:8666/rpc when running locally with the Sp00ky CLI’s bundled dev stack). The Sp00ky client connects directly to the database. Optional, and when omitted the client connects in offline-only mode and queues mutations until a remote is configured.

database.store

  • 'memory' (default): Transient in-memory storage (clears on page reload)
  • 'indexeddb': Persistent local storage in the browser

With 'indexeddb', the local store is bucketed per user: each account gets its own IndexedDB database (sp00ky-<userId>, with a shared sp00ky-anon bucket for signed-out sessions), and the client switches buckets automatically on sign-in/sign-out. Cached rows, query state, and the offline mutation outbox never leak between accounts on a shared device, and a user’s un-pushed offline mutations resume the next time they sign in. See Architecture → Per-User Local Buckets. With 'memory', an auth change resets the store instead (same isolation, no persistence).

'indexeddb' on the default SurrealDB engine needs @surrealdb/wasm 3.1.0 or later (the version @spooky-sync/core installs). The 3.0.x engine opened the store but failed every write with An IndexedDB error occured: idb error, so each session quietly ran in memory. From 3.1.0 the engine itself ships as @surrealdb/wasm-native; with Vite, exclude it from dependency pre-bundling next to @surrealdb/wasm, or the dev server serves index.html where the .wasm file should be:

vite.config.ts
export default defineConfig({
  optimizeDeps: {
    exclude: ['@surrealdb/wasm', '@surrealdb/wasm-native', '@spooky-sync/ssp-wasm'],
  },
});

If a store cannot be opened even after the client drops and recreates it, the session runs in memory and logs falling back to in-memory at error level; the next load tries IndexedDB again. Under localEngine: 'sqlite', 'indexeddb' selects the OPFS-backed SQLite store instead.

logLevel

Controls the verbosity of logging output:

  • 'trace': Most verbose, includes all internal operations
  • 'debug': Detailed debugging information
  • 'info': General lifecycle events (connected, synced)
  • 'warn': Recoverable errors and warnings
  • 'error': Critical failures
  • 'fatal': Fatal errors that stop execution

This is what the console prints. The page also keeps its last 500 lines for the DevTools Logs tab, which can record below this level (say, debug) for one tab session without printing more to the console.

persistenceClient

Determines where small metadata values (the auth token, the stream processor’s persisted state) are stored:

  • 'localstorage' (default): Use browser localStorage
  • 'surrealdb': Store in the local SurrealDB instance (inside the current user’s local bucket)
  • Custom implementation of PersistenceClient interface

Note: the boot-time bucket hint (sp00ky:last_bucket) is always written to plain localStorage regardless of this setting, because the client needs it to pick the right per-user bucket before any bucket is open.

streamDebounceTime

Minimum gap (ms) between two re-materializations of one query before they fan out to useQuery subscribers. The first change after a quiet spell is delivered on the next tick; later changes inside the window fold into one update at its end, so a burst of synced records (e.g. the initial library sync) repaints affected views once per window instead of row-by-row. Lower values repaint bursts more often; higher values reduce re-render thrash on bursty writes. Defaults to 50.

crdt

Load the loro-crdt engine at startup (as a separate chunk) so the first CRDT field opens without waiting for it. Off by default: Loro is then only fetched when a field is first opened, which keeps it out of apps that never edit one. Defaults to false.

crdtDebounceMs

How long (ms) the first edit to a CRDT field after a quiet spell waits before the tab sends it, so a burst of keystrokes travels as one edit-log row through the outbox. Edits apply to the local Loro document at once. Defaults to 50.

crdtSendIntervalMs

The shortest gap (ms) between two edit-log rows of one CRDT field. While someone keeps typing, their edits go out on this cadence instead of waiting for a pause, so other editors see the text a few hundred ms behind, not seconds. Lower values reach other editors sooner and create more rows (the SSP folds them into the document either way). Defaults to 200.

channels

Settings for peer to peer channels on -- @channel tables:

  • accept: per table, ({ kind, from, table, id, row, patch }) => boolean. Decides whether a peer’s update, create or delete (kind) is applied, after the built-in checks (sender listed by an open channel whose query the row matches, fields exist and fit their column type). Without a rule every such write is applied.
  • iceServers: STUN/TURN servers for the default WebRTC transport. Defaults to a public STUN server; add TURN for networks that block direct connections.
  • transport: a ChannelTransportFactory that replaces WebRTC (an existing room or relay).
  • confirmTimeoutMs: how long a peer’s persistent value, create or delete shows without the server confirming it. Defaults to 5000.

In Dart (spooky_core) the same keys live on ChannelsConfig (accept, iceServers, transport, confirmTimeoutMs), plus rtc: the peer connection factory, flutterWebRtc from spooky_flutter. Without rtc or transport, channels report unsupported.

liveInlineBodies

Carry a changed row’s body on the client’s LIVE notification (FETCH out) instead of fetching it afterwards, which saves one round trip between another device’s write and this screen. A row joining a view is committed from the notification either way; with this on, nothing else has to be read before it renders. The cost is wire bytes: each notification carries its own copy of the row, once per view that holds it. Defaults to false.

refSyncIntervalMs

Base cadence (ms) for the _00_list_ref poll that catches cross-session UPDATEs that the SurrealDB v3 LIVE-permission gap drops. Defaults to 500; non-positive values fall back to the default. One cycle reads every active query’s edges in a handful of round trips (batched by a row budget, views past 1,000 rows only every 15s), backs off toward 5s while nothing changes, and never runs back-to-back: the next tick waits at least as long as the previous cycle took. Statements on the remote connection run concurrently (up to 6 in flight), so the poll does not hold registrations or one-shot reads behind it.

persistCircuit

Persist the in-browser stream processor’s store as a snapshot in the local store, so a reload restores it and only steps in what changed since instead of re-downloading the working set. Defaults to true under localEngine: 'sqlite' (the OPFS-backed store holds the bytes) and false otherwise. Snapshots are written on circuitCheckpointMs (default 30000) and when the page goes hidden, never per ingest. Without a snapshot the circuit is primed by reading the cached rows back out of the local store.

circuitProjection

Keep only the fields registered queries evaluate (filter predicates, join keys, sort keys, plus id/_00_rv) per row inside the in-browser circuit. Defaults to true. Bodies are rendered from the local store, so the circuit never needs the rest; on rows with large text fields this is the difference between a few hundred megabytes of wasm heap and a few megabytes.

allowRawRemote

Opt back in to the raw remote escape hatches (db.useRemote, db.remoteQuery, db.queryRaw) when the generated schema’s policy.queryAllowlist is warn or enforce. With the query allowlist on, the SSP only registers shapes spky generate recorded from the query builder, so a hand-written SurrealQL string can never be allowlisted; these methods throw (... is disabled because sync.queryAllowlist is on for this schema) unless this flag is true. Defaults to false. The gate is client-side only: direct SurrealDB reads under table PERMISSIONS are unchanged.

Diagnostics

The client exposes a small number of read-only getters intended for e2e regression guards and runtime instrumentation.

pendingMutationCount and subscribeToPendingMutations

Live count of mutations queued for upstream sync. The subscribe variant fires on enqueue/dequeue so a UI can render a “saving…” indicator without polling.

liveRetryCount

Number of times the initial _00_list_ref[_user_*] LIVE SELECT subscription had to retry on the most recent setCurrentUserId call. Stays at 0 when the SSP’s pre-emptive per-user table creation got there first (the default path); a value above 0 means the LIVE registration hit a “table not found” race and the client fell through to the poll fallback while retrying. The e2e suite’s z-auth-bootstrap.spec.ts asserts this stays 0 after a clean signup so the pre-emptive path can’t silently regress.