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 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
+ }
@@ -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,4 @@
1
+ export { createAutosaveClient } from "./client.js";
2
+ export { applyJsonPatch, createJsonPatch } from "./core/patch.js";
3
+ export { cloneJson, hashJson, stableStringify } from "./core/json.js";
4
+ export { createLocalStoragePersister } from "./persistence.js";
@@ -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
+ }
@@ -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
@@ -0,0 +1,2 @@
1
+ export { createMemoryAutosaveServer } from "./server/memory.js";
2
+ export { createFetchTransport, createNodeSyncHandler } from "./server/transport.js";