json-patch-to-crdt 0.5.0 → 1.0.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/README.md CHANGED
@@ -104,12 +104,45 @@ For array-heavy snapshots, `diffJsonPatch` and `crdtToJsonPatch` support:
104
104
 
105
105
  `lcsMaxCells` only applies to `arrayStrategy: "lcs"`. If the classic LCS matrix for the trimmed unmatched window would exceed the configured cap, the diff falls back to an atomic array `replace`.
106
106
 
107
- `lcsLinearMaxCells` is the matching opt-in guardrail for `arrayStrategy: "lcs-linear"`. It uses the same trimmed unmatched-window estimate, but caps worst-case runtime instead of matrix allocation. When the cap is exceeded, `lcs-linear` also falls back to an atomic array `replace`. If you do not set `lcsLinearMaxCells`, `lcs-linear` keeps its previous behavior and will continue to run without an automatic fallback.
107
+ `lcsLinearMaxCells` is the matching guardrail for `arrayStrategy: "lcs-linear"` and defaults to `250_000`. It uses the same trimmed unmatched-window estimate, but caps worst-case runtime instead of matrix allocation. When the cap is exceeded, `lcs-linear` also falls back to an atomic array `replace`. Set `lcsLinearMaxCells: Number.POSITIVE_INFINITY` only when you explicitly want the previous unbounded behavior and accept the CPU cost for large unmatched arrays.
108
108
 
109
109
  `diffJsonPatch` keeps the existing `add`/`remove`/`replace` output by default. Set
110
110
  `emitMoves` and/or `emitCopies` to opt into deterministic RFC 6902 `move`/`copy`
111
111
  rewrites.
112
112
 
113
+ ## Cancellation
114
+
115
+ Expensive high-level APIs accept an optional `signal` compatible with `AbortSignal`:
116
+ `diffJsonPatch`, `applyPatch`, `applyPatchInPlace`, `tryApplyPatch`, `tryApplyPatchInPlace`,
117
+ `mergeState`, `tryMergeState`, `deserializeState`, and `tryDeserializeState`.
118
+
119
+ Cancellation is checked at safe points between traversal steps and array-diff loop iterations.
120
+ Immutable APIs either return no result or leave the input state unchanged when cancelled. In-place
121
+ patch application with `atomic: true` keeps the same all-or-nothing behavior; with `atomic: false`,
122
+ operations already applied before cancellation remain applied. Non-throwing APIs return
123
+ `reason: "OPERATION_CANCELLED"` and throwing APIs throw their usual domain error wrappers where
124
+ applicable.
125
+
126
+ ## Performance Gates
127
+
128
+ CI runs `bun run test:perf-gate` as a fixed-size performance smoke test for
129
+ the hottest paths: array diffing, merge traversal, and sequential patch
130
+ application. These gates are intentionally smaller and more stable than the
131
+ microbenchmarks in `bench/`; they fail with the measured median, sample list,
132
+ threshold, and tuning variable so regressions are actionable.
133
+
134
+ Run the gate locally before changing hot-path code:
135
+
136
+ ```bash
137
+ bun run test:perf-gate
138
+ ```
139
+
140
+ The default budgets are deliberately generous for shared CI. If a runner needs
141
+ environment-specific tuning, set `PERF_GATE_ARRAY_DIFF_MS`,
142
+ `PERF_GATE_MERGE_TRAVERSAL_MS`, `PERF_GATE_SEQUENTIAL_APPLY_MS`, or
143
+ `PERF_GATE_RUNS`. Use the `bench:*` scripts for deeper investigation and only
144
+ raise a gate after confirming the slowdown is intentional.
145
+
113
146
  ## Serialize / Restore State
114
147
 
