State synchronization log for collaborative applications. Validate every change before it happens.
Tools like Yjs and Automerge are amazing for text editing because they never reject a change—they just merge everything.
But for business applications, most often than not we have rules where "merging everything" can result in a bug. For example, if you have a "WIP Limit" of 3 tasks in a Kanban board and users drag two tasks in at once, you end up with 4 tasks.
state-sync-log is a Validated Replicated State Machine. It uses the same robust technology as Yjs in its core (networking, offline support), but it fundamentally changes the rules:
Every transaction is validated against your business logic before it is applied.
If a peer sends an invalid transaction your clients reject it strictly and deterministically, even when the change itself was made while offline.
| Feature | state-sync-log | Standard CRDTs (Yjs, Automerge) |
|---|---|---|
| Conflict Strategy | 🫸 Reject Invalid Changes | 🔀 Merge Everything |
| Data Model | Plain JSON | Specialized Types (Y.Map, Y.Array) |
| Validation | ✅ First-class citizen | ❌ Not possible (by design) |
| Best For | Business logic, Forms, Games, CRUD, Complex editors | Text editing, Drawing, Notes |
Imagine a Kanban board where you strictly enforce a limit of 3 tasks in the "Doing" column.
import { createStateSyncLog } from "state-sync-log"
import * as Y from "yjs"
type Task = { id: string; title: string; status: "todo" | "doing" | "done" }
type State = { tasks: Task[] }
// 1. Define your business rules
const validate = (state: State) => {
// RULE: Cannot have more than 3 tasks in 'doing'
const doingCount = state.tasks.filter(t => t.status === "doing").length
if (doingCount > 3) return false
// RULE: Tasks must always have a title
if (state.tasks.some(t => t.title.trim() === "")) return false
return true
}
// 2. Initialize the log
const log = createStateSyncLog<State>({
yDoc: new Y.Doc(),
validate,
// ... other options
})
// 3. Try to move a 4th task to "doing"
// If another user already filled the slot, this operation
// will be REJECTED on all clients (including this one).
log.emit([
{ kind: "set", path: ["tasks", 3], key: "status", value: "doing" }
])- 🛡️ Bulletproof Validation: Define a single
(state) => booleanfunction. If it returns false, the transaction never happened. - ⏭️ Replayable History: Since it's an event log, you can replay history to see exactly how a state was reached (up to the nearest checkpoint).
- 🏎️ Optimistic UI: Changes apply instantly locally. If they are later rejected (due to a conflict with a remote peer), the state automatically rolls back.
- 📦 Plain JSON: Work with standard JS objects and arrays. No need to learn
ymap.get('foo')syntax. - 🔌 Network Agnostic: Works with any Yjs provider (WebSockets, WebRTC, IndexedDB).
- 💾 Storage Efficient: Built-in compaction and retention policies keep your data small and fast.
- Installation
- API Reference
- Operations
- Generating Operations with createOps
- Gotchas & Limitations
- Contributing
- License
npm install state-sync-log
# or
pnpm add state-sync-log
# or
yarn add state-sync-logSince this is an append-only log, you might worry about it growing forever. We solved that.
state-sync-log can periodically be asked to compact the log into a snapshot checkpoint.
- Checkpoints: New peers just load the latest snapshot + recent ops. Fast load times!
- Retention Window: Old transaction history is automatically pruned after a set time (recommended: 2 weeks).
- Result: You get a full audit trail for recent history, without unboundedly growing storage.
You don't have to replace your existing state manager. state-sync-log is designed to drive them.
Using applyOps, you can surgically apply updates to MobX, Preact Signals, or any mutable store:
import { applyOps } from "state-sync-log"
import { observable } from "mobx"
// 1. Create your mutable MobX store (init with current state)
const store = observable(log.getState())
// 2. Sync it!
// 2. Sync it!
log.subscribe((newState, getAppliedOps) => {
// getAppliedOps is a lazy getter (computing reconciliation diffs only when requested)
const appliedOps = getAppliedOps()
// Apply ONLY the changes (efficient!)
applyOps(appliedOps, store)
})By default, applyOps deep clones values before inserting them to prevent aliasing. For better performance, you can disable cloning if you guarantee op values won't be mutated:
// Calculate ops first
const appliedOps = getAppliedOps()
applyOps(appliedOps, store, { cloneValues: false })Initializes the synchronization log.
import { createStateSyncLog } from "state-sync-log"
const log = createStateSyncLog<State>({
yDoc: new Y.Doc(),
validate: (state) => state.inventory >= 0
})Options:
| Option | Type | Description |
|---|---|---|
yDoc |
Y.Doc |
Required. The Yjs document instance. |
validate |
(state: State) => boolean |
Required. The gatekeeper function. If it returns false, the transaction is dropped. |
clientId |
string |
Optional unique ID. Auto-generated if omitted. |
retentionWindowMs |
number |
Time to keep transaction history before pruning (recommended: 2 weeks). Helps keep storage small. |
The object returned by createStateSyncLog.
Returns the current, validated state. Uses structural sharing for efficient immutable updates.
Propose a change. The change applies optimistically but may be reverted if it conflicts with a remote change that renders it invalid.
Listen for state changes. The callback receives the new state and a lazy getter function for the operations applied.
log.subscribe((newState, getAppliedOps) => {
const appliedOps = getAppliedOps()
render(newState)
})Automatically calculates the operations needed to turn the current state into targetState and emits them. Great for "Reset to Default" features.
Manually triggers a checkpoint. This compresses the history into a single snapshot to save memory and load time.
Stop listening and cleanup.
These are the atomic building blocks of your transactions.
Sets a property on an object.
{ kind: "set", path: ["users", "u1"], key: "name", value: "Alice" }Removes a property (equivalent of setting a property to undefined).
{ kind: "delete", path: ["users", "u1"], key: "avatarUrl" }Insert, remove, or replace items in an array.
// Remove 1 item at index 0, insert "New Item"
{ kind: "splice", path: ["todoList"], index: 0, deleteCount: 1, inserts: ["New Item"] }Adds an item only if it doesn't exist (like a Set).
{ kind: "addToSet", path: ["tags"], value: "urgent" }Removes an item if it exists.
{ kind: "deleteFromSet", path: ["tags"], value: "deprecated" }Writing operations by hand can be tedious and error-prone. The createOps utility lets you describe changes using familiar mutable-style JavaScript code, and it automatically generates the corresponding operations.
import { createOps } from "state-sync-log/createOps"
const state = { list: [{ text: "Learn", done: false }] }
const { nextState, ops } = createOps(state, (draft) => {
// Mutate the draft like you would a normal object
draft.list[0].done = true
draft.list.push({ text: "Practice", done: false })
})
// ops contains the operations that were performed:
// [
// { kind: 'set', path: ['list', 0], key: 'done', value: true },
// { kind: 'splice', path: ['list'], index: 1, deleteCount: 0, inserts: [{ text: 'Practice', done: false }] }
// ]
// nextState is the new immutable state (original state is unchanged)- Object properties:
draft.user.name = "Alice"generates asetop - Delete properties:
delete draft.user.avatargenerates adeleteop - Array methods:
push,pop,shift,unshift,splice,fill,sort,reverse,copyWithinall generatespliceops - Array index assignment:
draft.list[0] = newItemgenerates asetop - Array length:
draft.list.length = 5generates asetop for length
Returns the original (unmodified) value from a draft. Useful for comparisons.
import { createOps, original } from "state-sync-log/createOps"
createOps(state, (draft) => {
if (original(draft.user) !== draft.user) {
console.log("User was modified")
}
})Returns a snapshot of the current state of the draft (deep clone).
import { createOps, current } from "state-sync-log/createOps"
createOps(state, (draft) => {
draft.count++
console.log(current(draft)) // { count: 1 }
})Check if a value is a draft or can be made into one.
import { isDraft, isDraftable } from "state-sync-log/createOps"
isDraft(someDraft) // true for draft proxies
isDraftable({ a: 1 }) // true for plain objects/arrays
isDraftable(new Date()) // false for class instancesHelpers for treating arrays as sets (no duplicates).
import { createOps, addToSet, deleteFromSet } from "state-sync-log/createOps"
const { ops } = createOps({ tags: ["a", "b"] }, (draft) => {
addToSet(draft, ["tags"], "c") // Adds "c" since it doesn't exist
addToSet(draft, ["tags"], "a") // No-op, "a" already exists
deleteFromSet(draft, ["tags"], "b") // Removes "b"
})
// ops: [{ kind: 'addToSet', ... }, { kind: 'deleteFromSet', ... }]- Validation must be deterministic: Your
validatefunction must return the same result for the same state input (deterministic). Don't checkDate.now()or make API calls inside it. - Not for Text: Do not use this for collaborative text editing (Google Docs style). Use standard Y.Text for that; you can mix standard Yjs and
state-sync-login the same application!
See CONTRIBUTING.md.
MIT. See LICENSE.
