npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

state-sync-log

v0.10.0

Published

Validated Replicated State Machine built on Yjs. Combine CRDT offline capabilities with strict business logic validation, state reconciliation, and audit trails.

Downloads

228

Readme

The Problem with Standard CRDTs

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.

The Solution: state-sync-log

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.

Comparison

| 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 |


Example: Kanban Board with WIP Limits

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" }
])

Features

  • 🛡️ Bulletproof Validation: Define a single (state) => boolean function. 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.

Contents

Installation

npm install state-sync-log
# or
pnpm add state-sync-log
# or
yarn add state-sync-log

Storage Efficiency

Since this is an append-only log, you might worry about it growing forever. We solved that.

🗜️ Automatic Compaction & Retention

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.

Integration with MobX, Signals, etc

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 })

API Reference

createStateSyncLog(options)

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. |

StateSyncLogController

The object returned by createStateSyncLog.

getState(): State

Returns the current, validated state. Uses structural sharing for efficient immutable updates.

emit(ops: Op[]): void

Propose a change. The change applies optimistically but may be reverted if it conflicts with a remote change that renders it invalid.

subscribe(callback): UnsubscribeFn

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)
})

reconcileState(targetState: State): void

Automatically calculates the operations needed to turn the current state into targetState and emits them. Great for "Reset to Default" features.

compact(): void

Manually triggers a checkpoint. This compresses the history into a single snapshot to save memory and load time.

dispose(): void

Stop listening and cleanup.

Operations

These are the atomic building blocks of your transactions.

set (Objects)

Sets a property on an object.

{ kind: "set", path: ["users", "u1"], key: "name", value: "Alice" }

delete (Objects)

Removes a property (equivalent of setting a property to undefined).

{ kind: "delete", path: ["users", "u1"], key: "avatarUrl" }

splice (Arrays)

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"] }

addToSet (Arrays)

Adds an item only if it doesn't exist (like a Set).

{ kind: "addToSet", path: ["tags"], value: "urgent" }

deleteFromSet (Arrays)

Removes an item if it exists.

{ kind: "deleteFromSet", path: ["tags"], value: "deprecated" }

Generating Operations with createOps

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.

Basic Usage

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)

Supported Mutations

  • Object properties: draft.user.name = "Alice" generates a set op
  • Delete properties: delete draft.user.avatar generates a delete op
  • Array methods: push, pop, shift, unshift, splice, fill, sort, reverse, copyWithin all generate splice ops
  • Array index assignment: draft.list[0] = newItem generates a set op
  • Array length: draft.list.length = 5 generates a set op for length

Utility Functions

original(draft)

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")
  }
})

current(draft)

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 }
})

isDraft(value) / isDraftable(value)

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 instances

addToSet(draft, path, value) / deleteFromSet(draft, path, value)

Helpers 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', ... }]

Gotchas & Limitations

  1. Validation must be deterministic: Your validate function must return the same result for the same state input (deterministic). Don't check Date.now() or make API calls inside it.
  2. 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-log in the same application!

Contributing

See CONTRIBUTING.md.

License

MIT. See LICENSE.