@rohal12/spindle 0.59.2 → 0.59.4

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.
@@ -21,10 +21,11 @@ import {
21
21
  locateObjects,
22
22
  mergeKeys,
23
23
  pathKey,
24
+ sharedPaths,
24
25
  underChange,
25
26
  type PathChange,
26
27
  } from './structural';
27
- import { getByPath } from './utils/object-path';
28
+ import { getByPath, setByPath } from './utils/object-path';
28
29
  import { asNamespace, hasOwn } from './utils/namespace';
29
30
 
30
31
  type NamespaceName = keyof VariableNamespaces;
@@ -76,7 +77,11 @@ export function readState(): StoryState {
76
77
  const cloneValue = <T>(value: T): T =>
77
78
  deepClone(value, { keepUnregistered: true });
78
79
 
79
- /** Copies values for one target, so what they share they share there too. */
80
+ /**
81
+ * Copies values for one target (the three variable namespaces, so an object
82
+ * shared between them is shared there too), and what the values share they
83
+ * share there too.
84
+ */
80
85
  type Cloner = <T>(value: T) => T;
81
86
 
82
87
  /**
@@ -87,7 +92,7 @@ type Cloner = <T>(value: T) => T;
87
92
  * one, not a copy: `$a.self = $a` refers to the root of `a` in the store.
88
93
  */
89
94
  function clonerFor(
90
- target: Record<string, unknown>,
95
+ target: object,
91
96
  paths?: ReadonlyMap<object, string[]>,
92
97
  ): Cloner {
93
98
  const seen = paths ? existingObjects(paths, target) : new Map();
@@ -96,10 +101,10 @@ function clonerFor(
96
101
 
97
102
  /** One cloner per target copy, made when first used. */
98
103
  const clonersFor = (
99
- paths?: (target: Record<string, unknown>) => ReadonlyMap<object, string[]>,
104
+ paths?: (target: object) => ReadonlyMap<object, string[]>,
100
105
  ) => {
101
106
  const made = new Map<object, Cloner>();
102
- return (target: Record<string, unknown>): Cloner => {
107
+ return (target: object): Cloner => {
103
108
  let cloner = made.get(target);
104
109
  if (!cloner) {
105
110
  cloner = clonerFor(target, paths?.(target));
@@ -129,14 +134,13 @@ export function frozenCopy<T>(value: T): T {
129
134
  */
130
135
  function mirror(
131
136
  draft: VariableNamespaces,
132
- ns: NamespaceName,
133
137
  change: PathChange,
134
138
  scopes: readonly MutationScope[] = activeScopes,
135
- cloner: (target: Record<string, unknown>) => Cloner = clonerOf,
139
+ cloner: (target: object) => Cloner = clonerOf,
136
140
  ): void {
137
- const root = change.path[0]!;
141
+ const [ns, root] = change.path as [NamespaceName, string];
138
142
  for (const scope of scopes) {
139
- for (const copy of [scope.work[ns], scope.base[ns]]) {
143
+ for (const copy of [scope.work, scope.base]) {
140
144
  try {
141
145
  applyChange(
142
146
  copy,
@@ -147,11 +151,11 @@ function mirror(
147
151
  } catch {
148
152
  const stored = draft[ns][root];
149
153
  if (hasOwn(draft[ns], root)) {
150
- copy[root] = cloneValue(
154
+ copy[ns][root] = cloneValue(
151
155
  isDraft(stored) ? currentDraft(stored) : stored,
152
156
  );
153
157
  } else {
154
- delete copy[root];
158
+ delete copy[ns][root];
155
159
  }
156
160
  }
157
161
  }
@@ -159,7 +163,7 @@ function mirror(
159
163
  }
160
164
 
161
165
  /** A cloner of a copy of its own (no references shared with others). */
162
- const clonerOf = (target: Record<string, unknown>): Cloner => clonerFor(target);
166
+ const clonerOf = (target: object): Cloner => clonerFor(target);
163
167
 
164
168
  const NAMESPACE_KEYS: ReadonlySet<string> = new Set(NAMESPACES);
165
169
 
@@ -279,27 +283,23 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
279
283
  view,
280
284
  recipe as (draft: Draft<S>) => void,
281
285
  );
282
- const changes = NAMESPACES.map((ns) => {
286
+ // Paths below the namespaces, so one cloner and one application serve all
287
+ const changes = NAMESPACES.flatMap((ns) => {
283
288
  const own = patches.filter((p) => p.path[0] === ns);
284
- return [
285
- ns,
286
- own.length ? writtenPaths(view[ns], next[ns], own) : [],
287
- ] as const;
289
+ return own.length
290
+ ? writtenPaths(view[ns], next[ns], own).map(
291
+ (change): PathChange => ({ ...change, path: [ns, ...change.path] }),
292
+ )
293
+ : [];
288
294
  });
289
295
  // Each target takes its own copies, which share what the written values do
290
296
  const cloners = clonersFor();
291
- const owned = (
292
- change: PathChange,
293
- target: Record<string, unknown>,
294
- ): PathChange =>
297
+ const owned = (change: PathChange, target: object): PathChange =>
295
298
  change.deleted
296
299
  ? change
297
300
  : { ...change, value: cloners(target)(change.value) };
298
- for (const [ns, list] of changes) {
299
- for (const change of list) {
300
- applyChange(inner.work[ns], owned(change, inner.work[ns]));
301
- }
302
- }
301
+ for (const change of changes)
302
+ applyChange(inner.work, owned(change, inner.work));
303
303
 
304
304
  return (draft) => {
305
305
  const target = draft as unknown as Record<string, unknown>;
@@ -313,29 +313,62 @@ export function routeStoreUpdate<S extends VariableNamespaces>(
313
313
  }
314
314
  }
315
315
  const namespaces = draft as unknown as VariableNamespaces;
316
- for (const [ns, list] of changes) {
317
- for (const change of list) {
318
- const root = change.path[0]!;
319
- try {
320
- applyChange(namespaces[ns], owned(change, namespaces[ns]));
321
- } catch {
322
- if (hasOwn(inner.work[ns], root)) {
323
- namespaces[ns][root] = cloneValue(inner.work[ns][root]);
324
- } else {
325
- delete namespaces[ns][root];
326
- }
316
+ for (const change of changes) {
317
+ const [ns, root] = change.path as [NamespaceName, string];
318
+ try {
319
+ applyChange(namespaces, owned(change, namespaces));
320
+ } catch {
321
+ if (hasOwn(inner.work[ns], root)) {
322
+ namespaces[ns][root] = cloneValue(inner.work[ns][root]);
323
+ } else {
324
+ delete namespaces[ns][root];
327
325
  }
328
- mirror(namespaces, ns, change, activeScopes, cloners);
329
326
  }
327
+ mirror(namespaces, change, activeScopes, cloners);
330
328
  }
331
329
  };
332
330
  }
333
331
 
334
- const cloneNamespaces = (from: VariableNamespaces): VariableNamespaces => ({
335
- variables: deepClone(from.variables),
336
- temporary: deepClone(from.temporary),
337
- transient: deepClone(from.transient),
338
- });
332
+ /**
333
+ * Make the objects `work` holds at several paths one object in `draft`
334
+ * again where `changes` wrote below one of them: a write through one path
335
+ * of a draft gives that path a new object, and the other paths would go on
336
+ * referring to the old one (`$a.n = 2` with `$b` the same object as `$a`).
337
+ */
338
+ function relink(
339
+ draft: VariableNamespaces,
340
+ work: VariableNamespaces,
341
+ changes: readonly PathChange[],
342
+ ): void {
343
+ const written = changes.map((c) => pathKey(c.path).slice(0, -1));
344
+ for (const [first, ...others] of sharedPaths(work)) {
345
+ // Written at or below one of the paths: the written key follows its
346
+ // JSON, which opens with the path's own
347
+ const prefixes = [first!, ...others].map((p) => pathKey(p).slice(0, -1));
348
+ if (!written.some((w) => prefixes.some((p) => w.startsWith(p)))) continue;
349
+ try {
350
+ const object = getByPath(draft, first!);
351
+ if (object === null || typeof object !== 'object') continue;
352
+ for (const path of others)
353
+ setByPath(draft as unknown as Record<string, unknown>, path, object);
354
+ } catch {
355
+ // A path the store does not hold: the code's view is not the store's
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Copies of the namespaces that keep their references to each other: an
362
+ * object held in two of them (`%copy = $a`) is one object in the copies too.
363
+ */
364
+ function cloneNamespaces(from: VariableNamespaces): VariableNamespaces {
365
+ const seen = new Map<object, object>();
366
+ return {
367
+ variables: deepClone(from.variables, { seen }),
368
+ temporary: deepClone(from.temporary, { seen }),
369
+ transient: deepClone(from.transient, { seen }),
370
+ };
371
+ }
339
372
 
340
373
  /** A mutation to commit, and whether its code goes on running after. */
341
374
  interface Commit {
@@ -362,13 +395,9 @@ function commitScopes(commits: readonly Commit[]): void {
362
395
  const all = commits.map(({ scope, keepRunning }) => ({
363
396
  scope,
364
397
  keepRunning,
365
- changes: NAMESPACES.map(
366
- (ns) => [ns, changesOf(scope.base[ns], scope.work[ns])] as const,
367
- ),
398
+ changes: changesOf(scope.base, scope.work),
368
399
  }));
369
- const changed = all.filter(({ changes }) =>
370
- changes.some(([, list]) => list.length > 0),
371
- );
400
+ const changed = all.filter(({ changes }) => changes.length > 0);
372
401
  if (changed.length === 0) return;
373
402
  // Before the store update: watchers it fires may commit again (a goto)
374
403
  for (const { scope, keepRunning } of changed) {
@@ -390,73 +419,72 @@ function commitScopes(commits: readonly Commit[]): void {
390
419
  all.forEach(({ scope, changes }, i) => {
391
420
  // The copies the store and the enclosing mutations take are their own,
392
421
  // and keep the references of the values written (one object written
393
- // at two paths is one object there) and to the objects they hold
394
- // already (see clonerFor). The code's working copy stays its own.
395
- const paths = new Map(
396
- changes.map(([ns, list]) => [ns, locateObjects(scope.work[ns], list)]),
397
- );
398
- const own = new Map(
399
- changes.map(([ns]) => [ns, clonerFor(draft[ns], paths.get(ns))]),
400
- );
422
+ // at two paths, in one namespace or two, is one object there) and to
423
+ // the objects they hold already (see clonerFor). The code's working
424
+ // copy stays its own.
425
+ const paths = locateObjects(scope.work, changes);
426
+ const own = clonerFor(draft, paths);
401
427
  const enclosing = all.slice(0, i).map((c) => c.scope);
402
- const enclosingCloners = new Map(
403
- changes.map(([ns]) => [ns, clonersFor(() => paths.get(ns)!)]),
404
- );
405
- for (const [ns, list] of changes) {
406
- const replaced = new Set<string>();
407
- for (const change of list) {
408
- const root = change.path[0]!;
409
- if (replaced.has(root)) continue;
410
- try {
411
- if (change.deleted) {
412
- if (!isApplied(draft[ns], change)) applyChange(draft[ns], change);
413
- continue;
414
- }
415
- // A changed path already holding the value keeps its reference;
416
- // an alias change is one of reference only: it holds when the
417
- // very object is there.
418
- const value = change.alias ? own.get(ns)!(change.value) : undefined;
419
- if (
420
- change.alias
421
- ? Object.is(getByPath(draft[ns], change.path), value)
422
- : isApplied(draft[ns], change)
423
- ) {
424
- continue;
425
- }
426
- applyChange(draft[ns], {
427
- ...change,
428
- value: change.alias ? value : own.get(ns)!(change.value),
429
- });
430
- } catch {
431
- // An intermediate object the code wrote into is gone from the
432
- // store: the code's view of the whole root wins.
433
- draft[ns][root] = own.get(ns)!(scope.work[ns][root]);
434
- replaced.add(root);
428
+ const enclosingCloners = clonersFor(() => paths);
429
+ const replaced = new Set<string>();
430
+ for (const change of changes) {
431
+ const [ns, root] = change.path as [NamespaceName, string];
432
+ const rootKey = pathKey([ns, root]);
433
+ if (replaced.has(rootKey)) continue;
434
+ try {
435
+ if (change.deleted) {
436
+ if (!isApplied(draft, change)) applyChange(draft, change);
437
+ continue;
435
438
  }
439
+ // A changed path already holding the value keeps its reference;
440
+ // an alias change is one of reference only: it holds when the
441
+ // very object is there.
442
+ const value = change.alias ? own(change.value) : undefined;
443
+ if (
444
+ change.alias
445
+ ? Object.is(getByPath(draft, change.path), value)
446
+ : isApplied(draft, change)
447
+ ) {
448
+ continue;
449
+ }
450
+ applyChange(draft, {
451
+ ...change,
452
+ value: change.alias ? value : own(change.value),
453
+ });
454
+ } catch {
455
+ // An intermediate object the code wrote into is gone from the
456
+ // store: the code's view of the whole root wins.
457
+ draft[ns][root] = own(scope.work[ns][root]);
458
+ replaced.add(rootKey);
436
459
  }
437
- // Hand the changes to the mutations this one runs inside, before
438
- // watchers fired by this update run.
439
- for (const change of list) {
440
- mirror(draft, ns, change, enclosing, enclosingCloners.get(ns)!);
441
- }
460
+ }
461
+ relink(draft, scope.work, changes);
462
+ // Hand the changes to the mutations this one runs inside, before
463
+ // watchers fired by this update run.
464
+ for (const change of changes) {
465
+ mirror(draft, change, enclosing, enclosingCloners);
442
466
  }
443
467
  });
444
468
  }
445
469
  }
446
470
 
447
471
  /**
448
- * The changes of the code to a namespace: its property paths that differ
449
- * (see diffPaths), and those that hold equal content but another object
450
- * than they did (see aliasChanges), those first.
472
+ * The changes of the code to the namespaces, as paths below them (`['variables',
473
+ * 'a']`): the property paths that differ (see diffPaths), and those that hold
474
+ * equal content but another object than they did (see aliasChanges), those
475
+ * first. Compared as one object, so an object the namespaces share is one
476
+ * object, whichever of them it is reached through.
451
477
  */
452
478
  function changesOf(
453
- base: Record<string, unknown>,
454
- work: Record<string, unknown>,
479
+ base: VariableNamespaces,
480
+ work: VariableNamespaces,
455
481
  ): PathChange[] {
456
- const written = diffPaths(base, work);
482
+ const before = base as unknown as Record<string, unknown>;
483
+ const after = work as unknown as Record<string, unknown>;
484
+ const written = diffPaths(before, after);
457
485
  const keys = new Set(written.map((c) => pathKey(c.path)));
458
486
  const aliases: PathChange[] = [];
459
- for (const change of aliasChanges(base, work)) {
487
+ for (const change of aliasChanges(before, after)) {
460
488
  // Written whole by a change at it or above it
461
489
  if (underChange(change.path, keys)) continue;
462
490
  keys.add(pathKey(change.path));
@@ -503,11 +531,27 @@ export function runWithCommittedMutations<T>(action: () => T): T {
503
531
  }
504
532
  }
505
533
 
506
- export function executeMutation(
507
- code: string,
508
- mergedLocals: Record<string, unknown>,
509
- scopeUpdate: (key: string, value: unknown) => void,
510
- ): void {
534
+ /**
535
+ * Run `run` on working copies of the story state and commit what it changed
536
+ * to the store, as mutation code does (see commitScopes): the references
537
+ * between values are kept, which a store update made on a draft does not do.
538
+ * Nothing is committed when `run` throws.
539
+ */
540
+ export function mutateState(run: (work: VariableNamespaces) => void): void {
541
+ // The writes of the code this runs inside come first, and stay even if
542
+ // `run` throws
543
+ commitScopes(running());
544
+ const scope = startScope();
545
+ try {
546
+ run(scope.work);
547
+ } finally {
548
+ activeScopes.pop();
549
+ }
550
+ commitScopes([...running(), { scope, keepRunning: false }]);
551
+ }
552
+
553
+ /** Start a mutation, from the pending state of the one it runs inside. */
554
+ function startScope(): MutationScope {
511
555
  // A mutation started while another executes (a watcher run action fired
512
556
  // by a Story.set in its code) continues from the enclosing code's pending
513
557
  // state rather than the store, as a direct call at that point would.
@@ -516,6 +560,15 @@ export function executeMutation(
516
560
  work: cloneNamespaces(start),
517
561
  base: cloneNamespaces(start),
518
562
  };
563
+ activeScopes.push(scope);
564
+ return scope;
565
+ }
566
+
567
+ export function executeMutation(
568
+ code: string,
569
+ mergedLocals: Record<string, unknown>,
570
+ scopeUpdate: (key: string, value: unknown) => void,
571
+ ): void {
519
572
  // Locals are deep-cloned like the store namespaces: a loop item or widget
520
573
  // argument taken from story state is Immer-frozen, and an unfrozen object
521
574
  // mutated in place would keep its reference, so a nested assignment
@@ -525,7 +578,7 @@ export function executeMutation(
525
578
  deepClone(mergedLocals, { keepUnregistered: true }),
526
579
  );
527
580
 
528
- activeScopes.push(scope);
581
+ const scope = startScope();
529
582
  try {
530
583
  execute(
531
584
  code,
@@ -15,7 +15,7 @@ import {
15
15
  IncompatibleSaveError,
16
16
  SAVE_FORMAT_VERSION,
17
17
  } from './format';
18
- import { getBackend, resetBackend } from './storage';
18
+ import { getBackend, resetBackend, META_PREFIXES } from './storage';
19
19
  import { deepClone } from '../structural';
20
20
  import { emit } from '../event-emitter';
21
21
  import { withoutDraws } from '../prng';
@@ -152,7 +152,7 @@ async function startNewPlaythroughNow(
152
152
  const PLAYTHROUGH_LABEL = /^Playthrough (\d+)$/;
153
153
 
154
154
  function playthroughCountKey(ifid: string): string {
155
- return `playthroughCount.${ifid}`;
155
+ return `${META_PREFIXES.playthroughCount}${ifid}`;
156
156
  }
157
157
 
158
158
  /**
@@ -173,7 +173,7 @@ async function nextPlaythroughNumber(ifid: string): Promise<number> {
173
173
  }
174
174
 
175
175
  function currentPlaythroughKey(ifid: string): string {
176
- return `currentPlaythroughId.${ifid}`;
176
+ return `${META_PREFIXES.currentPlaythrough}${ifid}`;
177
177
  }
178
178
 
179
179
  export function getCurrentPlaythroughId(
@@ -476,9 +476,9 @@ export const getSavesGrouped = queued(async function getSavesGroupedNow(
476
476
 
477
477
  // --- Quick Save / Slot Save ---
478
478
 
479
- const AUTOSAVE_KEY_PREFIX = 'autosave.';
480
- const SLOT_KEY_PREFIX = 'slot.';
481
- const SLOT_INDEX_KEY_PREFIX = 'slotIndex.';
479
+ const AUTOSAVE_KEY_PREFIX = META_PREFIXES.autosave;
480
+ const SLOT_KEY_PREFIX = META_PREFIXES.slot;
481
+ const SLOT_INDEX_KEY_PREFIX = META_PREFIXES.slotIndex;
482
482
 
483
483
  /**
484
484
  * Whether `slot` names a slot. The default (autosave) slot is addressed by
@@ -46,6 +46,28 @@ function makeTables(table: MakeTable): Tables {
46
46
  };
47
47
  }
48
48
 
49
+ /**
50
+ * The meta keys a story owns, in one place: save-manager builds them,
51
+ * deleteMetaByIfid() matches them. A story's key is its prefix followed by
52
+ * its IFID, except a named slot's pointer, `slot.<name>.<IFID>`.
53
+ */
54
+ export const META_PREFIXES = {
55
+ autosave: 'autosave.',
56
+ slot: 'slot.',
57
+ slotIndex: 'slotIndex.',
58
+ playthroughCount: 'playthroughCount.',
59
+ currentPlaythrough: 'currentPlaythroughId.',
60
+ } as const;
61
+
62
+ /** Whether the meta `key` belongs to the story `ifid` (and no other). */
63
+ function isMetaKeyOf(key: string, ifid: string): boolean {
64
+ const { slot, ...others } = META_PREFIXES;
65
+ return (
66
+ Object.values(others).some((prefix) => key === prefix + ifid) ||
67
+ (key.startsWith(slot) && key.endsWith(`.${ifid}`))
68
+ );
69
+ }
70
+
49
71
  function createBackend(
50
72
  type: StorageBackend['type'],
51
73
  { saves, playthroughs, meta }: Tables,
@@ -96,7 +118,8 @@ function createBackend(
96
118
  deleteMeta: (key) => meta.delete(key),
97
119
  deleteMetaByPrefix: (prefix) =>
98
120
  deleteMetaWhere((key) => key.startsWith(prefix)),
99
- deleteMetaByIfid: (ifid) => deleteMetaWhere((key) => key.includes(ifid)),
121
+ deleteMetaByIfid: (ifid) =>
122
+ deleteMetaWhere((key) => isMetaKeyOf(key, ifid)),
100
123
  getAllMetaKeys: () => meta.keys(),
101
124
 
102
125
  destroy,
package/src/story-api.ts CHANGED
@@ -21,7 +21,11 @@ import {
21
21
  } from './saves/save-manager';
22
22
  import { getBackendType } from './saves/storage';
23
23
  import { registerClass } from './class-registry';
24
- import { frozenCopy, getActiveMutationScope } from './execute-mutation';
24
+ import {
25
+ frozenCopy,
26
+ getActiveMutationScope,
27
+ mutateState,
28
+ } from './execute-mutation';
25
29
  import { getByPath, setByPath } from './utils/object-path';
26
30
  import { changedNames, checkVariableName, ownValue } from './utils/namespace';
27
31
  import { historyQueries } from './expression';
@@ -298,6 +302,16 @@ function createStoryAPI(): StoryAPI {
298
302
  // while mutation code runs ({do}, ctx.mutate, watcher run actions), it
299
303
  // follows the code's own pending writes (program order, #215): see
300
304
  // routeStoreUpdate.
305
+ if (entries.some(([k]) => k.includes('.'))) {
306
+ // A write below a variable goes through the commit mutation code
307
+ // uses (nested in the running code, if any: it continues from the
308
+ // code's pending writes, and they are committed first): an update of a draft would give the written path new
309
+ // objects and leave the other references to the old ones (#295).
310
+ mutateState((work) => {
311
+ for (const [k, v] of entries) setOne(work, k, v);
312
+ });
313
+ return;
314
+ }
301
315
  useStoryStore.getState().updateVariables((draft) => {
302
316
  for (const [k, v] of entries) setOne(draft, k, v);
303
317
  });
package/src/structural.ts CHANGED
@@ -593,26 +593,54 @@ export function underChange(
593
593
  }
594
594
 
595
595
  /**
596
- * Where each object (or array, or other value) of `root` first appears, in
597
- * depth-first order. Plain objects are entered; arrays and other values are
598
- * leaves. A path at or below one of `skip` is left out.
596
+ * Every path each object (or array, or other value) of `root` is at, the
597
+ * first appearance first, in depth-first order. Plain objects and arrays are
598
+ * entered, where first met only (an object held in an array is one object,
599
+ * however it was reached); other values are leaves. A path at or below one
600
+ * of `skip` is left out.
599
601
  */
600
- function firstPaths(
601
- root: Record<string, unknown>,
602
+ function objectPaths(
603
+ root: object,
602
604
  skip: ReadonlySet<string> = new Set(),
603
- ): Map<object, string[]> {
604
- const found = new Map<object, string[]>();
605
+ ): Map<object, string[][]> {
606
+ const all = new Map<object, string[][]>();
605
607
  (function walk(node: Record<string, unknown>, path: string[]): void {
606
608
  for (const key of Object.keys(node)) {
607
609
  const value = node[key];
608
- if (!isObjectValue(value) || found.has(value)) continue;
610
+ if (!isObjectValue(value)) continue;
609
611
  const at = [...path, key];
610
612
  if (skip.has(pathKey(at))) continue;
611
- found.set(value, at);
612
- if (isMergeable(value)) walk(value, at);
613
+ const known = all.get(value);
614
+ if (known) {
615
+ known.push(at);
616
+ continue;
617
+ }
618
+ all.set(value, [at]);
619
+ if (isMergeable(value) || Array.isArray(value)) {
620
+ walk(value as Record<string, unknown>, at);
621
+ }
613
622
  }
614
- })(root, []);
615
- return found;
623
+ })(root as Record<string, unknown>, []);
624
+ return all;
625
+ }
626
+
627
+ /** Where each object of `root` first appears (see objectPaths). */
628
+ function firstPaths(
629
+ root: object,
630
+ skip?: ReadonlySet<string>,
631
+ ): Map<object, string[]> {
632
+ return new Map(
633
+ [...objectPaths(root, skip)].map(([object, paths]) => [object, paths[0]!]),
634
+ );
635
+ }
636
+
637
+ /**
638
+ * The objects `root` holds at more than one path (an object two variables
639
+ * refer to, or one that refers to itself), with every path they are at,
640
+ * parents before children.
641
+ */
642
+ export function sharedPaths(root: object): string[][][] {
643
+ return [...objectPaths(root).values()].filter((paths) => paths.length > 1);
616
644
  }
617
645
 
618
646
  /**
@@ -650,12 +678,12 @@ export function aliasChanges(
650
678
  // Only paths that are first appearances are entered: elsewhere the
651
679
  // contents are those of the object met first.
652
680
  if (
653
- isMergeable(x) &&
654
- mergesWith(x, y, false) &&
681
+ (isMergeable(x) || Array.isArray(x)) &&
682
+ mergesWith(x, y, true) &&
655
683
  pathKey(isAt) === k &&
656
684
  pathKey(wasAt) === k
657
685
  ) {
658
- walk(x, y as Record<string, unknown>, at);
686
+ walk(x as Record<string, unknown>, y as Record<string, unknown>, at);
659
687
  }
660
688
  }
661
689
  })(before, after, []);
@@ -667,10 +695,13 @@ export function aliasChanges(
667
695
  * `changes` (which write something new there), for `existingObjects`.
668
696
  */
669
697
  export function locateObjects(
670
- source: Record<string, unknown>,
698
+ source: object,
671
699
  changes: readonly PathChange[],
672
700
  ): Map<object, string[]> {
673
- return firstPaths(source, new Set(changes.map((c) => pathKey(c.path))));
701
+ return firstPaths(
702
+ source as Record<string, unknown>,
703
+ new Set(changes.map((c) => pathKey(c.path))),
704
+ );
674
705
  }
675
706
 
676
707
  /**
@@ -682,7 +713,7 @@ export function locateObjects(
682
713
  */
683
714
  export function existingObjects(
684
715
  paths: ReadonlyMap<object, string[]>,
685
- target: Record<string, unknown>,
716
+ target: object,
686
717
  ): Map<object, object> {
687
718
  return new (class extends Map<object, object> {
688
719
  override has(obj: object): boolean {
@@ -703,10 +734,7 @@ export function existingObjects(
703
734
  }
704
735
 
705
736
  /** Whether `target` already holds what `change` would write. */
706
- export function isApplied(
707
- target: Record<string, unknown>,
708
- change: PathChange,
709
- ): boolean {
737
+ export function isApplied(target: object, change: PathChange): boolean {
710
738
  const parent = getByPath(target, change.path.slice(0, -1));
711
739
  if (parent === null || typeof parent !== 'object') return change.deleted;
712
740
  const key = change.path[change.path.length - 1]!;
@@ -717,12 +745,10 @@ export function isApplied(
717
745
  );
718
746
  }
719
747
 
720
- export function applyChange(
721
- target: Record<string, unknown>,
722
- change: PathChange,
723
- ): void {
724
- if (change.deleted) deleteByPath(target, change.path);
725
- else setByPath(target, change.path, change.value);
748
+ export function applyChange(target: object, change: PathChange): void {
749
+ const root = target as Record<string, unknown>;
750
+ if (change.deleted) deleteByPath(root, change.path);
751
+ else setByPath(root, change.path, change.value);
726
752
  }
727
753
 
728
754
  /**
@@ -9,10 +9,7 @@ import { atomicName } from './value-kinds';
9
9
  * reads as missing, as setByPath() treats it. Other inherited properties
10
10
  * (class getters, `size` of a Map) are read.
11
11
  */
12
- export function getByPath(
13
- obj: Record<string, unknown>,
14
- segments: readonly string[],
15
- ): unknown {
12
+ export function getByPath(obj: object, segments: readonly string[]): unknown {
16
13
  let current: unknown = obj;
17
14
  for (const seg of segments) {
18
15
  if (current == null || typeof current !== 'object') return undefined;
@@ -38,7 +35,8 @@ const builtinName = atomicName;
38
35
  */
39
36
  export function shallowCopy(value: object): Record<string, unknown> {
40
37
  const copy = Array.isArray(value)
41
- ? []
38
+ ? // Keep the length: trailing holes are not among the keys
39
+ new Array<unknown>(value.length)
42
40
  : (Object.create(Object.getPrototypeOf(value) as object | null) as object);
43
41
  // Define rather than assign (Object.assign), so that a "__proto__" key
44
42
  // stays a key instead of replacing the copy's prototype