json-diffsync 0.0.1
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/LICENSE +21 -0
- package/README.md +419 -0
- package/package.json +76 -0
- package/src/client.js +102 -0
- package/src/core/json.js +35 -0
- package/src/core/patch.js +242 -0
- package/src/index.d.ts +35 -0
- package/src/index.js +4 -0
- package/src/persistence.js +14 -0
- package/src/react.d.ts +16 -0
- package/src/react.js +72 -0
- package/src/server/memory.js +168 -0
- package/src/server/transport.js +34 -0
- package/src/server.d.ts +9 -0
- package/src/server.js +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Himanshu Singh
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
# json-diffsync
|
|
2
|
+
|
|
3
|
+
Differential synchronization primitives for JSON autosave.
|
|
4
|
+
|
|
5
|
+
`json-diffsync` helps keep multiple open copies of the same JSON document in sync without letting one stale tab or device overwrite newer work from another. It is designed for autosave flows in editors, form builders, dashboard builders, app-state editors, and JSON-based rich-text frameworks.
|
|
6
|
+
|
|
7
|
+
It is intentionally not a CRDT and not Operational Transformation. It follows the classic differential synchronization model: every client keeps a local value and a shadow, the server keeps a canonical value and one shadow per client session, and sync requests exchange patches computed between the shadow and current JSON.
|
|
8
|
+
|
|
9
|
+
## Core Idea
|
|
10
|
+
|
|
11
|
+
```txt
|
|
12
|
+
Any JSON works.
|
|
13
|
+
Keyed arrays get item-level patches.
|
|
14
|
+
Unkeyed arrays still sync, but as lossy atomic replacements.
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Objects are diffed by property path. Arrays of objects are treated as keyed when each item has a stable `key` or `id` field by default. You can configure other identity fields such as `uuid`, `nodeId`, or `blockId`.
|
|
18
|
+
|
|
19
|
+
## Client Features
|
|
20
|
+
|
|
21
|
+
- Create an autosave client with `createAutosaveClient`.
|
|
22
|
+
- Track `value`, `shadow`, `clientVersion`, and `serverVersion`.
|
|
23
|
+
- Generate patches from local JSON changes.
|
|
24
|
+
- Send patches through any transport with a `sync(message)` function.
|
|
25
|
+
- Use the built-in `createFetchTransport` for HTTP sync.
|
|
26
|
+
- Persist local client state with `createLocalStoragePersister`.
|
|
27
|
+
- Recover from server shadow mismatch without blindly discarding unsynced local edits.
|
|
28
|
+
- Configure identity fields with `keyFields`.
|
|
29
|
+
- Use `sync({ confirmDestructive: true })` for explicit destructive saves.
|
|
30
|
+
|
|
31
|
+
## React Client Features
|
|
32
|
+
|
|
33
|
+
- Use `useDifferentialAutosave` for React apps.
|
|
34
|
+
- Autosync on an interval with `intervalMs`.
|
|
35
|
+
- Persist hook state under a configurable `storageKey`.
|
|
36
|
+
- Expose `value`, `setValue`, `sync`, `status`, and `error`.
|
|
37
|
+
- Works with any JSON-producing editor or UI state, including Lexical-style JSON.
|
|
38
|
+
|
|
39
|
+
## Server Features
|
|
40
|
+
|
|
41
|
+
- Create an in-memory reference server with `createMemoryAutosaveServer`.
|
|
42
|
+
- Store canonical JSON document state.
|
|
43
|
+
- Maintain one server-side shadow per client/session.
|
|
44
|
+
- Validate client versions and shadow hashes.
|
|
45
|
+
- Apply client patches and return missing server patches.
|
|
46
|
+
- Configure keyed array identity with `keyFields`.
|
|
47
|
+
- Configure destructive patch sensitivity with `destructiveDeleteRatio`.
|
|
48
|
+
- Keep or disable revision history with `keepRevisions`.
|
|
49
|
+
- Expose a Node HTTP handler with `createNodeSyncHandler`.
|
|
50
|
+
|
|
51
|
+
The in-memory server is a reference implementation. Production apps will usually wrap the same sync logic with durable storage.
|
|
52
|
+
|
|
53
|
+
## Shared Patch Features
|
|
54
|
+
|
|
55
|
+
- Diff arbitrary JSON with `createJsonPatch`.
|
|
56
|
+
- Apply patches with `applyJsonPatch`.
|
|
57
|
+
- Hash JSON deterministically with `hashJson`.
|
|
58
|
+
- Diff keyed arrays as item-level operations: `insertItem`, `removeItem`, `reorderItems`.
|
|
59
|
+
- Diff objects as path-level operations: `set`, `replace`, `delete`.
|
|
60
|
+
- Mark unkeyed array replacements with `patch.lossy === true`.
|
|
61
|
+
|
|
62
|
+
## Install
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
npm install json-diffsync
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Quick Start
|
|
69
|
+
|
|
70
|
+
Create a server-side sync store:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
import http from "node:http";
|
|
74
|
+
import {
|
|
75
|
+
createMemoryAutosaveServer,
|
|
76
|
+
createNodeSyncHandler
|
|
77
|
+
} from "json-diffsync/server";
|
|
78
|
+
|
|
79
|
+
const sync = createMemoryAutosaveServer({
|
|
80
|
+
keyFields: ["key", "id"]
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
sync.createDocument({
|
|
84
|
+
documentId: "doc-1",
|
|
85
|
+
value: {
|
|
86
|
+
title: "Draft",
|
|
87
|
+
blocks: []
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const handleSync = createNodeSyncHandler(sync);
|
|
92
|
+
|
|
93
|
+
http.createServer((request, response) => {
|
|
94
|
+
if (request.url === "/sync") return handleSync(request, response);
|
|
95
|
+
response.writeHead(404).end();
|
|
96
|
+
}).listen(3000);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Create a client:
|
|
100
|
+
|
|
101
|
+
```js
|
|
102
|
+
import { createAutosaveClient } from "json-diffsync";
|
|
103
|
+
import { createFetchTransport } from "json-diffsync/server";
|
|
104
|
+
|
|
105
|
+
const client = createAutosaveClient({
|
|
106
|
+
documentId: "doc-1",
|
|
107
|
+
sessionId: "laptop",
|
|
108
|
+
initialValue: {
|
|
109
|
+
title: "Draft",
|
|
110
|
+
blocks: []
|
|
111
|
+
},
|
|
112
|
+
transport: createFetchTransport("/sync")
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
client.setValue({
|
|
116
|
+
title: "Draft",
|
|
117
|
+
blocks: [
|
|
118
|
+
{ id: "intro", text: "Hello from the laptop" }
|
|
119
|
+
]
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await client.sync();
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## React Usage
|
|
126
|
+
|
|
127
|
+
```jsx
|
|
128
|
+
import { useDifferentialAutosave } from "json-diffsync/react";
|
|
129
|
+
import { createFetchTransport } from "json-diffsync/server";
|
|
130
|
+
|
|
131
|
+
const transport = createFetchTransport("/sync");
|
|
132
|
+
|
|
133
|
+
export function JsonEditor({ documentId, sessionId }) {
|
|
134
|
+
const autosave = useDifferentialAutosave({
|
|
135
|
+
documentId,
|
|
136
|
+
sessionId,
|
|
137
|
+
initialValue: {
|
|
138
|
+
title: "Untitled",
|
|
139
|
+
blocks: []
|
|
140
|
+
},
|
|
141
|
+
transport,
|
|
142
|
+
keyFields: ["id"],
|
|
143
|
+
intervalMs: 1500
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<button onClick={() => autosave.sync()}>
|
|
148
|
+
Save now
|
|
149
|
+
</button>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
For Lexical or other editor frameworks, call `autosave.setValue(...)` with the serialized JSON state when the editor updates, then apply `autosave.value` back to the editor when remote patches arrive.
|
|
155
|
+
|
|
156
|
+
## Fidelity Model
|
|
157
|
+
|
|
158
|
+
### Objects
|
|
159
|
+
|
|
160
|
+
Plain objects are diffed by property path:
|
|
161
|
+
|
|
162
|
+
```json
|
|
163
|
+
{ "title": "Draft" }
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
If `title` changes, the patch contains a path-level operation.
|
|
167
|
+
|
|
168
|
+
### Keyed Arrays
|
|
169
|
+
|
|
170
|
+
Keyed arrays are diffed by item identity:
|
|
171
|
+
|
|
172
|
+
```json
|
|
173
|
+
{
|
|
174
|
+
"blocks": [
|
|
175
|
+
{ "id": "intro", "text": "One" },
|
|
176
|
+
{ "id": "body", "text": "Two" }
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
If one client edits `intro` while another inserts a new block, the server can apply both without replacing the whole `blocks` array.
|
|
182
|
+
|
|
183
|
+
### Unkeyed Arrays
|
|
184
|
+
|
|
185
|
+
Unkeyed arrays still work:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{ "tags": ["draft", "review"] }
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
But they are treated as atomic replacements and marked lossy:
|
|
192
|
+
|
|
193
|
+
```js
|
|
194
|
+
patch.lossy === true
|
|
195
|
+
patch.ops[0].lossyReason === "unkeyed_array_replace"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
This means the library can sync the value, but concurrent edits to the same unkeyed array can overwrite each other. If a collection matters, give each item a stable key.
|
|
199
|
+
|
|
200
|
+
## Custom Identity Fields
|
|
201
|
+
|
|
202
|
+
Use `keyFields` on both client and server:
|
|
203
|
+
|
|
204
|
+
```js
|
|
205
|
+
const sync = createMemoryAutosaveServer({
|
|
206
|
+
keyFields: ["uuid", "nodeId", "blockId"]
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const client = createAutosaveClient({
|
|
210
|
+
documentId,
|
|
211
|
+
sessionId,
|
|
212
|
+
initialValue,
|
|
213
|
+
transport,
|
|
214
|
+
keyFields: ["uuid", "nodeId", "blockId"]
|
|
215
|
+
});
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## Network Shape
|
|
219
|
+
|
|
220
|
+
Client sends:
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"documentId": "doc-1",
|
|
225
|
+
"sessionId": "laptop",
|
|
226
|
+
"clientVersion": 2,
|
|
227
|
+
"serverVersion": 3,
|
|
228
|
+
"shadowHash": "a1b2c3d4",
|
|
229
|
+
"patch": {
|
|
230
|
+
"kind": "json-keyed",
|
|
231
|
+
"baseHash": "a1b2c3d4",
|
|
232
|
+
"lossy": false,
|
|
233
|
+
"ops": [
|
|
234
|
+
{
|
|
235
|
+
"op": "set",
|
|
236
|
+
"path": ["blocks", { "$key": "intro" }, "text"],
|
|
237
|
+
"value": "Hello from the laptop"
|
|
238
|
+
}
|
|
239
|
+
]
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Server responds with what the client is missing:
|
|
245
|
+
|
|
246
|
+
```json
|
|
247
|
+
{
|
|
248
|
+
"ok": true,
|
|
249
|
+
"clientVersion": 3,
|
|
250
|
+
"serverVersion": 4,
|
|
251
|
+
"patch": {
|
|
252
|
+
"kind": "json-keyed",
|
|
253
|
+
"lossy": false,
|
|
254
|
+
"ops": []
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
## Autosave Flow
|
|
260
|
+
|
|
261
|
+
Each client maintains:
|
|
262
|
+
|
|
263
|
+
```txt
|
|
264
|
+
client.value current JSON state
|
|
265
|
+
client.shadow server state this client last synced against
|
|
266
|
+
clientVersion monotonic version for this client shadow
|
|
267
|
+
serverVersion last server version this client received
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
The server maintains:
|
|
271
|
+
|
|
272
|
+
```txt
|
|
273
|
+
document.value canonical JSON
|
|
274
|
+
document.sessions[laptop].value server-side shadow for laptop
|
|
275
|
+
document.sessions[ipad].value server-side shadow for iPad
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
When a stale laptop autosaves, it does not say “replace the server with my full JSON.” It sends only the diff between `client.shadow` and `client.value`. If the laptop made no local edits, that patch is empty. The server then diffs the laptop shadow against canonical state and sends back changes made elsewhere.
|
|
279
|
+
|
|
280
|
+
## Guardrails
|
|
281
|
+
|
|
282
|
+
Differential sync prevents stale full-document overwrites, but it cannot know whether a valid delete-most diff came from a real user action or a broken client. The reference server includes:
|
|
283
|
+
|
|
284
|
+
- Shadow hash validation.
|
|
285
|
+
- Client version validation.
|
|
286
|
+
- Destructive patch confirmation.
|
|
287
|
+
- Revision history.
|
|
288
|
+
|
|
289
|
+
By default, a patch that shrinks the JSON payload by at least 80%, or removes at least 80% of a keyed child array, is rejected unless `sync({ confirmDestructive: true })` is used.
|
|
290
|
+
|
|
291
|
+
## API
|
|
292
|
+
|
|
293
|
+
### Client And Shared Core
|
|
294
|
+
|
|
295
|
+
```js
|
|
296
|
+
import {
|
|
297
|
+
createAutosaveClient,
|
|
298
|
+
createLocalStoragePersister,
|
|
299
|
+
createJsonPatch,
|
|
300
|
+
applyJsonPatch,
|
|
301
|
+
cloneJson,
|
|
302
|
+
stableStringify,
|
|
303
|
+
hashJson
|
|
304
|
+
} from "json-diffsync";
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
Client helpers:
|
|
308
|
+
|
|
309
|
+
- `createAutosaveClient(options)`
|
|
310
|
+
- `createLocalStoragePersister(key, storage?)`
|
|
311
|
+
|
|
312
|
+
Patch helpers:
|
|
313
|
+
|
|
314
|
+
- `createJsonPatch(before, after, options?)`
|
|
315
|
+
- `applyJsonPatch(value, patch, options?)`
|
|
316
|
+
- `hashJson(value)`
|
|
317
|
+
- `stableStringify(value)`
|
|
318
|
+
- `cloneJson(value)`
|
|
319
|
+
|
|
320
|
+
### React Client
|
|
321
|
+
|
|
322
|
+
```js
|
|
323
|
+
import { useDifferentialAutosave } from "json-diffsync/react";
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
React hook:
|
|
327
|
+
|
|
328
|
+
- `useDifferentialAutosave(options)`
|
|
329
|
+
|
|
330
|
+
### Server
|
|
331
|
+
|
|
332
|
+
```js
|
|
333
|
+
import {
|
|
334
|
+
createMemoryAutosaveServer,
|
|
335
|
+
createNodeSyncHandler,
|
|
336
|
+
createFetchTransport
|
|
337
|
+
} from "json-diffsync/server";
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Server helpers:
|
|
341
|
+
|
|
342
|
+
- `createMemoryAutosaveServer(options?)`
|
|
343
|
+
- `createNodeSyncHandler(server)`
|
|
344
|
+
- `createFetchTransport(url, fetchImpl?)`
|
|
345
|
+
|
|
346
|
+
## Source Layout
|
|
347
|
+
|
|
348
|
+
```txt
|
|
349
|
+
src/index.js public client/core entrypoint
|
|
350
|
+
src/client.js autosave client state machine
|
|
351
|
+
src/persistence.js localStorage-style persister
|
|
352
|
+
src/core/json.js clone, stable stringify, hash helpers
|
|
353
|
+
src/core/patch.js JSON diff and patch implementation
|
|
354
|
+
src/react.js React autosave hook
|
|
355
|
+
src/server.js public server entrypoint
|
|
356
|
+
src/server/memory.js in-memory reference sync server
|
|
357
|
+
src/server/transport.js fetch transport and Node HTTP handler
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
## Testing
|
|
361
|
+
|
|
362
|
+
Run the protocol and HTTP E2E tests:
|
|
363
|
+
|
|
364
|
+
```sh
|
|
365
|
+
npm test
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
The suite covers:
|
|
369
|
+
|
|
370
|
+
- stale laptop/iPad autosave recovery
|
|
371
|
+
- keyed item-level patches
|
|
372
|
+
- unkeyed lossy patches
|
|
373
|
+
- custom key fields
|
|
374
|
+
- destructive delete rejection
|
|
375
|
+
- large nested JSON documents
|
|
376
|
+
- HTTP sync over a real local server
|
|
377
|
+
- expected failure paths, including malformed patches, unknown sessions, version/hash mismatches, transport errors, and invalid HTTP methods
|
|
378
|
+
|
|
379
|
+
Run browser E2E with React + Lexical:
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
npm run test:e2e:browser
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
That test starts a Node sync API, starts a Vite React app, opens two isolated Chromium contexts as `laptop` and `ipad`, types into both editors, syncs over HTTP, and asserts both browser pages plus the server JSON converge.
|
|
386
|
+
|
|
387
|
+
## Benchmark
|
|
388
|
+
|
|
389
|
+
Run:
|
|
390
|
+
|
|
391
|
+
```sh
|
|
392
|
+
npm run bench
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
The benchmark generates deterministic nested JSON documents, mutates deep keyed nodes, inserts keyed blocks, reorders sections, and measures patch creation, patch application, and full in-memory client/server sync.
|
|
396
|
+
|
|
397
|
+
Current MVP behavior: patch size scales well, but full sync time still grows with total JSON size because the implementation hashes, stringifies, and clones whole values in a few places. Optimization targets are documented by the benchmark.
|
|
398
|
+
|
|
399
|
+
## Status
|
|
400
|
+
|
|
401
|
+
`json-diffsync` is early-stage software. The core model is working and tested, but the API may change before `1.0.0`.
|
|
402
|
+
|
|
403
|
+
Good current use cases:
|
|
404
|
+
|
|
405
|
+
- JSON autosave
|
|
406
|
+
- same-user multi-tab/device editing
|
|
407
|
+
- keyed editor/app state
|
|
408
|
+
- prototype collaboration flows
|
|
409
|
+
|
|
410
|
+
Use extra care for:
|
|
411
|
+
|
|
412
|
+
- high-frequency multi-user editing
|
|
413
|
+
- very large JSON values
|
|
414
|
+
- concurrent edits to the same scalar field
|
|
415
|
+
- unkeyed arrays where concurrent edits matter
|
|
416
|
+
|
|
417
|
+
## License
|
|
418
|
+
|
|
419
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "json-diffsync",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Differential synchronization primitives for JSON autosave, React clients, and Node servers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Himanshu Singh",
|
|
8
|
+
"homepage": "https://github.com/the5ereneRebe1/json-diffsync#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/the5ereneRebe1/json-diffsync.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/the5ereneRebe1/json-diffsync/issues"
|
|
15
|
+
},
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"types": "./src/index.d.ts",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=20"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./src/index.d.ts",
|
|
32
|
+
"import": "./src/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./react": {
|
|
35
|
+
"types": "./src/react.d.ts",
|
|
36
|
+
"import": "./src/react.js"
|
|
37
|
+
},
|
|
38
|
+
"./server": {
|
|
39
|
+
"types": "./src/server.d.ts",
|
|
40
|
+
"import": "./src/server.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"test": "node --test test/*.test.js",
|
|
45
|
+
"bench": "node scripts/bench.js",
|
|
46
|
+
"test:e2e:browser": "playwright test test/browser-e2e.spec.js",
|
|
47
|
+
"prepublishOnly": "npm test"
|
|
48
|
+
},
|
|
49
|
+
"keywords": [
|
|
50
|
+
"json",
|
|
51
|
+
"diff",
|
|
52
|
+
"differential-sync",
|
|
53
|
+
"autosave",
|
|
54
|
+
"differential-synchronization",
|
|
55
|
+
"react",
|
|
56
|
+
"collaboration",
|
|
57
|
+
"sync"
|
|
58
|
+
],
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"react": ">=18"
|
|
61
|
+
},
|
|
62
|
+
"peerDependenciesMeta": {
|
|
63
|
+
"react": {
|
|
64
|
+
"optional": true
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
"devDependencies": {
|
|
68
|
+
"@lexical/react": "^0.45.0",
|
|
69
|
+
"@playwright/test": "^1.60.0",
|
|
70
|
+
"@vitejs/plugin-react": "^6.0.2",
|
|
71
|
+
"lexical": "^0.45.0",
|
|
72
|
+
"react": "^19.2.7",
|
|
73
|
+
"react-dom": "^19.2.7",
|
|
74
|
+
"vite": "^8.0.16"
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/client.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { DEFAULT_KEY_FIELDS, cloneJson, hashJson } from "./core/json.js";
|
|
2
|
+
import { applyJsonPatch, createJsonPatch } from "./core/patch.js";
|
|
3
|
+
|
|
4
|
+
export function createAutosaveClient(options) {
|
|
5
|
+
const {
|
|
6
|
+
documentId,
|
|
7
|
+
sessionId,
|
|
8
|
+
initialValue = null,
|
|
9
|
+
clientVersion = 0,
|
|
10
|
+
serverVersion = 0,
|
|
11
|
+
transport,
|
|
12
|
+
persister,
|
|
13
|
+
keyFields = DEFAULT_KEY_FIELDS
|
|
14
|
+
} = options;
|
|
15
|
+
|
|
16
|
+
const saved = persister?.load();
|
|
17
|
+
const state = {
|
|
18
|
+
documentId,
|
|
19
|
+
sessionId,
|
|
20
|
+
value: saved?.value ?? cloneJson(initialValue),
|
|
21
|
+
shadow: saved?.shadow ?? cloneJson(initialValue),
|
|
22
|
+
clientVersion: saved?.clientVersion ?? clientVersion,
|
|
23
|
+
serverVersion: saved?.serverVersion ?? serverVersion,
|
|
24
|
+
syncing: false,
|
|
25
|
+
lastError: null
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function persist() {
|
|
29
|
+
persister?.save({
|
|
30
|
+
value: state.value,
|
|
31
|
+
shadow: state.shadow,
|
|
32
|
+
clientVersion: state.clientVersion,
|
|
33
|
+
serverVersion: state.serverVersion
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
persist();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
get state() {
|
|
41
|
+
return {
|
|
42
|
+
...state,
|
|
43
|
+
value: cloneJson(state.value),
|
|
44
|
+
shadow: cloneJson(state.shadow)
|
|
45
|
+
};
|
|
46
|
+
},
|
|
47
|
+
getValue() {
|
|
48
|
+
return cloneJson(state.value);
|
|
49
|
+
},
|
|
50
|
+
setValue(nextValue) {
|
|
51
|
+
state.value = cloneJson(nextValue);
|
|
52
|
+
persist();
|
|
53
|
+
},
|
|
54
|
+
async sync(meta = {}) {
|
|
55
|
+
if (state.syncing) return { skipped: true };
|
|
56
|
+
state.syncing = true;
|
|
57
|
+
state.lastError = null;
|
|
58
|
+
|
|
59
|
+
const patch = createJsonPatch(state.shadow, state.value, { keyFields });
|
|
60
|
+
try {
|
|
61
|
+
const response = await transport.sync({
|
|
62
|
+
documentId,
|
|
63
|
+
sessionId,
|
|
64
|
+
clientVersion: state.clientVersion,
|
|
65
|
+
serverVersion: state.serverVersion,
|
|
66
|
+
shadowHash: hashJson(state.shadow),
|
|
67
|
+
patch,
|
|
68
|
+
meta
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
if (response.reason === "shadow_mismatch" && response.value !== undefined) {
|
|
73
|
+
const hasLocalChanges = hashJson(state.value) !== hashJson(state.shadow);
|
|
74
|
+
if (!hasLocalChanges) state.value = cloneJson(response.value);
|
|
75
|
+
state.shadow = cloneJson(response.value);
|
|
76
|
+
state.clientVersion = response.clientVersion ?? state.clientVersion;
|
|
77
|
+
state.serverVersion = response.serverVersion ?? state.serverVersion;
|
|
78
|
+
persist();
|
|
79
|
+
}
|
|
80
|
+
throw new Error(response.reason ?? "Sync failed.");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
state.shadow = applyJsonPatch(state.shadow, patch, { strict: true, keyFields });
|
|
84
|
+
state.clientVersion = response.clientVersion;
|
|
85
|
+
|
|
86
|
+
if (response.patch) {
|
|
87
|
+
state.value = applyJsonPatch(state.value, response.patch, { keyFields });
|
|
88
|
+
state.shadow = applyJsonPatch(state.shadow, response.patch, { strict: true, keyFields });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
state.serverVersion = response.serverVersion;
|
|
92
|
+
persist();
|
|
93
|
+
return { ok: true, changed: patch.ops.length > 0 };
|
|
94
|
+
} catch (error) {
|
|
95
|
+
state.lastError = error;
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
state.syncing = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}
|
package/src/core/json.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const DEFAULT_KEY_FIELDS = ["key", "id"];
|
|
2
|
+
|
|
3
|
+
export function cloneJson(value) {
|
|
4
|
+
if (value === undefined) return undefined;
|
|
5
|
+
return JSON.parse(JSON.stringify(value));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function stableStringify(value) {
|
|
9
|
+
return JSON.stringify(sortJson(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function hashJson(value) {
|
|
13
|
+
const text = stableStringify(value);
|
|
14
|
+
let hash = 2166136261;
|
|
15
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
16
|
+
hash ^= text.charCodeAt(index);
|
|
17
|
+
hash = Math.imul(hash, 16777619);
|
|
18
|
+
}
|
|
19
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isPlainObject(value) {
|
|
23
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sortJson(value) {
|
|
27
|
+
if (Array.isArray(value)) return value.map(sortJson);
|
|
28
|
+
if (!isPlainObject(value)) return value;
|
|
29
|
+
return Object.keys(value)
|
|
30
|
+
.sort()
|
|
31
|
+
.reduce((sorted, key) => {
|
|
32
|
+
sorted[key] = sortJson(value[key]);
|
|
33
|
+
return sorted;
|
|
34
|
+
}, {});
|
|
35
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_KEY_FIELDS,
|
|
3
|
+
cloneJson,
|
|
4
|
+
hashJson,
|
|
5
|
+
isPlainObject,
|
|
6
|
+
stableStringify
|
|
7
|
+
} from "./json.js";
|
|
8
|
+
|
|
9
|
+
const KEY_SEGMENT = "$key";
|
|
10
|
+
|
|
11
|
+
export function createJsonPatch(before, after, options = {}) {
|
|
12
|
+
const ops = [];
|
|
13
|
+
diffJson(before, after, [], ops, options);
|
|
14
|
+
return {
|
|
15
|
+
kind: "json-keyed",
|
|
16
|
+
baseHash: hashJson(before),
|
|
17
|
+
beforeBytes: stableStringify(before).length,
|
|
18
|
+
afterBytes: stableStringify(after).length,
|
|
19
|
+
lossy: ops.some((op) => op.lossy === true),
|
|
20
|
+
ops
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function applyJsonPatch(value, patch, options = {}) {
|
|
25
|
+
if (!patch || patch.kind !== "json-keyed") {
|
|
26
|
+
throw new Error("Unsupported patch format.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let next = cloneJson(value);
|
|
30
|
+
for (const op of patch.ops) {
|
|
31
|
+
next = applyJsonOp(next, op, options);
|
|
32
|
+
}
|
|
33
|
+
return next;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function diffJson(before, after, path, ops, options) {
|
|
37
|
+
if (jsonEqual(before, after)) return;
|
|
38
|
+
|
|
39
|
+
if (isKeyedArray(before, options) && isKeyedArray(after, options)) {
|
|
40
|
+
diffKeyedArray(before, after, path, ops, options);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isPlainObject(before) && isPlainObject(after)) {
|
|
45
|
+
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
|
|
46
|
+
for (const key of [...keys].sort()) {
|
|
47
|
+
if (!(key in after)) {
|
|
48
|
+
ops.push({ op: "delete", path: [...path, key], oldValue: cloneJson(before[key]) });
|
|
49
|
+
} else if (!(key in before)) {
|
|
50
|
+
ops.push({ op: "set", path: [...path, key], value: cloneJson(after[key]) });
|
|
51
|
+
} else {
|
|
52
|
+
diffJson(before[key], after[key], [...path, key], ops, options);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
ops.push({
|
|
59
|
+
op: "replace",
|
|
60
|
+
path,
|
|
61
|
+
oldValue: cloneJson(before),
|
|
62
|
+
value: cloneJson(after),
|
|
63
|
+
lossy: Array.isArray(before) || Array.isArray(after),
|
|
64
|
+
lossyReason: Array.isArray(before) || Array.isArray(after) ? "unkeyed_array_replace" : undefined
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function diffKeyedArray(before, after, path, ops, options) {
|
|
69
|
+
const beforeByKey = mapByKey(before, options);
|
|
70
|
+
const afterByKey = mapByKey(after, options);
|
|
71
|
+
|
|
72
|
+
for (const [key, item] of beforeByKey) {
|
|
73
|
+
if (!afterByKey.has(key)) {
|
|
74
|
+
ops.push({
|
|
75
|
+
op: "removeItem",
|
|
76
|
+
path,
|
|
77
|
+
key,
|
|
78
|
+
oldValue: cloneJson(item),
|
|
79
|
+
beforeLength: before.length,
|
|
80
|
+
afterLength: after.length
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (let index = 0; index < after.length; index += 1) {
|
|
86
|
+
const item = after[index];
|
|
87
|
+
const key = getItemKey(item, options);
|
|
88
|
+
if (!beforeByKey.has(key)) {
|
|
89
|
+
ops.push({ op: "insertItem", path, key, index, value: cloneJson(item) });
|
|
90
|
+
} else {
|
|
91
|
+
diffJson(beforeByKey.get(key), item, [...path, { [KEY_SEGMENT]: key }], ops, options);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const beforeKeys = before.map((item) => getItemKey(item, options));
|
|
96
|
+
const afterKeys = after.map((item) => getItemKey(item, options));
|
|
97
|
+
if (!arrayEqual(beforeKeys, afterKeys)) {
|
|
98
|
+
ops.push({ op: "reorderItems", path, beforeKeys, keys: afterKeys });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function applyJsonOp(root, op, options) {
|
|
103
|
+
if (op.path.length === 0) {
|
|
104
|
+
if (op.op === "replace" || op.op === "set") return cloneJson(op.value);
|
|
105
|
+
if (op.op === "delete") return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parentPath = op.op === "insertItem" || op.op === "removeItem" || op.op === "reorderItems"
|
|
109
|
+
? op.path
|
|
110
|
+
: op.path.slice(0, -1);
|
|
111
|
+
const parent = resolvePath(root, parentPath, options);
|
|
112
|
+
|
|
113
|
+
switch (op.op) {
|
|
114
|
+
case "set":
|
|
115
|
+
case "replace": {
|
|
116
|
+
const key = op.path.at(-1);
|
|
117
|
+
if (options.strict && "oldValue" in op && !jsonEqual(getChild(parent, key, options), op.oldValue)) {
|
|
118
|
+
throw new Error("Patch oldValue does not match target.");
|
|
119
|
+
}
|
|
120
|
+
setChild(parent, key, cloneJson(op.value), options);
|
|
121
|
+
return root;
|
|
122
|
+
}
|
|
123
|
+
case "delete": {
|
|
124
|
+
const key = op.path.at(-1);
|
|
125
|
+
if (options.strict && "oldValue" in op && !jsonEqual(getChild(parent, key, options), op.oldValue)) {
|
|
126
|
+
throw new Error("Patch oldValue does not match target.");
|
|
127
|
+
}
|
|
128
|
+
deleteChild(parent, key);
|
|
129
|
+
return root;
|
|
130
|
+
}
|
|
131
|
+
case "insertItem": {
|
|
132
|
+
if (!Array.isArray(parent)) throw new Error("insertItem target is not an array.");
|
|
133
|
+
const existing = findIndexByKey(parent, op.key, options);
|
|
134
|
+
if (existing >= 0) parent.splice(existing, 1);
|
|
135
|
+
parent.splice(Math.min(op.index, parent.length), 0, cloneJson(op.value));
|
|
136
|
+
return root;
|
|
137
|
+
}
|
|
138
|
+
case "removeItem": {
|
|
139
|
+
if (!Array.isArray(parent)) throw new Error("removeItem target is not an array.");
|
|
140
|
+
const index = findIndexByKey(parent, op.key, options);
|
|
141
|
+
if (index < 0) {
|
|
142
|
+
if (options.strict) throw new Error("removeItem target key does not exist.");
|
|
143
|
+
return root;
|
|
144
|
+
}
|
|
145
|
+
if (options.strict && op.oldValue && !jsonEqual(parent[index], op.oldValue)) {
|
|
146
|
+
throw new Error("Patch oldValue does not match target.");
|
|
147
|
+
}
|
|
148
|
+
parent.splice(index, 1);
|
|
149
|
+
return root;
|
|
150
|
+
}
|
|
151
|
+
case "reorderItems": {
|
|
152
|
+
if (!Array.isArray(parent)) throw new Error("reorderItems target is not an array.");
|
|
153
|
+
const byKey = mapByKey(parent, options);
|
|
154
|
+
const ordered = [];
|
|
155
|
+
for (const key of op.keys) {
|
|
156
|
+
if (byKey.has(key)) ordered.push(byKey.get(key));
|
|
157
|
+
}
|
|
158
|
+
for (const item of parent) {
|
|
159
|
+
const key = getItemKey(item, options);
|
|
160
|
+
if (!op.keys.includes(key)) ordered.push(item);
|
|
161
|
+
}
|
|
162
|
+
parent.splice(0, parent.length, ...ordered);
|
|
163
|
+
return root;
|
|
164
|
+
}
|
|
165
|
+
default:
|
|
166
|
+
throw new Error(`Unsupported op: ${op.op}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function resolvePath(root, path, options) {
|
|
171
|
+
let current = root;
|
|
172
|
+
for (const segment of path) {
|
|
173
|
+
current = getChild(current, segment, options);
|
|
174
|
+
if (current === undefined) throw new Error("Patch path could not be resolved.");
|
|
175
|
+
}
|
|
176
|
+
return current;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getChild(parent, segment, options) {
|
|
180
|
+
if (isKeySegment(segment)) {
|
|
181
|
+
if (!Array.isArray(parent)) throw new Error("Key segment parent is not an array.");
|
|
182
|
+
return parent.find((item) => getItemKey(item, options) === segment[KEY_SEGMENT]);
|
|
183
|
+
}
|
|
184
|
+
return parent?.[segment];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function setChild(parent, segment, value, options) {
|
|
188
|
+
if (isKeySegment(segment)) {
|
|
189
|
+
if (!Array.isArray(parent)) throw new Error("Key segment parent is not an array.");
|
|
190
|
+
const index = findIndexByKey(parent, segment[KEY_SEGMENT], options);
|
|
191
|
+
if (index < 0) parent.push(value);
|
|
192
|
+
else parent[index] = value;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
parent[segment] = value;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function deleteChild(parent, segment) {
|
|
199
|
+
if (Array.isArray(parent) && typeof segment === "number") {
|
|
200
|
+
parent.splice(segment, 1);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
delete parent[segment];
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isKeyedArray(value, options) {
|
|
207
|
+
return (
|
|
208
|
+
Array.isArray(value) &&
|
|
209
|
+
value.every((item) => getItemKey(item, options) !== null)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function mapByKey(array, options) {
|
|
214
|
+
return new Map(array.map((item) => [getItemKey(item, options), item]));
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function getItemKey(item, options) {
|
|
218
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) return null;
|
|
219
|
+
if (typeof options.getKey === "function") return options.getKey(item);
|
|
220
|
+
for (const field of options.keyFields ?? DEFAULT_KEY_FIELDS) {
|
|
221
|
+
if (typeof item[field] === "string" || typeof item[field] === "number") {
|
|
222
|
+
return String(item[field]);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findIndexByKey(array, key, options) {
|
|
229
|
+
return array.findIndex((item) => getItemKey(item, options) === key);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isKeySegment(segment) {
|
|
233
|
+
return isPlainObject(segment) && typeof segment[KEY_SEGMENT] === "string";
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function jsonEqual(left, right) {
|
|
237
|
+
return stableStringify(left) === stableStringify(right);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function arrayEqual(left, right) {
|
|
241
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
242
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type JsonPathSegment = string | number | { $key: string };
|
|
2
|
+
|
|
3
|
+
export interface JsonPatch {
|
|
4
|
+
kind: "json-keyed";
|
|
5
|
+
baseHash: string;
|
|
6
|
+
beforeBytes: number;
|
|
7
|
+
afterBytes: number;
|
|
8
|
+
lossy: boolean;
|
|
9
|
+
ops: Array<Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SyncTransport {
|
|
13
|
+
sync(message: unknown): Promise<any>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function cloneJson<T>(value: T): T;
|
|
17
|
+
export function stableStringify(value: unknown): string;
|
|
18
|
+
export function hashJson(value: unknown): string;
|
|
19
|
+
export function createJsonPatch(before: unknown, after: unknown, options?: { keyFields?: string[] }): JsonPatch;
|
|
20
|
+
export function applyJsonPatch<T>(value: T, patch: JsonPatch, options?: { strict?: boolean; keyFields?: string[] }): T;
|
|
21
|
+
export function createLocalStoragePersister(key: string, storage?: Storage): {
|
|
22
|
+
load(): any;
|
|
23
|
+
save(state: unknown): void;
|
|
24
|
+
clear(): void;
|
|
25
|
+
};
|
|
26
|
+
export function createAutosaveClient(options: {
|
|
27
|
+
documentId: string;
|
|
28
|
+
sessionId: string;
|
|
29
|
+
initialValue?: unknown;
|
|
30
|
+
clientVersion?: number;
|
|
31
|
+
serverVersion?: number;
|
|
32
|
+
keyFields?: string[];
|
|
33
|
+
transport: SyncTransport;
|
|
34
|
+
persister?: { load(): any; save(state: unknown): void };
|
|
35
|
+
}): any;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function createLocalStoragePersister(key, storage = globalThis.localStorage) {
|
|
2
|
+
return {
|
|
3
|
+
load() {
|
|
4
|
+
const raw = storage?.getItem(key);
|
|
5
|
+
return raw ? JSON.parse(raw) : null;
|
|
6
|
+
},
|
|
7
|
+
save(state) {
|
|
8
|
+
storage?.setItem(key, JSON.stringify(state));
|
|
9
|
+
},
|
|
10
|
+
clear() {
|
|
11
|
+
storage?.removeItem(key);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
}
|
package/src/react.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function useDifferentialAutosave(options: {
|
|
2
|
+
documentId: string;
|
|
3
|
+
sessionId: string;
|
|
4
|
+
initialValue?: unknown;
|
|
5
|
+
transport: { sync(message: unknown): Promise<any> };
|
|
6
|
+
intervalMs?: number;
|
|
7
|
+
keyFields?: string[];
|
|
8
|
+
storageKey?: string;
|
|
9
|
+
}): {
|
|
10
|
+
value: any;
|
|
11
|
+
setValue(nextValue: unknown): void;
|
|
12
|
+
sync(meta?: Record<string, unknown>): Promise<any>;
|
|
13
|
+
status: "idle" | "syncing" | "error";
|
|
14
|
+
error: unknown;
|
|
15
|
+
client: any;
|
|
16
|
+
};
|
package/src/react.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createAutosaveClient, createLocalStoragePersister } from "./index.js";
|
|
3
|
+
|
|
4
|
+
export function useDifferentialAutosave(options) {
|
|
5
|
+
const {
|
|
6
|
+
documentId,
|
|
7
|
+
sessionId,
|
|
8
|
+
initialValue = null,
|
|
9
|
+
transport,
|
|
10
|
+
intervalMs = 2000,
|
|
11
|
+
keyFields,
|
|
12
|
+
storageKey = `driftsync:${documentId}:${sessionId}`
|
|
13
|
+
} = options;
|
|
14
|
+
|
|
15
|
+
const [, forceRender] = useState(0);
|
|
16
|
+
const clientRef = useRef(null);
|
|
17
|
+
|
|
18
|
+
if (!clientRef.current) {
|
|
19
|
+
clientRef.current = createAutosaveClient({
|
|
20
|
+
documentId,
|
|
21
|
+
sessionId,
|
|
22
|
+
initialValue,
|
|
23
|
+
transport,
|
|
24
|
+
keyFields,
|
|
25
|
+
persister: createLocalStoragePersister(storageKey)
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const client = clientRef.current;
|
|
30
|
+
|
|
31
|
+
const sync = useCallback(
|
|
32
|
+
async (meta) => {
|
|
33
|
+
const result = await client.sync(meta);
|
|
34
|
+
forceRender((value) => value + 1);
|
|
35
|
+
return result;
|
|
36
|
+
},
|
|
37
|
+
[client]
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const timer = setInterval(() => {
|
|
42
|
+
sync().catch(() => forceRender((value) => value + 1));
|
|
43
|
+
}, intervalMs);
|
|
44
|
+
|
|
45
|
+
const flush = () => {
|
|
46
|
+
sync().catch(() => {});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
globalThis.addEventListener?.("visibilitychange", flush);
|
|
50
|
+
globalThis.addEventListener?.("beforeunload", flush);
|
|
51
|
+
|
|
52
|
+
return () => {
|
|
53
|
+
clearInterval(timer);
|
|
54
|
+
globalThis.removeEventListener?.("visibilitychange", flush);
|
|
55
|
+
globalThis.removeEventListener?.("beforeunload", flush);
|
|
56
|
+
};
|
|
57
|
+
}, [intervalMs, sync]);
|
|
58
|
+
|
|
59
|
+
const snapshot = client.state;
|
|
60
|
+
|
|
61
|
+
return useMemo(() => ({
|
|
62
|
+
value: snapshot.value,
|
|
63
|
+
setValue(nextValue) {
|
|
64
|
+
client.setValue(nextValue);
|
|
65
|
+
forceRender((value) => value + 1);
|
|
66
|
+
},
|
|
67
|
+
sync,
|
|
68
|
+
status: snapshot.syncing ? "syncing" : snapshot.lastError ? "error" : "idle",
|
|
69
|
+
error: snapshot.lastError,
|
|
70
|
+
client
|
|
71
|
+
}), [client, sync, snapshot]);
|
|
72
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { applyJsonPatch, createJsonPatch } from "../core/patch.js";
|
|
2
|
+
import { cloneJson, hashJson, stableStringify } from "../core/json.js";
|
|
3
|
+
|
|
4
|
+
export function createMemoryAutosaveServer(options = {}) {
|
|
5
|
+
const documents = new Map();
|
|
6
|
+
const destructiveDeleteRatio = options.destructiveDeleteRatio ?? 0.8;
|
|
7
|
+
const keepRevisions = options.keepRevisions ?? true;
|
|
8
|
+
const keyFields = options.keyFields ?? ["key", "id"];
|
|
9
|
+
|
|
10
|
+
function getDocument(documentId) {
|
|
11
|
+
const document = documents.get(documentId);
|
|
12
|
+
if (!document) throw new Error(`Unknown document: ${documentId}`);
|
|
13
|
+
return document;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
createDocument({ documentId = cryptoRandomId(), value = null } = {}) {
|
|
18
|
+
const document = {
|
|
19
|
+
documentId,
|
|
20
|
+
value: cloneJson(value),
|
|
21
|
+
version: 0,
|
|
22
|
+
sessions: new Map(),
|
|
23
|
+
revisions: []
|
|
24
|
+
};
|
|
25
|
+
documents.set(documentId, document);
|
|
26
|
+
return { documentId, value: cloneJson(value), version: 0 };
|
|
27
|
+
},
|
|
28
|
+
openDocument({ documentId, sessionId }) {
|
|
29
|
+
const document = getDocument(documentId);
|
|
30
|
+
document.sessions.set(sessionId, {
|
|
31
|
+
value: cloneJson(document.value),
|
|
32
|
+
clientVersion: 0,
|
|
33
|
+
serverVersion: document.version
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
ok: true,
|
|
37
|
+
documentId,
|
|
38
|
+
sessionId,
|
|
39
|
+
value: cloneJson(document.value),
|
|
40
|
+
clientVersion: 0,
|
|
41
|
+
serverVersion: document.version
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
inspectDocument(documentId) {
|
|
45
|
+
const document = getDocument(documentId);
|
|
46
|
+
return {
|
|
47
|
+
documentId,
|
|
48
|
+
value: cloneJson(document.value),
|
|
49
|
+
version: document.version,
|
|
50
|
+
sessions: [...document.sessions.entries()].map(([sessionId, shadow]) => ({
|
|
51
|
+
sessionId,
|
|
52
|
+
value: cloneJson(shadow.value),
|
|
53
|
+
clientVersion: shadow.clientVersion,
|
|
54
|
+
serverVersion: shadow.serverVersion
|
|
55
|
+
})),
|
|
56
|
+
revisions: cloneJson(document.revisions)
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
sync(message) {
|
|
60
|
+
const document = getDocument(message.documentId);
|
|
61
|
+
const shadow = document.sessions.get(message.sessionId);
|
|
62
|
+
if (!shadow) {
|
|
63
|
+
return { ok: false, reason: "unknown_session" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (message.clientVersion !== shadow.clientVersion) {
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
reason: "client_version_mismatch",
|
|
70
|
+
clientVersion: shadow.clientVersion,
|
|
71
|
+
serverVersion: shadow.serverVersion
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (message.shadowHash !== hashJson(shadow.value) || message.patch.baseHash !== hashJson(shadow.value)) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: "shadow_mismatch",
|
|
79
|
+
value: cloneJson(document.value),
|
|
80
|
+
clientVersion: shadow.clientVersion,
|
|
81
|
+
serverVersion: document.version
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (isDestructivePatch(message.patch, destructiveDeleteRatio) && !message.meta?.confirmDestructive) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
reason: "destructive_patch_requires_confirmation",
|
|
89
|
+
clientVersion: shadow.clientVersion,
|
|
90
|
+
serverVersion: shadow.serverVersion
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let nextShadow;
|
|
95
|
+
let nextValue;
|
|
96
|
+
try {
|
|
97
|
+
nextShadow = applyJsonPatch(shadow.value, message.patch, { strict: true, keyFields });
|
|
98
|
+
nextValue = applyJsonPatch(document.value, message.patch, { keyFields });
|
|
99
|
+
} catch (error) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
reason: "patch_apply_failed",
|
|
103
|
+
detail: error.message,
|
|
104
|
+
clientVersion: shadow.clientVersion,
|
|
105
|
+
serverVersion: shadow.serverVersion
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const changedServer = stableStringify(nextValue) !== stableStringify(document.value);
|
|
110
|
+
if (keepRevisions && changedServer) {
|
|
111
|
+
document.revisions.push({
|
|
112
|
+
version: document.version,
|
|
113
|
+
sessionId: message.sessionId,
|
|
114
|
+
before: cloneJson(document.value),
|
|
115
|
+
after: cloneJson(nextValue),
|
|
116
|
+
patch: cloneJson(message.patch),
|
|
117
|
+
at: new Date().toISOString()
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
document.value = nextValue;
|
|
122
|
+
if (changedServer) document.version += 1;
|
|
123
|
+
shadow.value = nextShadow;
|
|
124
|
+
shadow.clientVersion += 1;
|
|
125
|
+
|
|
126
|
+
const serverPatch = createJsonPatch(shadow.value, document.value, { keyFields });
|
|
127
|
+
shadow.value = applyJsonPatch(shadow.value, serverPatch, { strict: true, keyFields });
|
|
128
|
+
shadow.serverVersion = document.version;
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
ok: true,
|
|
132
|
+
clientVersion: shadow.clientVersion,
|
|
133
|
+
serverVersion: shadow.serverVersion,
|
|
134
|
+
patch: serverPatch
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
documents
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function isDestructivePatch(patch, ratio) {
|
|
142
|
+
if (patch.beforeBytes === 0) return false;
|
|
143
|
+
const removedMostBytes = patch.afterBytes / patch.beforeBytes <= 1 - ratio;
|
|
144
|
+
const removedMostKeyedItems = patch.ops.some((op) => (
|
|
145
|
+
op.op === "removeItem" &&
|
|
146
|
+
op.beforeLength > 0 &&
|
|
147
|
+
op.afterLength / op.beforeLength <= 1 - ratio
|
|
148
|
+
));
|
|
149
|
+
const reorderedToEmpty = patch.ops.some((op) => (
|
|
150
|
+
op.op === "reorderItems" &&
|
|
151
|
+
Array.isArray(op.beforeKeys) &&
|
|
152
|
+
op.beforeKeys.length > 0 &&
|
|
153
|
+
Array.isArray(op.keys) &&
|
|
154
|
+
op.keys.length === 0
|
|
155
|
+
));
|
|
156
|
+
|
|
157
|
+
if (!removedMostBytes && !removedMostKeyedItems && !reorderedToEmpty) return false;
|
|
158
|
+
return patch.ops.some((op) => (
|
|
159
|
+
op.op === "delete" ||
|
|
160
|
+
op.op === "removeItem" ||
|
|
161
|
+
(op.op === "replace" && stableStringify(op.value).length < stableStringify(op.oldValue).length)
|
|
162
|
+
));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function cryptoRandomId() {
|
|
166
|
+
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
|
167
|
+
return `doc_${Math.random().toString(36).slice(2)}`;
|
|
168
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export function createFetchTransport(url, fetchImpl = globalThis.fetch) {
|
|
2
|
+
return {
|
|
3
|
+
async sync(message) {
|
|
4
|
+
const response = await fetchImpl(url, {
|
|
5
|
+
method: "POST",
|
|
6
|
+
headers: { "content-type": "application/json" },
|
|
7
|
+
body: JSON.stringify(message)
|
|
8
|
+
});
|
|
9
|
+
if (!response.ok) {
|
|
10
|
+
const body = await response.json().catch(() => ({}));
|
|
11
|
+
return body.ok === false ? body : { ok: false, reason: `http_${response.status}` };
|
|
12
|
+
}
|
|
13
|
+
return response.json();
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function createNodeSyncHandler(server) {
|
|
19
|
+
return async function handleSync(request, response) {
|
|
20
|
+
if (request.method !== "POST") {
|
|
21
|
+
response.writeHead(405, { "content-type": "application/json" });
|
|
22
|
+
response.end(JSON.stringify({ ok: false, reason: "method_not_allowed" }));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const chunks = [];
|
|
27
|
+
for await (const chunk of request) chunks.push(chunk);
|
|
28
|
+
const message = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
29
|
+
const result = server.sync(message);
|
|
30
|
+
|
|
31
|
+
response.writeHead(result.ok ? 200 : 409, { "content-type": "application/json" });
|
|
32
|
+
response.end(JSON.stringify(result));
|
|
33
|
+
};
|
|
34
|
+
}
|
package/src/server.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export function createMemoryAutosaveServer(options?: {
|
|
2
|
+
destructiveDeleteRatio?: number;
|
|
3
|
+
keepRevisions?: boolean;
|
|
4
|
+
keyFields?: string[];
|
|
5
|
+
}): any;
|
|
6
|
+
export function createFetchTransport(url: string, fetchImpl?: typeof fetch): {
|
|
7
|
+
sync(message: unknown): Promise<any>;
|
|
8
|
+
};
|
|
9
|
+
export function createNodeSyncHandler(server: any): (request: any, response: any) => Promise<void>;
|
package/src/server.js
ADDED