state-sync-log 0.9.0 β 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/README.md +368 -277
- package/dist/state-sync-log.esm.js +929 -136
- package/dist/state-sync-log.esm.mjs +929 -136
- package/dist/state-sync-log.umd.js +928 -135
- package/dist/types/createOps/constant.d.ts +6 -0
- package/dist/types/createOps/createOps.d.ts +25 -0
- package/dist/types/createOps/current.d.ts +13 -0
- package/dist/types/createOps/draft.d.ts +14 -0
- package/dist/types/createOps/draftify.d.ts +5 -0
- package/dist/types/createOps/index.d.ts +12 -0
- package/dist/types/createOps/interface.d.ts +74 -0
- package/dist/types/createOps/original.d.ts +15 -0
- package/dist/types/createOps/pushOp.d.ts +9 -0
- package/dist/types/createOps/setHelpers.d.ts +25 -0
- package/dist/types/createOps/utils.d.ts +95 -0
- package/dist/types/draft.d.ts +2 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/json.d.ts +1 -1
- package/dist/types/operations.d.ts +2 -2
- package/dist/types/utils.d.ts +5 -0
- package/package.json +1 -1
- package/src/createOps/constant.ts +10 -0
- package/src/createOps/createOps.ts +97 -0
- package/src/createOps/current.ts +85 -0
- package/src/createOps/draft.ts +606 -0
- package/src/createOps/draftify.ts +45 -0
- package/src/createOps/index.ts +18 -0
- package/src/createOps/interface.ts +95 -0
- package/src/createOps/original.ts +24 -0
- package/src/createOps/pushOp.ts +42 -0
- package/src/createOps/setHelpers.ts +93 -0
- package/src/createOps/utils.ts +325 -0
- package/src/draft.ts +306 -288
- package/src/index.ts +1 -0
- package/src/json.ts +1 -1
- package/src/operations.ts +33 -11
- package/src/utils.ts +67 -55
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -1,277 +1,368 @@
|
|
|
1
|
-
<p align="center">
|
|
2
|
-
<img src="./logo.png" height="220" />
|
|
3
|
-
</p>
|
|
4
|
-
<p align="center">
|
|
5
|
-
<i>State synchronization log for collaborative applications. <b>Validate every change before it happens.</b></i>
|
|
6
|
-
</p>
|
|
7
|
-
|
|
8
|
-
<p align="center">
|
|
9
|
-
<a aria-label="NPM version" href="https://www.npmjs.com/package/state-sync-log">
|
|
10
|
-
<img src="https://img.shields.io/npm/v/state-sync-log.svg?style=for-the-badge&logo=npm&labelColor=333" />
|
|
11
|
-
</a>
|
|
12
|
-
<a aria-label="License" href="./LICENSE">
|
|
13
|
-
<img src="https://img.shields.io/npm/l/state-sync-log.svg?style=for-the-badge&labelColor=333" />
|
|
14
|
-
</a>
|
|
15
|
-
<a aria-label="Types" href="./packages/state-sync-log/tsconfig.json">
|
|
16
|
-
<img src="https://img.shields.io/npm/types/state-sync-log.svg?style=for-the-badge&logo=typescript&labelColor=333" />
|
|
17
|
-
</a>
|
|
18
|
-
<br />
|
|
19
|
-
<a aria-label="CI" href="https://github.com/xaviergonz/state-sync-log/actions/workflows/main.yml">
|
|
20
|
-
<img src="https://img.shields.io/github/actions/workflow/status/xaviergonz/state-sync-log/main.yml?branch=master&label=CI&logo=github&style=for-the-badge&labelColor=333" />
|
|
21
|
-
</a>
|
|
22
|
-
<a aria-label="Codecov" href="https://codecov.io/gh/xaviergonz/state-sync-log">
|
|
23
|
-
<img src="https://img.shields.io/codecov/c/github/xaviergonz/state-sync-log?token=6MLRFUBK8V&label=codecov&logo=codecov&style=for-the-badge&labelColor=333" />
|
|
24
|
-
</a>
|
|
25
|
-
</p>
|
|
26
|
-
|
|
27
|
-
## The Problem with Standard CRDTs
|
|
28
|
-
|
|
29
|
-
Tools like Yjs and Automerge are amazing for text editing because **they never reject a change**βthey just merge everything.
|
|
30
|
-
|
|
31
|
-
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.
|
|
32
|
-
|
|
33
|
-
## The Solution: state-sync-log
|
|
34
|
-
|
|
35
|
-
`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:
|
|
36
|
-
|
|
37
|
-
**Every transaction is validated against your business logic before it is applied.**
|
|
38
|
-
|
|
39
|
-
If a peer sends an invalid transaction your clients **reject it strictly and deterministically**, even when the change itself was made while offline.
|
|
40
|
-
|
|
41
|
-
### Comparison
|
|
42
|
-
|
|
43
|
-
| Feature | state-sync-log | Standard CRDTs (Yjs, Automerge) |
|
|
44
|
-
| :--- | :---: | :---: |
|
|
45
|
-
| **Conflict Strategy** | π«Έ **Reject Invalid Changes** | π **Merge Everything** |
|
|
46
|
-
| **Data Model** | Plain JSON | Specialized Types (Y.Map, Y.Array) |
|
|
47
|
-
| **Validation** | β
First-class citizen | β Not possible (by design) |
|
|
48
|
-
| **Best For** | Business logic, Forms, Games, CRUD, Complex editors | Text editing, Drawing, Notes |
|
|
49
|
-
|
|
50
|
-
---
|
|
51
|
-
|
|
52
|
-
## Example: Kanban Board with WIP Limits
|
|
53
|
-
|
|
54
|
-
Imagine a Kanban board where you strictly enforce a limit of **3 tasks** in the "Doing" column.
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { createStateSyncLog } from "state-sync-log"
|
|
58
|
-
import * as Y from "yjs"
|
|
59
|
-
|
|
60
|
-
type Task = { id: string; title: string; status: "todo" | "doing" | "done" }
|
|
61
|
-
type State = { tasks: Task[] }
|
|
62
|
-
|
|
63
|
-
// 1. Define your business rules
|
|
64
|
-
const validate = (state: State) => {
|
|
65
|
-
// RULE: Cannot have more than 3 tasks in 'doing'
|
|
66
|
-
const doingCount = state.tasks.filter(t => t.status === "doing").length
|
|
67
|
-
if (doingCount > 3) return false
|
|
68
|
-
|
|
69
|
-
// RULE: Tasks must always have a title
|
|
70
|
-
if (state.tasks.some(t => t.title.trim() === "")) return false
|
|
71
|
-
|
|
72
|
-
return true
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
// 2. Initialize the log
|
|
76
|
-
const log = createStateSyncLog<State>({
|
|
77
|
-
yDoc: new Y.Doc(),
|
|
78
|
-
validate,
|
|
79
|
-
// ... other options
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
// 3. Try to move a 4th task to "doing"
|
|
83
|
-
// If another user already filled the slot, this operation
|
|
84
|
-
// will be REJECTED on all clients (including this one).
|
|
85
|
-
log.emit([
|
|
86
|
-
{ kind: "set", path: ["tasks", 3], key: "status", value: "doing" }
|
|
87
|
-
])
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
## Features
|
|
91
|
-
|
|
92
|
-
- π‘οΈ **Bulletproof Validation**: Define a single `(state) => boolean` function. If it returns false, the transaction never happened.
|
|
93
|
-
- βοΈ **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).
|
|
94
|
-
- ποΈ **Optimistic UI**: Changes apply instantly locally. If they are later rejected (due to a conflict with a remote peer), the state automatically rolls back.
|
|
95
|
-
- π¦ **Plain JSON**: Work with standard JS objects and arrays. No need to learn `ymap.get('foo')` syntax.
|
|
96
|
-
- π **Network Agnostic**: Works with any Yjs provider (WebSockets, WebRTC, IndexedDB).
|
|
97
|
-
- πΎ **Storage Efficient**: Built-in compaction and retention policies keep your data small and fast.
|
|
98
|
-
|
|
99
|
-
## Contents
|
|
100
|
-
|
|
101
|
-
- [Installation](#installation)
|
|
102
|
-
- [API Reference](#api-reference)
|
|
103
|
-
- [Operations](#operations)
|
|
104
|
-
- [
|
|
105
|
-
- [
|
|
106
|
-
- [
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
- **
|
|
128
|
-
- **
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
import {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
// 2. Sync it!
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
|
181
|
-
|
|
|
182
|
-
| `
|
|
183
|
-
| `
|
|
184
|
-
| `
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./logo.png" height="220" />
|
|
3
|
+
</p>
|
|
4
|
+
<p align="center">
|
|
5
|
+
<i>State synchronization log for collaborative applications. <b>Validate every change before it happens.</b></i>
|
|
6
|
+
</p>
|
|
7
|
+
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a aria-label="NPM version" href="https://www.npmjs.com/package/state-sync-log">
|
|
10
|
+
<img src="https://img.shields.io/npm/v/state-sync-log.svg?style=for-the-badge&logo=npm&labelColor=333" />
|
|
11
|
+
</a>
|
|
12
|
+
<a aria-label="License" href="./LICENSE">
|
|
13
|
+
<img src="https://img.shields.io/npm/l/state-sync-log.svg?style=for-the-badge&labelColor=333" />
|
|
14
|
+
</a>
|
|
15
|
+
<a aria-label="Types" href="./packages/state-sync-log/tsconfig.json">
|
|
16
|
+
<img src="https://img.shields.io/npm/types/state-sync-log.svg?style=for-the-badge&logo=typescript&labelColor=333" />
|
|
17
|
+
</a>
|
|
18
|
+
<br />
|
|
19
|
+
<a aria-label="CI" href="https://github.com/xaviergonz/state-sync-log/actions/workflows/main.yml">
|
|
20
|
+
<img src="https://img.shields.io/github/actions/workflow/status/xaviergonz/state-sync-log/main.yml?branch=master&label=CI&logo=github&style=for-the-badge&labelColor=333" />
|
|
21
|
+
</a>
|
|
22
|
+
<a aria-label="Codecov" href="https://codecov.io/gh/xaviergonz/state-sync-log">
|
|
23
|
+
<img src="https://img.shields.io/codecov/c/github/xaviergonz/state-sync-log?token=6MLRFUBK8V&label=codecov&logo=codecov&style=for-the-badge&labelColor=333" />
|
|
24
|
+
</a>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
## The Problem with Standard CRDTs
|
|
28
|
+
|
|
29
|
+
Tools like Yjs and Automerge are amazing for text editing because **they never reject a change**βthey just merge everything.
|
|
30
|
+
|
|
31
|
+
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.
|
|
32
|
+
|
|
33
|
+
## The Solution: state-sync-log
|
|
34
|
+
|
|
35
|
+
`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:
|
|
36
|
+
|
|
37
|
+
**Every transaction is validated against your business logic before it is applied.**
|
|
38
|
+
|
|
39
|
+
If a peer sends an invalid transaction your clients **reject it strictly and deterministically**, even when the change itself was made while offline.
|
|
40
|
+
|
|
41
|
+
### Comparison
|
|
42
|
+
|
|
43
|
+
| Feature | state-sync-log | Standard CRDTs (Yjs, Automerge) |
|
|
44
|
+
| :--- | :---: | :---: |
|
|
45
|
+
| **Conflict Strategy** | π«Έ **Reject Invalid Changes** | π **Merge Everything** |
|
|
46
|
+
| **Data Model** | Plain JSON | Specialized Types (Y.Map, Y.Array) |
|
|
47
|
+
| **Validation** | β
First-class citizen | β Not possible (by design) |
|
|
48
|
+
| **Best For** | Business logic, Forms, Games, CRUD, Complex editors | Text editing, Drawing, Notes |
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## Example: Kanban Board with WIP Limits
|
|
53
|
+
|
|
54
|
+
Imagine a Kanban board where you strictly enforce a limit of **3 tasks** in the "Doing" column.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { createStateSyncLog } from "state-sync-log"
|
|
58
|
+
import * as Y from "yjs"
|
|
59
|
+
|
|
60
|
+
type Task = { id: string; title: string; status: "todo" | "doing" | "done" }
|
|
61
|
+
type State = { tasks: Task[] }
|
|
62
|
+
|
|
63
|
+
// 1. Define your business rules
|
|
64
|
+
const validate = (state: State) => {
|
|
65
|
+
// RULE: Cannot have more than 3 tasks in 'doing'
|
|
66
|
+
const doingCount = state.tasks.filter(t => t.status === "doing").length
|
|
67
|
+
if (doingCount > 3) return false
|
|
68
|
+
|
|
69
|
+
// RULE: Tasks must always have a title
|
|
70
|
+
if (state.tasks.some(t => t.title.trim() === "")) return false
|
|
71
|
+
|
|
72
|
+
return true
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 2. Initialize the log
|
|
76
|
+
const log = createStateSyncLog<State>({
|
|
77
|
+
yDoc: new Y.Doc(),
|
|
78
|
+
validate,
|
|
79
|
+
// ... other options
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// 3. Try to move a 4th task to "doing"
|
|
83
|
+
// If another user already filled the slot, this operation
|
|
84
|
+
// will be REJECTED on all clients (including this one).
|
|
85
|
+
log.emit([
|
|
86
|
+
{ kind: "set", path: ["tasks", 3], key: "status", value: "doing" }
|
|
87
|
+
])
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Features
|
|
91
|
+
|
|
92
|
+
- π‘οΈ **Bulletproof Validation**: Define a single `(state) => boolean` function. If it returns false, the transaction never happened.
|
|
93
|
+
- βοΈ **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).
|
|
94
|
+
- ποΈ **Optimistic UI**: Changes apply instantly locally. If they are later rejected (due to a conflict with a remote peer), the state automatically rolls back.
|
|
95
|
+
- π¦ **Plain JSON**: Work with standard JS objects and arrays. No need to learn `ymap.get('foo')` syntax.
|
|
96
|
+
- π **Network Agnostic**: Works with any Yjs provider (WebSockets, WebRTC, IndexedDB).
|
|
97
|
+
- πΎ **Storage Efficient**: Built-in compaction and retention policies keep your data small and fast.
|
|
98
|
+
|
|
99
|
+
## Contents
|
|
100
|
+
|
|
101
|
+
- [Installation](#installation)
|
|
102
|
+
- [API Reference](#api-reference)
|
|
103
|
+
- [Operations](#operations)
|
|
104
|
+
- [Generating Operations with createOps](#generating-operations-with-createops)
|
|
105
|
+
- [Gotchas & Limitations](#gotchas--limitations)
|
|
106
|
+
- [Contributing](#contributing)
|
|
107
|
+
- [License](#license)
|
|
108
|
+
|
|
109
|
+
## Installation
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install state-sync-log
|
|
113
|
+
# or
|
|
114
|
+
pnpm add state-sync-log
|
|
115
|
+
# or
|
|
116
|
+
yarn add state-sync-log
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Storage Efficiency
|
|
120
|
+
|
|
121
|
+
Since this is an append-only log, you might worry about it growing forever. We solved that.
|
|
122
|
+
|
|
123
|
+
### ποΈ Automatic Compaction & Retention
|
|
124
|
+
|
|
125
|
+
`state-sync-log` can periodically be asked to compact the log into a **snapshot checkpoint**.
|
|
126
|
+
|
|
127
|
+
- **Checkpoints:** New peers just load the latest snapshot + recent ops. Fast load times!
|
|
128
|
+
- **Retention Window:** Old transaction history is automatically pruned after a set time (recommended: 2 weeks).
|
|
129
|
+
- **Result:** You get a full audit trail for recent history, without unboundedly growing storage.
|
|
130
|
+
|
|
131
|
+
## Integration with MobX, Signals, etc
|
|
132
|
+
|
|
133
|
+
You don't have to replace your existing state manager. `state-sync-log` is designed to drive them.
|
|
134
|
+
|
|
135
|
+
Using `applyOps`, you can surgically apply updates to **MobX**, **Preact Signals**, or any mutable store:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
import { applyOps } from "state-sync-log"
|
|
139
|
+
import { observable } from "mobx"
|
|
140
|
+
|
|
141
|
+
// 1. Create your mutable MobX store (init with current state)
|
|
142
|
+
const store = observable(log.getState())
|
|
143
|
+
|
|
144
|
+
// 2. Sync it!
|
|
145
|
+
// 2. Sync it!
|
|
146
|
+
log.subscribe((newState, getAppliedOps) => {
|
|
147
|
+
// getAppliedOps is a lazy getter (computing reconciliation diffs only when requested)
|
|
148
|
+
const appliedOps = getAppliedOps()
|
|
149
|
+
|
|
150
|
+
// Apply ONLY the changes (efficient!)
|
|
151
|
+
applyOps(appliedOps, store)
|
|
152
|
+
})
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
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:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
// Calculate ops first
|
|
159
|
+
const appliedOps = getAppliedOps()
|
|
160
|
+
applyOps(appliedOps, store, { cloneValues: false })
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## API Reference
|
|
164
|
+
|
|
165
|
+
### `createStateSyncLog(options)`
|
|
166
|
+
|
|
167
|
+
Initializes the synchronization log.
|
|
168
|
+
|
|
169
|
+
```ts
|
|
170
|
+
import { createStateSyncLog } from "state-sync-log"
|
|
171
|
+
|
|
172
|
+
const log = createStateSyncLog<State>({
|
|
173
|
+
yDoc: new Y.Doc(),
|
|
174
|
+
validate: (state) => state.inventory >= 0
|
|
175
|
+
})
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
**Options:**
|
|
179
|
+
|
|
180
|
+
| Option | Type | Description |
|
|
181
|
+
| --- | --- | --- |
|
|
182
|
+
| `yDoc` | `Y.Doc` | **Required.** The Yjs document instance. |
|
|
183
|
+
| `validate` | `(state: State) => boolean` | **Required.** The gatekeeper function. If it returns `false`, the transaction is dropped. |
|
|
184
|
+
| `clientId` | `string` | Optional unique ID. Auto-generated if omitted. |
|
|
185
|
+
| `retentionWindowMs` | `number` | Time to keep transaction history before pruning (recommended: 2 weeks). Helps keep storage small. |
|
|
186
|
+
|
|
187
|
+
### `StateSyncLogController`
|
|
188
|
+
|
|
189
|
+
The object returned by `createStateSyncLog`.
|
|
190
|
+
|
|
191
|
+
#### `getState(): State`
|
|
192
|
+
|
|
193
|
+
Returns the current, validated state. Uses structural sharing for efficient immutable updates.
|
|
194
|
+
|
|
195
|
+
#### `emit(ops: Op[]): void`
|
|
196
|
+
|
|
197
|
+
Propose a change. The change applies optimistically but may be reverted if it conflicts with a remote change that renders it invalid.
|
|
198
|
+
|
|
199
|
+
#### `subscribe(callback): UnsubscribeFn`
|
|
200
|
+
|
|
201
|
+
Listen for state changes. The callback receives the new state and a lazy getter function for the operations applied.
|
|
202
|
+
|
|
203
|
+
```ts
|
|
204
|
+
log.subscribe((newState, getAppliedOps) => {
|
|
205
|
+
const appliedOps = getAppliedOps()
|
|
206
|
+
render(newState)
|
|
207
|
+
})
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `reconcileState(targetState: State): void`
|
|
211
|
+
|
|
212
|
+
Automatically calculates the operations needed to turn the current state into `targetState` and emits them. Great for "Reset to Default" features.
|
|
213
|
+
|
|
214
|
+
#### `compact(): void`
|
|
215
|
+
|
|
216
|
+
Manually triggers a checkpoint. This compresses the history into a single snapshot to save memory and load time.
|
|
217
|
+
|
|
218
|
+
#### `dispose(): void`
|
|
219
|
+
|
|
220
|
+
Stop listening and cleanup.
|
|
221
|
+
|
|
222
|
+
## Operations
|
|
223
|
+
|
|
224
|
+
These are the atomic building blocks of your transactions.
|
|
225
|
+
|
|
226
|
+
### `set` (Objects)
|
|
227
|
+
|
|
228
|
+
Sets a property on an object.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
{ kind: "set", path: ["users", "u1"], key: "name", value: "Alice" }
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### `delete` (Objects)
|
|
235
|
+
|
|
236
|
+
Removes a property (equivalent of setting a property to `undefined`).
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
{ kind: "delete", path: ["users", "u1"], key: "avatarUrl" }
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### `splice` (Arrays)
|
|
243
|
+
|
|
244
|
+
Insert, remove, or replace items in an array.
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
// Remove 1 item at index 0, insert "New Item"
|
|
248
|
+
{ kind: "splice", path: ["todoList"], index: 0, deleteCount: 1, inserts: ["New Item"] }
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `addToSet` (Arrays)
|
|
252
|
+
|
|
253
|
+
Adds an item only if it doesn't exist (like a Set).
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
{ kind: "addToSet", path: ["tags"], value: "urgent" }
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### `deleteFromSet` (Arrays)
|
|
260
|
+
|
|
261
|
+
Removes an item if it exists.
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
{ kind: "deleteFromSet", path: ["tags"], value: "deprecated" }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Generating Operations with `createOps`
|
|
268
|
+
|
|
269
|
+
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.
|
|
270
|
+
|
|
271
|
+
### Basic Usage
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
import { createOps } from "state-sync-log/createOps"
|
|
275
|
+
|
|
276
|
+
const state = { list: [{ text: "Learn", done: false }] }
|
|
277
|
+
|
|
278
|
+
const { nextState, ops } = createOps(state, (draft) => {
|
|
279
|
+
// Mutate the draft like you would a normal object
|
|
280
|
+
draft.list[0].done = true
|
|
281
|
+
draft.list.push({ text: "Practice", done: false })
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
// ops contains the operations that were performed:
|
|
285
|
+
// [
|
|
286
|
+
// { kind: 'set', path: ['list', 0], key: 'done', value: true },
|
|
287
|
+
// { kind: 'splice', path: ['list'], index: 1, deleteCount: 0, inserts: [{ text: 'Practice', done: false }] }
|
|
288
|
+
// ]
|
|
289
|
+
|
|
290
|
+
// nextState is the new immutable state (original state is unchanged)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Supported Mutations
|
|
294
|
+
|
|
295
|
+
- **Object properties**: `draft.user.name = "Alice"` generates a `set` op
|
|
296
|
+
- **Delete properties**: `delete draft.user.avatar` generates a `delete` op
|
|
297
|
+
- **Array methods**: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, `sort`, `reverse`, `copyWithin` all generate `splice` ops
|
|
298
|
+
- **Array index assignment**: `draft.list[0] = newItem` generates a `set` op
|
|
299
|
+
- **Array length**: `draft.list.length = 5` generates a `set` op for length
|
|
300
|
+
|
|
301
|
+
### Utility Functions
|
|
302
|
+
|
|
303
|
+
#### `original(draft)`
|
|
304
|
+
|
|
305
|
+
Returns the original (unmodified) value from a draft. Useful for comparisons.
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
import { createOps, original } from "state-sync-log/createOps"
|
|
309
|
+
|
|
310
|
+
createOps(state, (draft) => {
|
|
311
|
+
if (original(draft.user) !== draft.user) {
|
|
312
|
+
console.log("User was modified")
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
#### `current(draft)`
|
|
318
|
+
|
|
319
|
+
Returns a snapshot of the current state of the draft (deep clone).
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import { createOps, current } from "state-sync-log/createOps"
|
|
323
|
+
|
|
324
|
+
createOps(state, (draft) => {
|
|
325
|
+
draft.count++
|
|
326
|
+
console.log(current(draft)) // { count: 1 }
|
|
327
|
+
})
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
#### `isDraft(value)` / `isDraftable(value)`
|
|
331
|
+
|
|
332
|
+
Check if a value is a draft or can be made into one.
|
|
333
|
+
|
|
334
|
+
```ts
|
|
335
|
+
import { isDraft, isDraftable } from "state-sync-log/createOps"
|
|
336
|
+
|
|
337
|
+
isDraft(someDraft) // true for draft proxies
|
|
338
|
+
isDraftable({ a: 1 }) // true for plain objects/arrays
|
|
339
|
+
isDraftable(new Date()) // false for class instances
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
#### `addToSet(draft, path, value)` / `deleteFromSet(draft, path, value)`
|
|
343
|
+
|
|
344
|
+
Helpers for treating arrays as sets (no duplicates).
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
import { createOps, addToSet, deleteFromSet } from "state-sync-log/createOps"
|
|
348
|
+
|
|
349
|
+
const { ops } = createOps({ tags: ["a", "b"] }, (draft) => {
|
|
350
|
+
addToSet(draft, ["tags"], "c") // Adds "c" since it doesn't exist
|
|
351
|
+
addToSet(draft, ["tags"], "a") // No-op, "a" already exists
|
|
352
|
+
deleteFromSet(draft, ["tags"], "b") // Removes "b"
|
|
353
|
+
})
|
|
354
|
+
// ops: [{ kind: 'addToSet', ... }, { kind: 'deleteFromSet', ... }]
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
## Gotchas & Limitations
|
|
358
|
+
|
|
359
|
+
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.
|
|
360
|
+
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!
|
|
361
|
+
|
|
362
|
+
## Contributing
|
|
363
|
+
|
|
364
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
|
365
|
+
|
|
366
|
+
## License
|
|
367
|
+
|
|
368
|
+
MIT. See [LICENSE](./LICENSE).
|