115
148
  ```ts
@@ -118,12 +151,18 @@ import {
118
151
  createState,
119
152
  deserializeState,
120
153
  serializeState,
154
+ validateSerializedState,
121
155
  toJson,
122
156
  } from "json-patch-to-crdt";
123
157
 
124
158
  const state = createState({ counter: 1 }, { actor: "A" });
125
159
  const saved = serializeState(state);
126
160
 
161
+ const validation = validateSerializedState(saved);
162
+ if (!validation.ok) {
163
+ throw new Error(`Invalid snapshot at ${validation.error.path}: ${validation.error.message}`);
164
+ }
165
+
127
166
  const restored = deserializeState(saved);
128
167
  const next = applyPatch(restored, [{ op: "replace", path: "/counter", value: 2 }]);
129
168
 
@@ -135,10 +174,40 @@ Persisted snapshot compatibility:
135
174
 
136
175
  - `serializeState(...)` emits a versioned envelope.
137
176
  - `deserializeState(...)` accepts both the current versioned format and legacy unversioned snapshots.
177
+ - `validateSerializedState(...)` and `validateSerializedDoc(...)` preflight serialized payloads and return typed failures without requiring callers to catch exceptions.
138
178
  - Future envelope versions are rejected until an explicit migration path is added.
139
179
 
140
180
  The same compatibility contract applies to the lower-level `serializeDoc(...)` and
141
- `deserializeDoc(...)` helpers exported from `json-patch-to-crdt/internals`.
181
+ `deserializeDoc(...)` helpers. Use the validation-only helpers at trust boundaries
182
+ when you need to reject malformed snapshots before restoring runtime state; they
183
+ walk the serialized payload and CRDT invariants without allocating the runtime
184
+ document maps returned by the deserializers. Use `deserializeState(...)` or
185
+ `deserializeDoc(...)` when you need the CRDT data structures.
186
+
187
+ ### Resource Budgets for Serialized Payloads
188
+
189
+ When accepting serialized CRDT payloads from network clients, pass
190
+ `resourceBudget` limits to `deserializeState(...)`, `deserializeDoc(...)`, or the
191
+ non-throwing `tryDeserialize*` variants. These limits reject hostile shallow
192
+ payloads before validation allocates large maps or walks excessive element sets.
193
+
194
+ ```ts
195
+ const result = tryDeserializeState(payload, {
196
+ resourceBudget: {
197
+ objectEntries: 10_000,
198
+ sequenceElements: 50_000,
199
+ serializedElements: 100_000,
200
+ visitedNodes: 100_000,
201
+ },
202
+ });
203
+ ```
204
+
205
+ Tune these caps to your product limits. For network-exposed services, start with
206
+ caps near the largest document you intend to accept and lower them where request
207
+ size, latency, or memory limits are tighter. `objectEntries` covers object keys
208
+ and tombstone maps, `sequenceElements` covers RGA sequence elements and JSON
209
+ arrays, `serializedElements` caps the combined serialized breadth, and
210
+ `visitedNodes` caps total decoded node/value traversal.
142
211
 
143
212
  ## Error Handling
144
213
 
@@ -158,6 +227,33 @@ try {
158
227
 
159
228
  If you prefer non-throwing results, use `tryApplyPatch(...)` / `tryMergeState(...)`.
160
229
 
230
+ ## Strict Parent Semantics
231
+
232
+ RFC 6902 requires the parent of an `add` target to already exist. For compatibility
233
+ with older CRDT intent flows, missing array parents can still be materialized for
234
+ `/path/0` and `/path/-` inserts when `strictParents` is explicitly disabled.
235
+
236
+ Use the named strict profile for RFC 6902 boundaries:
237
+
238
+ ```ts
239
+ import { applyPatch, createState, withStrictRfc6902Parents } from "json-patch-to-crdt";
240
+
241
+ const base = createState({}, { actor: "A" });
242
+ const head = applyPatch(base, [{ op: "add", path: "/items", value: [] }]);
243
+
244
+ applyPatch(head, [{ op: "add", path: "/items/0", value: "x" }], withStrictRfc6902Parents({ base }));
245
+ // throws PatchError: base array missing at /items
246
+ ```
247
+
248
+ The error is based on the explicit `base` snapshot used for CRDT array index
249
+ resolution. In this example, `base` predates `/items`; without `{ base }`, the
250
+ current `head` state would be used as the base and the insert would succeed.
251
+
252
+ Callers that intentionally depend on the legacy auto-create behavior should opt
253
+ in explicitly with `withLegacyMissingArrayParents(...)`. That compatibility
254
+ profile is deprecated because accepting missing parents can hide invalid upstream
255
+ patch generation.
256
+
161
257
  ## Version Vector Helpers
162
258
 
163
259
  `observedVersionVector(...)` lets you inspect the highest observed counter per
@@ -195,6 +291,7 @@ vector, it returns that vector unchanged.
195
291
 
196
292
  `createState`, `applyPatch`, `diffJsonPatch`, and `crdtToJsonPatch` accept a
197
293
  `jsonValidation` option for callers that may pass runtime values through `any`.
294
+ The default remains `"none"` for backward compatibility with earlier releases.
198
295
 
199
296
  - `"none"` keeps the current behavior with no extra runtime validation.
200
297
  - `"strict"` rejects values that are not valid JSON, including non-finite
@@ -219,13 +316,39 @@ console.log(toJson(state));
219
316
  // { keep: true, nested: {}, arr: [null] }
220
317
  ```
