@rohal12/spindle 0.52.4 → 0.52.5

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.
@@ -0,0 +1,388 @@
1
+ // Structural operations on story values: copying, comparing, and finding
2
+ // and merging the differences between two versions of a value. Mutation
3
+ // commits, history and save hooks all go through these, so they agree on
4
+ // what a change is.
5
+
6
+ import { registeredClassName } from './class-registry';
7
+ import { hasOwn } from './utils/namespace';
8
+ import { deleteByPath, getByPath, setByPath } from './utils/object-path';
9
+
10
+ /**
11
+ * Set an own enumerable property. A "__proto__" key is defined as an own
12
+ * property (as JSON.parse does) instead of replacing the prototype.
13
+ */
14
+ export function setOwn(target: object, key: string, value: unknown): void {
15
+ if (key === '__proto__') {
16
+ Object.defineProperty(target, key, {
17
+ value,
18
+ enumerable: true,
19
+ writable: true,
20
+ configurable: true,
21
+ });
22
+ } else {
23
+ (target as Record<string, unknown>)[key] = value;
24
+ }
25
+ }
26
+
27
+ // --- Deep Clone ---
28
+
29
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
30
+ if (typeof value !== 'object' || value === null) return false;
31
+ const proto = Object.getPrototypeOf(value);
32
+ return proto === Object.prototype || proto === null;
33
+ }
34
+
35
+ export interface DeepCloneOptions {
36
+ /**
37
+ * Return instances of unregistered classes (DOM nodes, promises, other
38
+ * library objects) by reference instead of copying their own keys into a
39
+ * plain object, which would lose their prototype and identity.
40
+ */
41
+ keepUnregistered?: boolean;
42
+ }
43
+
44
+ export function deepClone<T>(value: T, options: DeepCloneOptions = {}): T {
45
+ const seen = new Map<object, object>();
46
+
47
+ function clone(val: unknown): unknown {
48
+ if (val === null || typeof val !== 'object') return val;
49
+
50
+ const obj = val as object;
51
+ if (seen.has(obj)) return seen.get(obj);
52
+
53
+ if (val instanceof Date) return new Date(val.getTime()) as unknown;
54
+ if (val instanceof RegExp)
55
+ return new RegExp(val.source, val.flags) as unknown;
56
+
57
+ if (Array.isArray(val)) {
58
+ const arr: unknown[] = [];
59
+ seen.set(obj, arr);
60
+ for (let i = 0; i < val.length; i++) {
61
+ arr[i] = clone(val[i]);
62
+ }
63
+ return arr;
64
+ }
65
+
66
+ if (val instanceof Map) {
67
+ const copy = new Map();
68
+ seen.set(obj, copy);
69
+ for (const [k, v] of val) {
70
+ copy.set(clone(k), clone(v));
71
+ }
72
+ return copy;
73
+ }
74
+
75
+ if (val instanceof Set) {
76
+ const copy = new Set();
77
+ seen.set(obj, copy);
78
+ for (const v of val) {
79
+ copy.add(clone(v));
80
+ }
81
+ return copy;
82
+ }
83
+
84
+ // A registered class instance keeps its class, and a plain object its
85
+ // prototype (which may be null). An instance of an unregistered class
86
+ // is treated as plain, unless it is kept by reference.
87
+ const keepsProto =
88
+ registeredClassName(obj) !== undefined || isPlainObject(val);
89
+ if (!keepsProto && options.keepUnregistered) return val;
90
+ const copy = (
91
+ keepsProto
92
+ ? Object.create(Object.getPrototypeOf(obj) as object | null)
93
+ : {}
94
+ ) as Record<string, unknown>;
95
+ seen.set(obj, copy);
96
+ for (const key of Object.keys(obj)) {
97
+ setOwn(copy, key, clone((obj as Record<string, unknown>)[key]));
98
+ }
99
+ return copy;
100
+ }
101
+
102
+ return clone(value) as T;
103
+ }
104
+
105
+ // --- Deep Equal ---
106
+
107
+ /**
108
+ * Structural equality over the value types deepClone() supports: primitives,
109
+ * arrays, plain objects, class instances, Date, RegExp, Map and Set (nested
110
+ * at any depth). Map and Set entries are compared in insertion order. Arrays
111
+ * are compared by length and index, so a hole equals an undefined element
112
+ * (deepClone() and save/load turn holes into undefined elements).
113
+ */
114
+ export function deepEqual(a: unknown, b: unknown): boolean {
115
+ return equal(a, b, new Map());
116
+ }
117
+
118
+ /**
119
+ * `assumed` holds the pairs already being compared: meeting one again (a
120
+ * cycle) assumes it equal. Every object may pair with several others, since
121
+ * cycles of different lengths can still unfold to the same value.
122
+ */
123
+ function equal(
124
+ a: unknown,
125
+ b: unknown,
126
+ assumed: Map<object, Set<object>>,
127
+ ): boolean {
128
+ if (Object.is(a, b)) return true;
129
+ if (
130
+ a === null ||
131
+ b === null ||
132
+ typeof a !== 'object' ||
133
+ typeof b !== 'object'
134
+ ) {
135
+ return false;
136
+ }
137
+ if (Object.getPrototypeOf(a) !== Object.getPrototypeOf(b)) return false;
138
+ const pairs = assumed.get(a);
139
+ if (pairs?.has(b)) return true;
140
+ if (pairs) pairs.add(b);
141
+ else assumed.set(a, new Set([b]));
142
+
143
+ if (a instanceof Date) return Object.is(a.getTime(), (b as Date).getTime());
144
+ if (a instanceof RegExp) return String(a) === String(b);
145
+ if (a instanceof Map || a instanceof Set) {
146
+ const bc = b as Map<unknown, unknown> | Set<unknown>;
147
+ if (a.size !== bc.size) return false;
148
+ const ai = a.entries();
149
+ const bi = bc.entries();
150
+ for (
151
+ let x = ai.next(), y = bi.next();
152
+ !x.done;
153
+ x = ai.next(), y = bi.next()
154
+ ) {
155
+ if (!equal(x.value, y.value, assumed)) return false;
156
+ }
157
+ return true;
158
+ }
159
+
160
+ if (Array.isArray(a)) {
161
+ const bc = b as unknown[];
162
+ if (a.length !== bc.length) return false;
163
+ for (let i = 0; i < a.length; i++) {
164
+ if (!equal(a[i], bc[i], assumed)) return false;
165
+ }
166
+ return true;
167
+ }
168
+
169
+ const ao = a as Record<string, unknown>;
170
+ const bo = b as Record<string, unknown>;
171
+ const keys = Object.keys(ao);
172
+ if (keys.length !== Object.keys(bo).length) return false;
173
+ for (const key of keys) {
174
+ if (!hasOwn(bo, key) || !equal(ao[key], bo[key], assumed)) return false;
175
+ }
176
+ return true;
177
+ }
178
+
179
+ // --- Structural diff ---
180
+
181
+ /**
182
+ * Objects merged property by property: plain objects and (registered)
183
+ * class instances. Arrays, Map, Set, Date and RegExp are values that are
184
+ * replaced as a whole, since their elements have no stable identity to
185
+ * merge by (a shift moves every index).
186
+ */
187
+ export function isMergeable(value: unknown): value is Record<string, unknown> {
188
+ return (
189
+ typeof value === 'object' &&
190
+ value !== null &&
191
+ !Array.isArray(value) &&
192
+ !(value instanceof Map) &&
193
+ !(value instanceof Set) &&
194
+ !(value instanceof Date) &&
195
+ !(value instanceof RegExp)
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Whether changes between `a` and `b` merge key by key: both are objects of
201
+ * one class (see isMergeable), or, with `arrays`, both are arrays.
202
+ */
203
+ export function mergesWith(
204
+ a: unknown,
205
+ b: unknown,
206
+ arrays: boolean,
207
+ ): a is Record<string, unknown> {
208
+ if (arrays && (Array.isArray(a) || Array.isArray(b))) {
209
+ return Array.isArray(a) && Array.isArray(b);
210
+ }
211
+ return (
212
+ isMergeable(a) &&
213
+ isMergeable(b) &&
214
+ Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
215
+ );
216
+ }
217
+
218
+ /** How a value differs from an earlier version at one key. */
219
+ export type KeyChange =
220
+ | { key: string; kind: 'added' | 'changed'; after: unknown }
221
+ | { key: string; kind: 'deleted' }
222
+ | {
223
+ key: string;
224
+ kind: 'nested';
225
+ before: Record<string, unknown>;
226
+ after: Record<string, unknown>;
227
+ };
228
+
229
+ /**
230
+ * The keys at which `after` differs from `before`, two values that merge
231
+ * (see mergesWith): keys added, deleted, or holding another value. Store
232
+ * updates are immutable, so a value that kept its identity is no change,
233
+ * and one rebuilt with equal content (mutation code commits whole values)
234
+ * is none either. Values that merge are reported as 'nested', to be
235
+ * compared key by key; they may still be equal. With `arrays`, arrays
236
+ * merge too: elements at the indices both hold are compared, and the
237
+ * elements one has beyond the other's length are added or deleted.
238
+ */
239
+ export function keyChanges(
240
+ before: Record<string, unknown>,
241
+ after: Record<string, unknown>,
242
+ arrays: boolean,
243
+ ): KeyChange[] {
244
+ const changes: KeyChange[] = [];
245
+ const compare = (key: string, b: unknown, a: unknown): void => {
246
+ if (Object.is(b, a)) return;
247
+ if (mergesWith(b, a, arrays)) {
248
+ changes.push({
249
+ key,
250
+ kind: 'nested',
251
+ before: b,
252
+ after: a as Record<string, unknown>,
253
+ });
254
+ } else if (!deepEqual(b, a)) {
255
+ changes.push({ key, kind: 'changed', after: a });
256
+ }
257
+ };
258
+
259
+ if (Array.isArray(after)) {
260
+ const b = before as unknown as unknown[];
261
+ const a = after as unknown[];
262
+ for (let i = 0; i < Math.min(b.length, a.length); i++) {
263
+ compare(String(i), b[i], a[i]);
264
+ }
265
+ for (let i = b.length; i < a.length; i++) {
266
+ changes.push({ key: String(i), kind: 'added', after: a[i] });
267
+ }
268
+ for (let i = a.length; i < b.length; i++) {
269
+ changes.push({ key: String(i), kind: 'deleted' });
270
+ }
271
+ return changes;
272
+ }
273
+
274
+ for (const key of Object.keys(after)) {
275
+ if (hasOwn(before, key)) compare(key, before[key], after[key]);
276
+ else changes.push({ key, kind: 'added', after: after[key] });
277
+ }
278
+ for (const key of Object.keys(before)) {
279
+ if (!hasOwn(after, key)) changes.push({ key, kind: 'deleted' });
280
+ }
281
+ return changes;
282
+ }
283
+
284
+ /** A write to one property path of an object, or its deletion. */
285
+ export type PathChange =
286
+ | { path: string[]; deleted: false; value: unknown }
287
+ | { path: string[]; deleted: true };
288
+
289
+ /**
290
+ * The property paths where `after` differs from `before`, two objects of
291
+ * one class: objects that merge (see isMergeable) are compared property by
292
+ * property, any other value as a whole.
293
+ */
294
+ export function diffPaths(
295
+ before: Record<string, unknown>,
296
+ after: Record<string, unknown>,
297
+ ): PathChange[] {
298
+ const changes: PathChange[] = [];
299
+ const ancestors = new Set<object>();
300
+ (function walk(
301
+ b: Record<string, unknown>,
302
+ a: Record<string, unknown>,
303
+ path: string[],
304
+ ): void {
305
+ // Stop at cycles; shared (non-cyclic) references are visited per path.
306
+ if (ancestors.has(a)) return;
307
+ ancestors.add(a);
308
+ for (const change of keyChanges(b, a, false)) {
309
+ const at = [...path, change.key];
310
+ if (change.kind === 'nested') {
311
+ walk(change.before, change.after, at);
312
+ } else {
313
+ changes.push(
314
+ change.kind === 'deleted'
315
+ ? { path: at, deleted: true }
316
+ : { path: at, deleted: false, value: change.after },
317
+ );
318
+ }
319
+ }
320
+ ancestors.delete(a);
321
+ })(before, after, []);
322
+ return changes;
323
+ }
324
+
325
+ /** Whether `target` already holds what `change` would write. */
326
+ export function isApplied(
327
+ target: Record<string, unknown>,
328
+ change: PathChange,
329
+ ): boolean {
330
+ const parent = getByPath(target, change.path.slice(0, -1));
331
+ if (parent === null || typeof parent !== 'object') return change.deleted;
332
+ const key = change.path[change.path.length - 1]!;
333
+ if (!hasOwn(parent, key)) return change.deleted;
334
+ return (
335
+ !change.deleted &&
336
+ deepEqual((parent as Record<string, unknown>)[key], change.value)
337
+ );
338
+ }
339
+
340
+ export function applyChange(
341
+ target: Record<string, unknown>,
342
+ change: PathChange,
343
+ ): void {
344
+ if (change.deleted) deleteByPath(target, change.path);
345
+ else setByPath(target, change.path, change.value);
346
+ }
347
+
348
+ /**
349
+ * Write how `after` differs from `before` (see keyChanges; arrays merge by
350
+ * index) into `target`, another version of the same object or array.
351
+ * Values that changed are written whole, as deep copies; for values that
352
+ * merge, `mergeNested` may merge them into `target` instead, returning
353
+ * whether it did. Into an array, elements removed from the end are removed
354
+ * at the same indices and elements added are appended, and changes at
355
+ * indices `target` lacks are dropped: where `target` was resized, its
356
+ * indices do not line up with `before`'s.
357
+ */
358
+ export function mergeKeys(
359
+ target: Record<string, unknown>,
360
+ before: Record<string, unknown>,
361
+ after: Record<string, unknown>,
362
+ mergeNested?: (
363
+ key: string,
364
+ before: Record<string, unknown>,
365
+ after: Record<string, unknown>,
366
+ ) => boolean,
367
+ ): void {
368
+ const list = Array.isArray(target) ? (target as unknown[]) : undefined;
369
+ for (const change of keyChanges(before, after, true)) {
370
+ const { key } = change;
371
+ if (change.kind === 'deleted') {
372
+ if (list) list.length = Math.min(list.length, Number(key));
373
+ else delete target[key];
374
+ } else if (list && change.kind === 'added') {
375
+ list.push(deepClone(change.after));
376
+ } else if (list && Number(key) >= list.length) {
377
+ continue;
378
+ } else if (
379
+ change.kind !== 'nested' ||
380
+ !(
381
+ mergeNested?.(key, change.before, change.after) ||
382
+ deepEqual(change.before, change.after)
383
+ )
384
+ ) {
385
+ target[key] = deepClone(change.after);
386
+ }
387
+ }
388
+ }