@crewhaus/tool-data 0.7.0 → 0.8.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.
@@ -33,7 +33,8 @@
33
33
  * `&&`. Anything unsupported raises a parse error naming the offending
34
34
  * position, never a silent empty result.
35
35
  */
36
- import { isPlainObject } from "./json";
36
+ import { testPatternSync } from "@crewhaus/tool-schema";
37
+ import { getOwn, isPlainObject } from "./json";
37
38
  export class PathError extends Error {
38
39
  }
39
40
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$-]*/;
@@ -233,85 +234,160 @@ function parseLiteral(text, whole, at) {
233
234
  return n;
234
235
  throw new PathError(`cannot read ${JSON.stringify(text)} as a literal at position ${at} of ${JSON.stringify(whole)}`);
235
236
  }
236
- /** Run a parsed path over a value, returning matches in document order. */
237
- export function queryPath(root, steps, limit) {
238
- let nodes = [{ path: [], value: root }];
239
- for (const step of steps) {
240
- const next = [];
241
- for (const node of nodes)
242
- applyStep(step, node, next);
243
- nodes = next;
244
- // Bound intermediate sets too: `..*` over a deep tree can blow up.
245
- const ceiling = Math.max(limit, 1) * 20;
246
- if (nodes.length > ceiling)
247
- nodes = nodes.slice(0, ceiling);
237
+ /**
238
+ * The most nodes one query may visit, counting every node a step produces
239
+ * and every node a `..` walk passes. A single pass over the largest document
240
+ * this package accepts is well inside it; a path that walks the document
241
+ * again for each node it found (`$..*..x` over a deep document) is what it
242
+ * stops.
243
+ */
244
+ export const MAX_QUERY_VISITS = 5_000_000;
245
+ /**
246
+ * Run a parsed path over a value, returning matches in document order.
247
+ *
248
+ * Evaluated lazily, depth first: each node a step produces runs through the
249
+ * remaining steps before the next is produced, which gives exactly the
250
+ * step-by-step, document-order result, and lets the query stop as soon as it
251
+ * has one match more than `limit` (to know it truncated). 0.7.0 built each
252
+ * step's whole node list first and then cut it at 20 x limit — so `$..*`
253
+ * materialized every node with its path, and a cut in the middle of a path
254
+ * silently dropped real matches: `$[*].b` over thirty records with `b` only
255
+ * in the last answered zero matches, not truncated.
256
+ */
257
+ export function queryPath(root, steps, limit, maxVisits = MAX_QUERY_VISITS, regex) {
258
+ const found = [];
259
+ const want = Math.max(limit, 0) + 1;
260
+ const budget = { left: maxVisits };
261
+ const filters = { regex, undetermined: [] };
262
+ const start = { value: root, parent: null, seg: null };
263
+ let stopped = false;
264
+ if (steps.length === 0) {
265
+ found.push(start);
266
+ }
267
+ else {
268
+ // One iterator per step: iterators[i] yields step i's outputs for the
269
+ // node the level above handed it. An explicit stack, not recursion, so a
270
+ // long path cannot overflow it.
271
+ const iterators = [expand(steps[0], start, budget, filters)];
272
+ while (iterators.length > 0) {
273
+ const next = iterators[iterators.length - 1].next();
274
+ if (budget.left < 0) {
275
+ stopped = true;
276
+ break;
277
+ }
278
+ if (next.done === true) {
279
+ iterators.pop();
280
+ continue;
281
+ }
282
+ if (iterators.length === steps.length) {
283
+ found.push(next.value);
284
+ if (found.length >= want)
285
+ break;
286
+ continue;
287
+ }
288
+ iterators.push(expand(steps[iterators.length], next.value, budget, filters));
289
+ }
248
290
  }
249
- const truncated = nodes.length > limit;
291
+ const shown = found.slice(0, Math.max(limit, 0));
292
+ const open = filters.undetermined;
250
293
  return {
251
- matches: nodes.slice(0, limit).map((n) => ({ path: renderPath(n.path), value: n.value })),
252
- truncated,
294
+ matches: shown.map((n) => ({ path: renderPath(segmentsOf(n)), value: n.value })),
295
+ truncated: stopped || found.length > shown.length,
296
+ ...(stopped ? { stoppedAtVisits: maxVisits } : {}),
297
+ ...(open.length > 0
298
+ ? {
299
+ undetermined: {
300
+ count: open.length,
301
+ paths: open.slice(0, 50).map((n) => renderPath(segmentsOf(n))),
302
+ },
303
+ }
304
+ : {}),
253
305
  };
254
306
  }
255
- function applyStep(step, node, out) {
307
+ function segmentsOf(node) {
308
+ const out = [];
309
+ for (let n = node; n !== null && n.seg !== null; n = n.parent)
310
+ out.push(n.seg);
311
+ return out.reverse();
312
+ }
313
+ const child = (parent, seg, value) => ({ value, parent, seg });
314
+ /** The nodes one step produces from `node`, lazily, charging each to the budget. */
315
+ function* expand(step, node, budget, filters) {
256
316
  const v = node.value;
257
317
  switch (step.kind) {
258
318
  case "child": {
259
319
  if (isPlainObject(v) && Object.hasOwn(v, step.name)) {
260
- out.push({ path: [...node.path, step.name], value: v[step.name] });
320
+ budget.left -= 1;
321
+ yield child(node, step.name, v[step.name]);
261
322
  }
262
323
  return;
263
324
  }
264
325
  case "wildcard": {
265
326
  if (Array.isArray(v)) {
266
- v.forEach((el, i) => out.push({ path: [...node.path, i], value: el }));
327
+ for (let i = 0; i < v.length; i++) {
328
+ budget.left -= 1;
329
+ yield child(node, i, v[i]);
330
+ }
267
331
  }
268
332
  else if (isPlainObject(v)) {
269
- for (const k of Object.keys(v))
270
- out.push({ path: [...node.path, k], value: v[k] });
333
+ for (const k of Object.keys(v)) {
334
+ budget.left -= 1;
335
+ yield child(node, k, v[k]);
336
+ }
271
337
  }
272
338
  return;
273
339
  }
274
340
  case "descend": {
275
341
  const name = step.name;
276
- walk(node, (n) => {
342
+ for (const n of descendants(node, budget)) {
277
343
  if (name === null) {
278
- if (n.path.length !== node.path.length)
279
- out.push(n);
280
- return;
344
+ if (n !== node)
345
+ yield n;
281
346
  }
282
- if (isPlainObject(n.value) && Object.hasOwn(n.value, name)) {
283
- out.push({ path: [...n.path, name], value: n.value[name] });
347
+ else if (isPlainObject(n.value) && Object.hasOwn(n.value, name)) {
348
+ yield child(n, name, n.value[name]);
284
349
  }
285
- });
350
+ }
286
351
  return;
287
352
  }
288
353
  case "index": {
289
354
  if (!Array.isArray(v))
290
355
  return;
291
356
  const i = step.index < 0 ? v.length + step.index : step.index;
292
- if (i >= 0 && i < v.length)
293
- out.push({ path: [...node.path, i], value: v[i] });
357
+ if (i >= 0 && i < v.length) {
358
+ budget.left -= 1;
359
+ yield child(node, i, v[i]);
360
+ }
294
361
  return;
295
362
  }
296
363
  case "slice": {
297
364
  if (!Array.isArray(v))
298
365
  return;
299
366
  for (const i of sliceIndices(v.length, step.start, step.end, step.step)) {
300
- out.push({ path: [...node.path, i], value: v[i] });
367
+ budget.left -= 1;
368
+ yield child(node, i, v[i]);
301
369
  }
302
370
  return;
303
371
  }
304
372
  case "filter": {
373
+ const keep = (seg, value) => {
374
+ const verdict = matchesFilter(value, step, filters.regex);
375
+ if (verdict === "undetermined")
376
+ filters.undetermined.push(child(node, seg, value));
377
+ return verdict === true;
378
+ };
305
379
  if (Array.isArray(v)) {
306
- v.forEach((el, i) => {
307
- if (matchesFilter(el, step))
308
- out.push({ path: [...node.path, i], value: el });
309
- });
380
+ for (let i = 0; i < v.length; i++) {
381
+ budget.left -= 1;
382
+ if (keep(i, v[i]))
383
+ yield child(node, i, v[i]);
384
+ }
310
385
  }
311
386
  else if (isPlainObject(v)) {
312
387
  for (const k of Object.keys(v)) {
313
- if (matchesFilter(v[k], step))
314
- out.push({ path: [...node.path, k], value: v[k] });
388
+ budget.left -= 1;
389
+ if (keep(k, v[k]))
390
+ yield child(node, k, v[k]);
315
391
  }
316
392
  }
317
393
  return;
@@ -320,16 +396,41 @@ function applyStep(step, node, out) {
320
396
  return;
321
397
  }
322
398
  }
323
- /** Depth-first pre-order walk, used by recursive descent. */
324
- function walk(node, visit) {
325
- visit(node);
326
- const v = node.value;
327
- if (Array.isArray(v)) {
328
- v.forEach((el, i) => walk({ path: [...node.path, i], value: el }, visit));
329
- }
330
- else if (isPlainObject(v)) {
331
- for (const k of Object.keys(v))
332
- walk({ path: [...node.path, k], value: v[k] }, visit);
399
+ /**
400
+ * `node` and everything under it, depth-first pre-order, lazily. An explicit
401
+ * stack of child cursors, one per level, so a deep document costs one frame
402
+ * per level and never a recursion.
403
+ */
404
+ function* descendants(node, budget) {
405
+ budget.left -= 1;
406
+ yield node;
407
+ const cursorOf = (n) => Array.isArray(n.value)
408
+ ? { of: n, keys: null, at: 0 }
409
+ : isPlainObject(n.value)
410
+ ? { of: n, keys: Object.keys(n.value), at: 0 }
411
+ : null;
412
+ const stack = [];
413
+ const first = cursorOf(node);
414
+ if (first !== null)
415
+ stack.push(first);
416
+ while (stack.length > 0) {
417
+ const top = stack[stack.length - 1];
418
+ const container = top.of.value;
419
+ const size = top.keys === null ? container.length : top.keys.length;
420
+ if (top.at >= size) {
421
+ stack.pop();
422
+ continue;
423
+ }
424
+ const at = top.at;
425
+ top.at += 1;
426
+ const next = top.keys === null
427
+ ? child(top.of, at, container[at])
428
+ : child(top.of, top.keys[at], container[top.keys[at]]);
429
+ budget.left -= 1;
430
+ yield next;
431
+ const deeper = cursorOf(next);
432
+ if (deeper !== null)
433
+ stack.push(deeper);
333
434
  }
334
435
  }
335
436
  /** Python slice semantics, including negative bounds and a negative step. */
@@ -350,11 +451,19 @@ export function sliceIndices(length, start, end, step) {
350
451
  }
351
452
  return out;
352
453
  }
353
- function matchesFilter(value, step) {
454
+ /**
455
+ * Does `value` pass the filter? A `=~` pattern is never run here: its
456
+ * answer comes from `regex`, resolved in the worker by the tool, and one
457
+ * not answered yet reads as a miss for this pass (the tool runs the query
458
+ * again once it is answered). One that could not be answered is
459
+ * `"undetermined"`; one the screen refuses, or that does not compile, is a
460
+ * bad path, where 0.7.0 silently matched nothing.
461
+ */
462
+ function matchesFilter(value, step, regex) {
354
463
  let cur = value;
355
464
  for (const seg of step.field) {
356
465
  if (isPlainObject(cur))
357
- cur = cur[seg];
466
+ cur = getOwn(cur, seg);
358
467
  else if (Array.isArray(cur) && /^-?\d+$/.test(seg)) {
359
468
  const i = Number(seg);
360
469
  cur = cur[i < 0 ? cur.length + i : i];
@@ -372,12 +481,16 @@ function matchesFilter(value, step) {
372
481
  if (step.op === "=~") {
373
482
  if (typeof lit !== "string")
374
483
  return false;
375
- try {
376
- return new RegExp(lit).test(String(cur));
377
- }
378
- catch {
484
+ const subject = String(cur);
485
+ const answer = regex === undefined ? testPatternSync(lit, "", subject) : regex.lookup(lit, "", subject);
486
+ if (answer === undefined)
379
487
  return false;
488
+ if (typeof answer === "boolean")
489
+ return answer;
490
+ if ("refused" in answer) {
491
+ throw new PathError(`the filter's regex /${lit}/ was not run: ${answer.refused}`);
380
492
  }
493
+ return "undetermined";
381
494
  }
382
495
  if (typeof cur === "number" && typeof lit === "number") {
383
496
  return compare(cur < lit, cur > lit, step.op);
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A `z.record` that keeps a key named `__proto__`.
3
+ *
4
+ * zod rebuilds a record into a fresh object and skips `__proto__` while it
5
+ * does (so the rebuild cannot set the new object's prototype). That is the
6
+ * right call for zod, and the wrong one for a data tool: `{"__proto__": 1}`
7
+ * is valid JSON, `JSON.parse` keeps it as an own property, and CsvParse,
8
+ * FlattenObject and JsonParse hand it back as one. Passed on to TableQuery,
9
+ * CsvWrite or UnflattenObject, the field vanished before the tool ran, with
10
+ * no error: the documented FlattenObject -> UnflattenObject round trip lost
11
+ * it, and a `__proto__` column read by CsvParse was not written by CsvWrite.
12
+ *
13
+ * `ownRecord(values)` is a `ZodRecord` in every way a reader of the schema
14
+ * can see (the model is shown the same JSON Schema, and introspection finds
15
+ * a record), so only the parse differs: after zod's own checks, an own
16
+ * `__proto__` key is validated with the same value schema and put back as
17
+ * an own data property, at its place in the key order. Nothing is ever
18
+ * assigned through `obj.__proto__`, so no prototype changes.
19
+ */
20
+ import { z } from "zod";
21
+ /** `z.record(values)`, except that a key named `__proto__` is kept as data. */
22
+ export declare function ownRecord<Value extends z.ZodTypeAny>(values: Value): z.ZodRecord<z.ZodString, Value>;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * A `z.record` that keeps a key named `__proto__`.
3
+ *
4
+ * zod rebuilds a record into a fresh object and skips `__proto__` while it
5
+ * does (so the rebuild cannot set the new object's prototype). That is the
6
+ * right call for zod, and the wrong one for a data tool: `{"__proto__": 1}`
7
+ * is valid JSON, `JSON.parse` keeps it as an own property, and CsvParse,
8
+ * FlattenObject and JsonParse hand it back as one. Passed on to TableQuery,
9
+ * CsvWrite or UnflattenObject, the field vanished before the tool ran, with
10
+ * no error: the documented FlattenObject -> UnflattenObject round trip lost
11
+ * it, and a `__proto__` column read by CsvParse was not written by CsvWrite.
12
+ *
13
+ * `ownRecord(values)` is a `ZodRecord` in every way a reader of the schema
14
+ * can see (the model is shown the same JSON Schema, and introspection finds
15
+ * a record), so only the parse differs: after zod's own checks, an own
16
+ * `__proto__` key is validated with the same value schema and put back as
17
+ * an own data property, at its place in the key order. Nothing is ever
18
+ * assigned through `obj.__proto__`, so no prototype changes.
19
+ */
20
+ import { z } from "zod";
21
+ const PROTO_KEY = "__proto__";
22
+ function isRecordLike(value) {
23
+ return typeof value === "object" && value !== null && !Array.isArray(value);
24
+ }
25
+ class ZodOwnRecord extends z.ZodRecord {
26
+ _parse(input) {
27
+ const result = super._parse(input);
28
+ const raw = input.data;
29
+ if (!isRecordLike(raw) || !Object.hasOwn(raw, PROTO_KEY))
30
+ return result;
31
+ const restore = (parsed) => {
32
+ if (parsed.status === "aborted")
33
+ return parsed;
34
+ const ownValue = this._def.valueType.safeParse(raw[PROTO_KEY]);
35
+ if (!ownValue.success) {
36
+ const ctx = this._getOrReturnCtx(input);
37
+ for (const issue of ownValue.error.issues) {
38
+ z.addIssueToContext(ctx, { ...issue, path: [...input.path, PROTO_KEY, ...issue.path] });
39
+ }
40
+ return z.INVALID;
41
+ }
42
+ // Rebuild in the caller's key order, with `__proto__` as a plain own
43
+ // property where it stood.
44
+ const out = {};
45
+ for (const key of Object.keys(raw)) {
46
+ const value = key === PROTO_KEY ? ownValue.data : parsed.value[key];
47
+ Object.defineProperty(out, key, {
48
+ value,
49
+ enumerable: true,
50
+ writable: true,
51
+ configurable: true,
52
+ });
53
+ }
54
+ return { status: parsed.status, value: out };
55
+ };
56
+ return result instanceof Promise ? result.then(restore) : restore(result);
57
+ }
58
+ }
59
+ /** `z.record(values)`, except that a key named `__proto__` is kept as data. */
60
+ export function ownRecord(values) {
61
+ return new ZodOwnRecord({
62
+ keyType: z.string(),
63
+ valueType: values,
64
+ typeName: z.ZodFirstPartyTypeKind.ZodRecord,
65
+ });
66
+ }
@@ -51,16 +51,32 @@ export type PatchOp = {
51
51
  path: string;
52
52
  value: unknown;
53
53
  };
54
+ /**
55
+ * The most values a patch may add to a document, over all its operations:
56
+ * about as many as the largest document this package reads holds. Each
57
+ * `copy` of the root doubles the document, so twenty of them on a
58
+ * seven-character document made 13.6 million characters.
59
+ */
60
+ export declare const MAX_PATCH_ADDED_NODES = 2000000;
54
61
  /**
55
62
  * Apply an RFC 6902 patch. Returns the new document; the input is never
56
63
  * mutated. Throws `PatchError` (carrying the failing operation's index) if
57
- * any operation cannot be applied, leaving nothing half-done.
64
+ * any operation cannot be applied, leaving nothing half-done — including an
65
+ * operation that would take the values the patch adds (by add, replace or
66
+ * copy) past `maxAddedNodes`.
58
67
  */
59
- export declare function applyJsonPatch(doc: unknown, ops: ReadonlyArray<PatchOp>): unknown;
68
+ export declare function applyJsonPatch(doc: unknown, ops: ReadonlyArray<PatchOp>, maxAddedNodes?: number): unknown;
60
69
  /**
61
70
  * Apply an RFC 7386 merge patch: an object merges key by key, `null` deletes
62
71
  * a key, and any non-object patch replaces the target outright. Arrays are
63
72
  * replaced whole — that is the RFC's behaviour, not a shortcut here.
73
+ *
74
+ * The result is a fresh value that shares nothing with either input, and
75
+ * every node of it is built exactly once: a target key the patch leaves
76
+ * alone is cloned, and one it touches is merged from the ORIGINAL child.
77
+ * (0.7.0 deep-cloned the target and then recursed into the clone, cloning
78
+ * the rest of the tree again at every level — quadratic in depth.) Keys keep
79
+ * the target's order, with new keys after them in the patch's order.
64
80
  */
65
81
  export declare function applyMergePatch(target: unknown, patch: unknown): unknown;
66
82
  /**
package/dist/lib/patch.js CHANGED
@@ -7,7 +7,7 @@
7
7
  * leaves the caller's input untouched and reports which operation index
8
8
  * failed — which is the behaviour RFC 6902 §5 requires.
9
9
  */
10
- import { deepClone, deepEqual, isPlainObject } from "./json";
10
+ import { deepClone, deepEqual, isPlainObject, setOwn } from "./json";
11
11
  export class PatchError extends Error {
12
12
  opIndex;
13
13
  constructor(message, opIndex) {
@@ -109,7 +109,9 @@ function addAt(parent, token, value, pointer, i) {
109
109
  return;
110
110
  }
111
111
  if (isPlainObject(parent)) {
112
- parent[token] = value;
112
+ // An own member even for `__proto__`: assignment would call the setter,
113
+ // change nothing, and report success.
114
+ setOwn(parent, token, value);
113
115
  return;
114
116
  }
115
117
  throw new PatchError(`cannot add to a ${typeof parent} at "${pointer}"`, i);
@@ -135,13 +137,45 @@ function removeAt(parent, token, pointer, i) {
135
137
  }
136
138
  throw new PatchError(`cannot remove from a ${typeof parent} at "${pointer}"`, i);
137
139
  }
140
+ /**
141
+ * The most values a patch may add to a document, over all its operations:
142
+ * about as many as the largest document this package reads holds. Each
143
+ * `copy` of the root doubles the document, so twenty of them on a
144
+ * seven-character document made 13.6 million characters.
145
+ */
146
+ export const MAX_PATCH_ADDED_NODES = 2_000_000;
147
+ /** Charge the values in `value` to the patch's budget; throw once it is spent. */
148
+ function chargeNodes(value, budget, opIndex) {
149
+ const stack = [value];
150
+ while (stack.length > 0) {
151
+ const node = stack.pop();
152
+ budget.left -= 1;
153
+ if (budget.left < 0) {
154
+ throw new PatchError(`the patch would add more than ${MAX_PATCH_ADDED_NODES} values to the document`, opIndex);
155
+ }
156
+ if (Array.isArray(node))
157
+ for (const el of node)
158
+ stack.push(el);
159
+ else if (isPlainObject(node))
160
+ for (const k of Object.keys(node))
161
+ stack.push(node[k]);
162
+ }
163
+ }
138
164
  /**
139
165
  * Apply an RFC 6902 patch. Returns the new document; the input is never
140
166
  * mutated. Throws `PatchError` (carrying the failing operation's index) if
141
- * any operation cannot be applied, leaving nothing half-done.
167
+ * any operation cannot be applied, leaving nothing half-done — including an
168
+ * operation that would take the values the patch adds (by add, replace or
169
+ * copy) past `maxAddedNodes`.
142
170
  */
143
- export function applyJsonPatch(doc, ops) {
171
+ export function applyJsonPatch(doc, ops, maxAddedNodes = MAX_PATCH_ADDED_NODES) {
144
172
  let working = deepClone(doc);
173
+ const budget = { left: maxAddedNodes };
174
+ // Every value an operation adds is counted before it is cloned in.
175
+ const added = (value, i) => {
176
+ chargeNodes(value, budget, i);
177
+ return deepClone(value);
178
+ };
145
179
  ops.forEach((op, i) => {
146
180
  switch (op.op) {
147
181
  case "test": {
@@ -155,11 +189,11 @@ export function applyJsonPatch(doc, ops) {
155
189
  }
156
190
  case "add": {
157
191
  if (op.path === "") {
158
- working = deepClone(op.value);
192
+ working = added(op.value, i);
159
193
  return;
160
194
  }
161
195
  const { parent, token } = locateParent(working, op.path, i);
162
- addAt(parent, token, deepClone(op.value), op.path, i);
196
+ addAt(parent, token, added(op.value, i), op.path, i);
163
197
  return;
164
198
  }
165
199
  case "remove": {
@@ -171,7 +205,7 @@ export function applyJsonPatch(doc, ops) {
171
205
  }
172
206
  case "replace": {
173
207
  if (op.path === "") {
174
- working = deepClone(op.value);
208
+ working = added(op.value, i);
175
209
  return;
176
210
  }
177
211
  const existing = resolvePointer(working, op.path);
@@ -180,7 +214,7 @@ export function applyJsonPatch(doc, ops) {
180
214
  }
181
215
  const { parent, token } = locateParent(working, op.path, i);
182
216
  removeAt(parent, token, op.path, i);
183
- addAt(parent, token, deepClone(op.value), op.path, i);
217
+ addAt(parent, token, added(op.value, i), op.path, i);
184
218
  return;
185
219
  }
186
220
  case "move": {
@@ -210,11 +244,11 @@ export function applyJsonPatch(doc, ops) {
210
244
  if (!src.found)
211
245
  throw new PatchError(`cannot copy from "${op.from}": ${src.reason}`, i);
212
246
  if (op.path === "") {
213
- working = deepClone(src.value);
247
+ working = added(src.value, i);
214
248
  return;
215
249
  }
216
250
  const dst = locateParent(working, op.path, i);
217
- addAt(dst.parent, dst.token, deepClone(src.value), op.path, i);
251
+ addAt(dst.parent, dst.token, added(src.value, i), op.path, i);
218
252
  return;
219
253
  }
220
254
  default: {
@@ -229,21 +263,38 @@ export function applyJsonPatch(doc, ops) {
229
263
  * Apply an RFC 7386 merge patch: an object merges key by key, `null` deletes
230
264
  * a key, and any non-object patch replaces the target outright. Arrays are
231
265
  * replaced whole — that is the RFC's behaviour, not a shortcut here.
266
+ *
267
+ * The result is a fresh value that shares nothing with either input, and
268
+ * every node of it is built exactly once: a target key the patch leaves
269
+ * alone is cloned, and one it touches is merged from the ORIGINAL child.
270
+ * (0.7.0 deep-cloned the target and then recursed into the clone, cloning
271
+ * the rest of the tree again at every level — quadratic in depth.) Keys keep
272
+ * the target's order, with new keys after them in the patch's order.
232
273
  */
233
274
  export function applyMergePatch(target, patch) {
234
275
  if (!isPlainObject(patch))
235
276
  return deepClone(patch);
236
- const base = isPlainObject(target)
237
- ? deepClone(target)
238
- : {};
277
+ const out = {};
278
+ const source = isPlainObject(target) ? target : null;
279
+ if (source !== null) {
280
+ for (const key of Object.keys(source)) {
281
+ if (!Object.hasOwn(patch, key)) {
282
+ setOwn(out, key, deepClone(source[key]));
283
+ continue;
284
+ }
285
+ const value = patch[key];
286
+ if (value !== null)
287
+ setOwn(out, key, applyMergePatch(source[key], value));
288
+ }
289
+ }
239
290
  for (const key of Object.keys(patch)) {
291
+ if (source !== null && Object.hasOwn(source, key))
292
+ continue;
240
293
  const value = patch[key];
241
- if (value === null)
242
- delete base[key];
243
- else
244
- base[key] = applyMergePatch(base[key], value);
294
+ if (value !== null)
295
+ setOwn(out, key, applyMergePatch(undefined, value));
245
296
  }
246
- return base;
297
+ return out;
247
298
  }
248
299
  /**
249
300
  * Derive the RFC 7386 merge patch that turns `from` into `to`. Returns the
@@ -257,16 +308,16 @@ export function diffMergePatch(from, to) {
257
308
  const out = {};
258
309
  for (const key of Object.keys(to)) {
259
310
  if (!Object.hasOwn(from, key)) {
260
- out[key] = deepClone(to[key]);
311
+ setOwn(out, key, deepClone(to[key]));
261
312
  continue;
262
313
  }
263
314
  if (deepEqual(from[key], to[key]))
264
315
  continue;
265
- out[key] = diffMergePatch(from[key], to[key]);
316
+ setOwn(out, key, diffMergePatch(from[key], to[key]));
266
317
  }
267
318
  for (const key of Object.keys(from)) {
268
319
  if (!Object.hasOwn(to, key))
269
- out[key] = null;
320
+ setOwn(out, key, null);
270
321
  }
271
322
  return out;
272
323
  }