221
318
 
319
+ For new untrusted-input boundaries, prefer the safe convenience helpers instead
320
+ of relying on every call site to remember `jsonValidation`.
321
+
322
+ ```ts
323
+ import {
324
+ applySafePatch,
325
+ createNormalizedState,
326
+ createSafeState,
327
+ diffSafeJsonPatch,
328
+ } from "json-patch-to-crdt";
329
+
330
+ const strictState = createSafeState(inputFromApi, { actor: "A" });
331
+ const strictNext = applySafePatch(strictState, patchFromApi);
332
+ const strictDelta = diffSafeJsonPatch(previousSnapshot, nextSnapshot);
333
+
334
+ const normalizedState = createNormalizedState(inputFromApi, { actor: "A" });
335
+ ```
336
+
337
+ The `Safe` helpers use strict validation and reject invalid runtime values. The
338
+ `Normalized` helpers use normalization and coerce invalid runtime values into
339
+ JSON-safe output. Existing `createState`, `applyPatch`, and `diffJsonPatch`
340
+ calls keep their current compatibility defaults unless you opt into a validation
341
+ mode directly.
342
+
222
343
  ## API Overview
223
344
 
224
345
  Main exports most apps need:
225
346
 
226
347
  - `createState(initial, { actor })`
348
+ - `createSafeState(initial, { actor })` / `createNormalizedState(initial, { actor })`
227
349
  - `forkState(origin, actor)`
228
350
  - `applyPatch(state, patch, options?)`
351
+ - `applySafePatch(state, patch, options?)` / `applyNormalizedPatch(state, patch, options?)`
229
352
  - `tryApplyPatch(state, patch, options?)`
230
353
  - `mergeState(local, remote, { actor })`
231
354
  - `tryMergeState(local, remote, options?)`
@@ -236,7 +359,9 @@ Main exports most apps need:
236
359
  - `compactStateTombstones(state, { stable })`
237
360
  - `toJson(stateOrDoc)`
238
361
  - `diffJsonPatch(baseJson, nextJson, options?)`
362
+ - `diffSafeJsonPatch(baseJson, nextJson, options?)` / `diffNormalizedJsonPatch(baseJson, nextJson, options?)`
239
363
  - `serializeState(state)` / `deserializeState(payload)`
364
+ - `validateSerializedState(payload)` / `validateSerializedDoc(payload)`
240
365
  - `validateJsonPatch(baseJson, patch, options?)`
241
366
 
242
367
  Advanced/internal helpers are available from: