Skip to content

@syncraft-labs/core

The @syncraft-labs/core package provides the underlying engine for Syncraft Labs. It is framework-agnostic and can be used in any JavaScript/TypeScript environment.

Create a new store instance. Each store manages one slice of state identified by storageKey.

| storageKey | string | required | Unique key for IndexedDB database name | | initialState | T | undefined | Default state when no persisted data exists | | maxOutboxSize | number | 1000 | Maximum outbox entries before overflow strategy triggers | | overflowStrategy | "reject" \| "dropOldest" \| "forceFlush" | "reject" | Behavior when maxOutboxSize is reached: "reject" throws Error, "dropOldest" drops oldest entry with warning, "forceFlush" invokes onOverflow callback to attempt sync before write | | onOverflow | (info: OutboxOverflowInfo) => void \| Promise<void> | undefined | Callback invoked on outbox overflow events | | onRollback | (error: Error, previousState: T \| undefined) => void | undefined | Callback invoked when local persistence fails and optimistic draft reverts | | logger | SyncraftLogger | console | Injectable logger replacing hardcoded console calls | | conflictStrategy | "lastWriteWins" \| "custom" | "lastWriteWins" | Strategy when reconciling incoming remote state | | resolver | ConflictResolver<T> | undefined | Custom three-way merge function (required when conflictStrategy is "custom") | | onConflictResolved | (info: ConflictResolvedInfo) => void | undefined | Callback invoked when a conflict is resolved | | onQuotaExceeded | (info: QuotaExceededInfo) => void | undefined | Callback invoked on storage quota exhaustion | | storageMode | "document" \| "collection" | "document" | Storage strategy: "document" (single blob) or "collection" (per-entity records) | | idField | string | undefined | Required when storageMode is "collection". Property name of entity unique ID |

Method Signature Description
get() () => Promise<T | undefined> Async read — memory → IndexedDB fallback
getSnapshot() () => T | undefined Synchronous read from memory (fast path)
set(updater) (updater: DraftUpdater<T>) => Promise<void> Mutate via draft. Optimistic + durable
subscribe(listener) (listener: SyncListener<T>) => Unsubscribe Listen to state changes
hydrate() () => Promise<T | undefined> Load from IndexedDB (call once on init)
getOutbox() () => Promise<readonly OutboxEntry<T>[]> Read pending mutations
compactOutbox() () => Promise<readonly OutboxEntry<T>[]> Return compacted view of outbox entries (last-write-wins)
clearOutbox(ids) (ids: readonly string[]) => Promise<void> Remove synced entries by ID
destroy() () => void Close IndexedDB connection, clear listeners
isHydrating boolean (getter) true until hydrate() completes

type DraftUpdater<T> = (draft: T) => void | T;

Two patterns:

  • Mutate the draft (most common): (draft) => { draft.count += 1; }
  • Replace entirely: () => freshDataFromServer
interface OutboxEntry<T> {
readonly id: string; // UUID v4
readonly timestamp: number; // Unix ms
readonly patches: Patch[]; // Applied JSON patches
readonly inversePatches: Patch[]; // Inverse patches for rollback
}

Recursively freeze an object and all nested properties using Object.freeze(). Used in development mode (NODE_ENV !== "production") to prevent accidental direct state mutations.

import { deepFreeze } from "@syncraft-labs/core";
const frozen = deepFreeze({ user: { name: "Alice" } });
// frozen.user.name = "Bob"; // Throws TypeError in strict mode

assertNoCycles(obj: unknown, context?: string): void

Section titled “assertNoCycles(obj: unknown, context?: string): void”

Recursively traverse an object tree and throw a descriptive Error if a circular reference is detected. Supports DAG (Directed Acyclic Graph) / diamond shared references without false positives.

import { assertNoCycles } from "@syncraft-labs/core";
assertNoCycles(state, "Initial state");
// Throws Error: [Syncraft Labs] Circular reference detected at path "a.b.self" during Initial state. State must be a plain acyclic object tree.

validateStateShape(obj: unknown, context?: string): void

Section titled “validateStateShape(obj: unknown, context?: string): void”

Recursively traverse a state object tree to validate that all values conform to supported state shapes (plain objects, arrays, primitives, Date leaves, Map, and Set). Emits a dev warning for Date instances and throws an Error for unsupported types like RegExp, functions, or custom class instances.

import { validateStateShape } from "@syncraft-labs/core";
validateStateShape(state, "hydrate()");
// Warns: [Syncraft Labs] Date detected at path "user.createdAt" during hydrate() — Dates are allowed as leaf values but must be replaced wholesale rather than having their fields mutated.
// Throws: [Syncraft Labs] Unsupported type "RegExp" detected at path "pattern" during hydrate(). State must only contain plain objects, arrays, and primitives. Use a plain object instead.

isUnsupportedType(value: unknown): boolean

Section titled “isUnsupportedType(value: unknown): boolean”

Check if a given value is unsupported for state persistence and draft proxying.

import { isUnsupportedType } from "@syncraft-labs/core";
isUnsupportedType(new Map()); // false (now supported)
isUnsupportedType(new Set()); // false (now supported)
isUnsupportedType(new Date()); // false (allowed as leaf)
isUnsupportedType({ a: 1 }); // false
isUnsupportedType(/test/); // true (unsupported)

compactOutbox<T>(entries: readonly OutboxEntry<T>[]): CompactResult<T> | null

Section titled “compactOutbox<T>(entries: readonly OutboxEntry<T>[]): CompactResult<T> | null”

Compact an array of outbox entries by merging consecutive mutations to the same path (last-write-wins). Returns { compacted, originalIds } or null if empty.

import { compactOutbox } from "@syncraft-labs/core";
const result = compactOutbox(outboxEntries);
if (result) {
await pusher([result.compacted]);
await store.clearOutbox(result.originalIds);
}

applyPatches<T>(baseState: T, patches: readonly Patch[]): T

Section titled “applyPatches<T>(baseState: T, patches: readonly Patch[]): T”

Apply an array of Immer-style JSON patches to a base state object. Returns a new state object without mutating the original.

import { applyPatches } from "@syncraft-labs/core";
const nextState = applyPatches(baseState, entry.patches);
interface Patch {
op: "replace" | "add" | "remove";
path: (string | number)[];
value?: unknown;
}

Patches reuse standard replace/add/remove ops with path conventions for collections. Map and Set keys/values are restricted to string | number:

For Map operations, patches use $entries in the path:

  • map.set("key", value) → { op: "add", path: [..., "$entries", "key"], value } (or replace if key existed)
  • map.delete("key") → { op: "remove", path: [..., "$entries", "key"] }
  • map.clear() → one remove per entry

For Set operations, patches use $values in the path:

  • set.add(value) → { op: "add", path: [..., "$values", "value"], value }
  • set.delete(value) → { op: "remove", path: [..., "$values", "value"] }
  • set.clear() → one remove per value
type SyncListener<T> = (state: T) => void;
type Unsubscribe = () => void;

In browser environments (typeof window !== "undefined"), active stores are automatically registered into window.__SYNCRAFT_DEVTOOLS__.stores (a Map<string, SyncStore<any>>).

This allows browser developer extensions or developer console scripts to inspect snapshots, monitor outbox queues, and interact directly with active store instances during debugging. When a store is destroyed via store.destroy(), it is automatically unregistered from the DevTools registry to prevent memory leaks.