@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.
API Reference
Section titled “API Reference”createSyncStore<T>(config): SyncStore<T>
Section titled “createSyncStore<T>(config): SyncStore<T>”Create a new store instance. Each store manages one slice of state identified by storageKey.
Config: SyncStoreConfig<T>
Section titled “Config: SyncStoreConfig<T>”| 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 |
Returns: SyncStore<T>
Section titled “Returns: SyncStore<T>”| 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 |
Core Types
Section titled “Core Types”DraftUpdater<T>
Section titled “DraftUpdater<T>”type DraftUpdater<T> = (draft: T) => void | T;Two patterns:
- Mutate the draft (most common):
(draft) => { draft.count += 1; } - Replace entirely:
() => freshDataFromServer
OutboxEntry<T>
Section titled “OutboxEntry<T>”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}Utilities
Section titled “Utilities”deepFreeze<T>(obj: T): Readonly<T>
Section titled “deepFreeze<T>(obj: T): Readonly<T>”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 modeassertNoCycles(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 }); // falseisUnsupportedType(/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 }(orreplaceif key existed)map.delete("key")→{ op: "remove", path: [..., "$entries", "key"] }map.clear()→ oneremoveper 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()→ oneremoveper value
SyncListener<T>
Section titled “SyncListener<T>”type SyncListener<T> = (state: T) => void;Unsubscribe
Section titled “Unsubscribe”type Unsubscribe = () => void;DevTools Inspection
Section titled “DevTools Inspection”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.
