ts-data-forge 14.8.0 → 14.10.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
@@ -82,6 +82,7 @@ Essential FP utilities for cleaner, more reliable code.
82
82
 
83
83
  - **Optional** - Type-safe null handling
84
84
  - **Result** - Error handling without exceptions
85
+ - **AsyncData** - The state of data fetched asynchronously: idle, pending, or settled as a `Result`
85
86
  - **Pipe** - Function composition utilities
86
87
  - **match** - Pattern matching for TypeScript
87
88
 
@@ -7,6 +7,6 @@
7
7
  *
8
8
  * New code should import these from `ts-std-forge` directly.
9
9
  */
10
- export { AsyncResult, Optional, Result, TernaryResult, match, pipe, } from 'ts-std-forge';
11
- export type { Err, None, Ok, Pipe, Some, TernaryErr, TernaryOk, TernaryWarn, UnknownOptional, UnknownResult, UnknownTernaryResult, } from 'ts-std-forge';
10
+ export { AsyncData, AsyncResult, Optional, Result, TernaryResult, match, pipe, } from 'ts-std-forge';
11
+ export type { AsyncErr, AsyncIdle, AsyncPending, Err, None, Ok, Pipe, Some, TernaryErr, TernaryOk, TernaryWarn, UnknownAsyncData, UnknownOptional, UnknownResult, UnknownTernaryResult, } from 'ts-std-forge';
12
12
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/functional/index.mts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,WAAW,EACX,QAAQ,EACR,MAAM,EACN,aAAa,EACb,KAAK,EACL,IAAI,GACL,MAAM,cAAc,CAAC;AAEtB,YAAY,EACV,GAAG,EACH,IAAI,EACJ,EAAE,EACF,IAAI,EACJ,IAAI,EACJ,UAAU,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,aAAa,EACb,oBAAoB,GACrB,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/functional/index.mts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,SAAS,EACT,WAAW,EACX,QAAQ,EACR,MAAM,EACN,aAAa,EACb,KAAK,EACL,IAAI,GACL,MAAM,cAAc,CAAC;AAEtB,YAAY,EACV,QAAQ,EACR,SAAS,EACT,YAAY,EACZ,GAAG,EACH,IAAI,EACJ,EAAE,EACF,IAAI,EACJ,IAAI,EACJ,UAAU,EACV,SAAS,EACT,WAAW,EACX,gBAAgB,EAChB,eAAe,EACf,aAAa,EACb,oBAAoB,GACrB,MAAM,cAAc,CAAC"}
@@ -7,5 +7,5 @@
7
7
 
8
8
 
9
9
 
10
- export { AsyncResult, Optional, Result, TernaryResult, match, pipe, } from 'ts-std-forge';
10
+ export { AsyncData, AsyncResult, Optional, Result, TernaryResult, match, pipe, } from 'ts-std-forge';
11
11
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/functional/index.mts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,WAAW,EACX,QAAQ,EACR,MAAM,EACN,aAAa,EACb,KAAK,EACL,IAAI,GACL,MAAM,cAAc,CAAC"}
1
+ {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/functional/index.mts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EACL,SAAS,EACT,WAAW,EACX,QAAQ,EACR,MAAM,EACN,aAAa,EACb,KAAK,EACL,IAAI,GACL,MAAM,cAAc,CAAC"}
@@ -6,6 +6,7 @@ export * from './if-then.mjs';
6
6
  export * from './map-nullable.mjs';
7
7
  export * from './memoize-function.mjs';
8
8
  export * from './panic-error.mjs';
9
+ export * from './replace-equal-deep.mjs';
9
10
  export * from './tuple.mjs';
10
11
  export * from './unknown-to-string.mjs';
11
12
  //# sourceMappingURL=index.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.d.mts","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,0BAA0B,CAAC;AACzC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
@@ -6,6 +6,7 @@ export * from './if-then.mjs';
6
6
  export * from './map-nullable.mjs';
7
7
  export * from './memoize-function.mjs';
8
8
  export * from './panic-error.mjs';
9
+ export * from './replace-equal-deep.mjs';
9
10
  export * from './tuple.mjs';
10
11
  export * from './unknown-to-string.mjs';
11
12
  //# sourceMappingURL=index.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
1
+ {"version":3,"file":"index.mjs","sourceRoot":"","sources":["../../src/others/index.mts"],"names":[],"mappings":"AAAA,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AACpC,cAAc,yBAAyB,CAAC;AACxC,cAAc,uBAAuB,CAAC;AACtC,cAAc,eAAe,CAAC;AAC9B,cAAc,oBAAoB,CAAC;AACnC,cAAc,wBAAwB,CAAC;AACvC,cAAc,mBAAmB,CAAC;AAClC,cAAc,0BAA0B,CAAC;AACzC,cAAc,aAAa,CAAC;AAC5B,cAAc,yBAAyB,CAAC"}
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Returns `next` with every part deeply equal to the same part of `current`
3
+ * replaced by that part of `current` (structural sharing). When the whole of
4
+ * `next` is deeply equal to `current`, `current` itself is returned, so an
5
+ * `Object.is` comparison of the result with `current` tells whether anything
6
+ * changed, and a reader that compares by reference sees a new object only
7
+ * where the value really changed.
8
+ *
9
+ * Only plain objects and arrays are walked into. Anything else — a `Map`, a
10
+ * `Set`, a `Date`, a class instance — is compared whole with
11
+ * {@link fastDeepEqual} and kept or replaced whole.
12
+ *
13
+ * Array items are matched by index. Give `key` to match them by key instead:
14
+ * an item for which it returns something other than `undefined` is matched
15
+ * with the item of `current` it returns the same key for, and an item it
16
+ * returns `undefined` for is still matched by index. The one `key` is used
17
+ * for the arrays at every depth.
18
+ *
19
+ * Neither argument is changed. The result shares objects with both, so it
20
+ * relies on neither being changed afterwards.
21
+ *
22
+ * Call it where the new value is written over the one held, so that nothing
23
+ * downstream has a new object it must compare again.
24
+ *
25
+ * @param current - The value held now
26
+ * @param next - The value to write over it
27
+ * @param options - {@link ReplaceEqualDeepOptions}
28
+ * @returns `current` when `next` is deeply equal to it; otherwise a value
29
+ * deeply equal to `next` that reuses the equal parts of `current`
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * type PullReport = DeepReadonly<{
34
+ * summary: { open: number };
35
+ * pulls: { number: number; title: string }[];
36
+ * }>;
37
+ *
38
+ * const held: PullReport = {
39
+ * summary: { open: 2 },
40
+ * pulls: [
41
+ * { number: 1, title: 'one' },
42
+ * { number: 2, title: 'two' },
43
+ * ],
44
+ * } as const;
45
+ *
46
+ * // A server answers with the whole report every time.
47
+ * const answer: PullReport = {
48
+ * summary: { open: 2 },
49
+ * pulls: [
50
+ * { number: 1, title: 'one' },
51
+ * { number: 2, title: 'two (edited)' },
52
+ * ],
53
+ * } as const;
54
+ *
55
+ * const written = replaceEqualDeep(held, answer);
56
+ *
57
+ * assert.deepStrictEqual(written, answer);
58
+ *
59
+ * // What did not change is the object already held,
60
+ * assert.strictEqual(written.summary, held.summary);
61
+ *
62
+ * assert.strictEqual(written.pulls[0], held.pulls[0]);
63
+ *
64
+ * // and what changed is new.
65
+ * assert.notStrictEqual(written.pulls[1], held.pulls[1]);
66
+ *
67
+ * // An answer that changes nothing gives back the value held.
68
+ * assert.strictEqual(replaceEqualDeep(held, structuredClone(held)), held);
69
+ *
70
+ * // Match array items by key rather than by index.
71
+ * const opened = replaceEqualDeep(
72
+ * held.pulls,
73
+ * [{ number: 0, title: 'zero' }, ...structuredClone(held.pulls)],
74
+ * {
75
+ * key: (item) =>
76
+ * isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
77
+ * },
78
+ * );
79
+ *
80
+ * assert.strictEqual(opened[1], held.pulls[0]);
81
+ *
82
+ * assert.strictEqual(opened[2], held.pulls[1]);
83
+ * ```
84
+ */
85
+ export declare const replaceEqualDeep: <T>(current: T, next: T, options?: ReplaceEqualDeepOptions) => T;
86
+ /** Options of {@link replaceEqualDeep}. */
87
+ export type ReplaceEqualDeepOptions = Readonly<{
88
+ /**
89
+ * The key an array item is matched by. Items it returns `undefined` for
90
+ * are matched by index. Keys are compared with `SameValueZero`, as `Map`
91
+ * compares them.
92
+ */
93
+ key?: (item: unknown) => unknown;
94
+ }>;
95
+ //# sourceMappingURL=replace-equal-deep.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-equal-deep.d.mts","sourceRoot":"","sources":["../../src/others/replace-equal-deep.mts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmFG;AACH,eAAO,MAAM,gBAAgB,GAAI,CAAC,WACvB,CAAC,QACJ,CAAC,YACG,uBAAuB,KAChC,CAIwC,CAAC;AAE5C,2CAA2C;AAC3C,MAAM,MAAM,uBAAuB,GAAG,QAAQ,CAAC;IAC7C;;;;OAIG;IACH,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,OAAO,CAAC;CAClC,CAAC,CAAC"}
@@ -0,0 +1,156 @@
1
+ import { hasKey, isRecord } from '../guard/index.mjs';
2
+ import { fastDeepEqual } from './fast-deep-equal.mjs';
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+
16
+
17
+
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
27
+
28
+
29
+
30
+
31
+
32
+
33
+
34
+
35
+
36
+
37
+
38
+
39
+
40
+
41
+
42
+
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+ export const replaceEqualDeep = (current, next, options) =>
88
+
89
+
90
+
91
+ replace(current, next, options?.key);
92
+ const replace = (current, next, key) => Object.is(current, next)
93
+ ? current
94
+ : isPlainArray(current) && isPlainArray(next)
95
+ ? replaceArray(current, next, key)
96
+ : isPlainObject(current) && isPlainObject(next)
97
+ ? replaceObject(current, next, key)
98
+ : fastDeepEqual(current, next)
99
+ ? current
100
+ : next;
101
+
102
+
103
+
104
+ const replaceArray = (current, next, key) => {
105
+ const byKey = key === undefined ? undefined : indexByKey(current, key);
106
+ let mut_isCurrent = current.length === next.length;
107
+ let mut_isNext = true;
108
+ const mut_shared = [];
109
+ for (let mut_index = 0; mut_index < next.length; mut_index += 1) {
110
+ const index = mut_index;
111
+ const item = next[index];
112
+ const itemKey = key?.(item);
113
+ const before = itemKey === undefined || byKey === undefined
114
+ ? index < current.length
115
+ ? current[index]
116
+ : absent
117
+ : byKey.has(itemKey)
118
+ ? byKey.get(itemKey)
119
+ : absent;
120
+ const result = before === absent ? item : replace(before, item, key);
121
+ mut_isCurrent &&= Object.is(result, current[index]);
122
+ mut_isNext &&= Object.is(result, item);
123
+ mut_shared.push(result);
124
+ }
125
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
126
+ };
127
+ const replaceObject = (current, next, key) => {
128
+ const nextKeys = Object.keys(next);
129
+ let mut_isCurrent = Object.keys(current).length === nextKeys.length;
130
+ let mut_isNext = true;
131
+ const mut_shared = {};
132
+ for (const k of nextKeys) {
133
+ const value = next[k];
134
+ const has = hasKey(current, k);
135
+ const result = has ? replace(current[k], value, key) : value;
136
+ mut_isCurrent &&= has && Object.is(result, current[k]);
137
+ mut_isNext &&= Object.is(result, value);
138
+ mut_shared[k] = result;
139
+ }
140
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
141
+ };
142
+
143
+ const absent = Symbol('absent');
144
+ const indexByKey = (items, key) => {
145
+ const mut_byKey = new Map();
146
+ for (const item of items) {
147
+ const itemKey = key(item);
148
+ if (itemKey !== undefined && !mut_byKey.has(itemKey)) {
149
+ mut_byKey.set(itemKey, item);
150
+ }
151
+ }
152
+ return mut_byKey;
153
+ };
154
+ const isPlainArray = (value) => Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
155
+ const isPlainObject = (value) => isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
156
+ //# sourceMappingURL=replace-equal-deep.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"replace-equal-deep.mjs","sourceRoot":"","sources":["../../src/others/replace-equal-deep.mts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-data-forge",
3
- "version": "14.8.0",
3
+ "version": "14.10.0",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "typescript",
@@ -37,7 +37,7 @@
37
37
  ],
38
38
  "dependencies": {
39
39
  "@sindresorhus/is": "^8.1.0",
40
- "ts-std-forge": "^0.5.1",
40
+ "ts-std-forge": "^0.5.2",
41
41
  "ts-type-forge": "~9.2.2"
42
42
  },
43
43
  "devDependencies": {
@@ -46,9 +46,9 @@
46
46
  "@mui/material": "9.4.0",
47
47
  "@types/react": "19.3.0",
48
48
  "@vitest/browser-playwright": "5.0.3",
49
- "eslint-config-typed": "5.19.0",
50
- "eslint-plugin-ts-fortress": "1.3.4",
51
- "eslint-plugin-ts-type-forge": "0.8.9",
49
+ "eslint-config-typed": "5.19.1",
50
+ "eslint-plugin-ts-fortress": "1.3.7",
51
+ "eslint-plugin-ts-type-forge": "0.8.12",
52
52
  "immer": "11.1.21",
53
53
  "playwright": "1.64.0",
54
54
  "prettier": "3.9.9",
@@ -8,6 +8,7 @@
8
8
  * New code should import these from `ts-std-forge` directly.
9
9
  */
10
10
  export {
11
+ AsyncData,
11
12
  AsyncResult,
12
13
  Optional,
13
14
  Result,
@@ -17,6 +18,9 @@ export {
17
18
  } from 'ts-std-forge';
18
19
 
19
20
  export type {
21
+ AsyncErr,
22
+ AsyncIdle,
23
+ AsyncPending,
20
24
  Err,
21
25
  None,
22
26
  Ok,
@@ -25,6 +29,7 @@ export type {
25
29
  TernaryErr,
26
30
  TernaryOk,
27
31
  TernaryWarn,
32
+ UnknownAsyncData,
28
33
  UnknownOptional,
29
34
  UnknownResult,
30
35
  UnknownTernaryResult,
@@ -11,6 +11,9 @@ describe(debounce, () => {
11
11
  // eslint-disable-next-line vitest/no-hooks
12
12
  afterEach(() => {
13
13
  vi.restoreAllMocks();
14
+
15
+ // The test files of a worker share it, timers included.
16
+ vi.useRealTimers();
14
17
  });
15
18
 
16
19
  test('should delay the execution of the function', () => {
@@ -6,5 +6,6 @@ export * from './if-then.mjs';
6
6
  export * from './map-nullable.mjs';
7
7
  export * from './memoize-function.mjs';
8
8
  export * from './panic-error.mjs';
9
+ export * from './replace-equal-deep.mjs';
9
10
  export * from './tuple.mjs';
10
11
  export * from './unknown-to-string.mjs';
@@ -0,0 +1,225 @@
1
+ import type { MutableRecord, UnknownRecord } from 'ts-type-forge';
2
+ import { hasKey, isRecord } from '../guard/index.mjs';
3
+ import { fastDeepEqual } from './fast-deep-equal.mjs';
4
+
5
+ /**
6
+ * Returns `next` with every part deeply equal to the same part of `current`
7
+ * replaced by that part of `current` (structural sharing). When the whole of
8
+ * `next` is deeply equal to `current`, `current` itself is returned, so an
9
+ * `Object.is` comparison of the result with `current` tells whether anything
10
+ * changed, and a reader that compares by reference sees a new object only
11
+ * where the value really changed.
12
+ *
13
+ * Only plain objects and arrays are walked into. Anything else — a `Map`, a
14
+ * `Set`, a `Date`, a class instance — is compared whole with
15
+ * {@link fastDeepEqual} and kept or replaced whole.
16
+ *
17
+ * Array items are matched by index. Give `key` to match them by key instead:
18
+ * an item for which it returns something other than `undefined` is matched
19
+ * with the item of `current` it returns the same key for, and an item it
20
+ * returns `undefined` for is still matched by index. The one `key` is used
21
+ * for the arrays at every depth.
22
+ *
23
+ * Neither argument is changed. The result shares objects with both, so it
24
+ * relies on neither being changed afterwards.
25
+ *
26
+ * Call it where the new value is written over the one held, so that nothing
27
+ * downstream has a new object it must compare again.
28
+ *
29
+ * @param current - The value held now
30
+ * @param next - The value to write over it
31
+ * @param options - {@link ReplaceEqualDeepOptions}
32
+ * @returns `current` when `next` is deeply equal to it; otherwise a value
33
+ * deeply equal to `next` that reuses the equal parts of `current`
34
+ *
35
+ * @example
36
+ * ```ts
37
+ * type PullReport = DeepReadonly<{
38
+ * summary: { open: number };
39
+ * pulls: { number: number; title: string }[];
40
+ * }>;
41
+ *
42
+ * const held: PullReport = {
43
+ * summary: { open: 2 },
44
+ * pulls: [
45
+ * { number: 1, title: 'one' },
46
+ * { number: 2, title: 'two' },
47
+ * ],
48
+ * } as const;
49
+ *
50
+ * // A server answers with the whole report every time.
51
+ * const answer: PullReport = {
52
+ * summary: { open: 2 },
53
+ * pulls: [
54
+ * { number: 1, title: 'one' },
55
+ * { number: 2, title: 'two (edited)' },
56
+ * ],
57
+ * } as const;
58
+ *
59
+ * const written = replaceEqualDeep(held, answer);
60
+ *
61
+ * assert.deepStrictEqual(written, answer);
62
+ *
63
+ * // What did not change is the object already held,
64
+ * assert.strictEqual(written.summary, held.summary);
65
+ *
66
+ * assert.strictEqual(written.pulls[0], held.pulls[0]);
67
+ *
68
+ * // and what changed is new.
69
+ * assert.notStrictEqual(written.pulls[1], held.pulls[1]);
70
+ *
71
+ * // An answer that changes nothing gives back the value held.
72
+ * assert.strictEqual(replaceEqualDeep(held, structuredClone(held)), held);
73
+ *
74
+ * // Match array items by key rather than by index.
75
+ * const opened = replaceEqualDeep(
76
+ * held.pulls,
77
+ * [{ number: 0, title: 'zero' }, ...structuredClone(held.pulls)],
78
+ * {
79
+ * key: (item) =>
80
+ * isRecord(item) && hasKey(item, 'number') ? item.number : undefined,
81
+ * },
82
+ * );
83
+ *
84
+ * assert.strictEqual(opened[1], held.pulls[0]);
85
+ *
86
+ * assert.strictEqual(opened[2], held.pulls[1]);
87
+ * ```
88
+ */
89
+ export const replaceEqualDeep = <T,>(
90
+ current: T,
91
+ next: T,
92
+ options?: ReplaceEqualDeepOptions,
93
+ ): T =>
94
+ // The walk sees `unknown`; what it returns is `current`, `next`, or a copy
95
+ // of `next`'s shape whose parts are parts of either, so it is a `T`.
96
+ // eslint-disable-next-line total-functions/no-unsafe-type-assertion
97
+ replace(current, next, options?.key) as T;
98
+
99
+ /** Options of {@link replaceEqualDeep}. */
100
+ export type ReplaceEqualDeepOptions = Readonly<{
101
+ /**
102
+ * The key an array item is matched by. Items it returns `undefined` for
103
+ * are matched by index. Keys are compared with `SameValueZero`, as `Map`
104
+ * compares them.
105
+ */
106
+ key?: (item: unknown) => unknown;
107
+ }>;
108
+
109
+ type Key = (item: unknown) => unknown;
110
+
111
+ const replace = (
112
+ current: unknown,
113
+ next: unknown,
114
+ key: Key | undefined,
115
+ ): unknown =>
116
+ Object.is(current, next)
117
+ ? current
118
+ : isPlainArray(current) && isPlainArray(next)
119
+ ? replaceArray(current, next, key)
120
+ : isPlainObject(current) && isPlainObject(next)
121
+ ? replaceObject(current, next, key)
122
+ : fastDeepEqual(current, next)
123
+ ? current
124
+ : next;
125
+
126
+ // Each walk returns `current` when every part of the result is the same part
127
+ // of `current`, `next` when every part is the same part of `next` (nothing
128
+ // was reused, so no copy is needed), and the copy otherwise.
129
+
130
+ const replaceArray = (
131
+ current: readonly unknown[],
132
+ next: readonly unknown[],
133
+ key: Key | undefined,
134
+ ): readonly unknown[] => {
135
+ const byKey = key === undefined ? undefined : indexByKey(current, key);
136
+
137
+ let mut_isCurrent = current.length === next.length;
138
+
139
+ let mut_isNext = true;
140
+
141
+ const mut_shared: unknown[] = [];
142
+
143
+ for (let mut_index = 0; mut_index < next.length; mut_index += 1) {
144
+ const index = mut_index;
145
+
146
+ const item = next[index];
147
+
148
+ const itemKey = key?.(item);
149
+
150
+ const before =
151
+ itemKey === undefined || byKey === undefined
152
+ ? index < current.length
153
+ ? current[index]
154
+ : absent
155
+ : byKey.has(itemKey)
156
+ ? byKey.get(itemKey)
157
+ : absent;
158
+
159
+ const result = before === absent ? item : replace(before, item, key);
160
+
161
+ mut_isCurrent &&= Object.is(result, current[index]);
162
+
163
+ mut_isNext &&= Object.is(result, item);
164
+
165
+ mut_shared.push(result);
166
+ }
167
+
168
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
169
+ };
170
+
171
+ const replaceObject = (
172
+ current: UnknownRecord,
173
+ next: UnknownRecord,
174
+ key: Key | undefined,
175
+ ): UnknownRecord => {
176
+ const nextKeys = Object.keys(next);
177
+
178
+ let mut_isCurrent = Object.keys(current).length === nextKeys.length;
179
+
180
+ let mut_isNext = true;
181
+
182
+ const mut_shared: MutableRecord<string, unknown> = {};
183
+
184
+ for (const k of nextKeys) {
185
+ const value = next[k];
186
+
187
+ const has = hasKey(current, k);
188
+
189
+ const result = has ? replace(current[k], value, key) : value;
190
+
191
+ mut_isCurrent &&= has && Object.is(result, current[k]);
192
+
193
+ mut_isNext &&= Object.is(result, value);
194
+
195
+ mut_shared[k] = result;
196
+ }
197
+
198
+ return mut_isCurrent ? current : mut_isNext ? next : mut_shared;
199
+ };
200
+
201
+ // What an item of `next` is matched with when nothing of `current` is.
202
+ const absent: unique symbol = Symbol('absent');
203
+
204
+ const indexByKey = (
205
+ items: readonly unknown[],
206
+ key: Key,
207
+ ): ReadonlyMap<unknown, unknown> => {
208
+ const mut_byKey = new Map<unknown, unknown>();
209
+
210
+ for (const item of items) {
211
+ const itemKey = key(item);
212
+
213
+ if (itemKey !== undefined && !mut_byKey.has(itemKey)) {
214
+ mut_byKey.set(itemKey, item);
215
+ }
216
+ }
217
+
218
+ return mut_byKey;
219
+ };
220
+
221
+ const isPlainArray = (value: unknown): value is readonly unknown[] =>
222
+ Array.isArray(value) && Object.getPrototypeOf(value) === Array.prototype;
223
+
224
+ const isPlainObject = (value: unknown): value is UnknownRecord =>
225
+ isRecord(value) && Object.getPrototypeOf(value) === Object.prototype;
@@ -0,0 +1,342 @@
1
+ // A `Date` is one of the values compared whole, so the cases build some.
2
+ /* eslint-disable unicorn/prefer-temporal */
3
+ import type { DeepReadonly, ReadonlyRecord } from 'ts-type-forge';
4
+ import { expectType } from '../expect-type.mjs';
5
+ import { hasKey, isRecord } from '../guard/index.mjs';
6
+ import { replaceEqualDeep } from './replace-equal-deep.mjs';
7
+
8
+ describe(replaceEqualDeep, () => {
9
+ test('returns the current value when the next one is deeply equal', () => {
10
+ const current = {
11
+ id: 1,
12
+ tags: ['a', 'b'],
13
+ owner: { name: 'x', roles: [{ role: 'admin' }] },
14
+ } as const;
15
+
16
+ const next = {
17
+ id: 1,
18
+ tags: ['a', 'b'],
19
+ owner: { name: 'x', roles: [{ role: 'admin' }] },
20
+ } as const;
21
+
22
+ assert.strictEqual(replaceEqualDeep(current, next), current);
23
+ });
24
+
25
+ test('keeps the branches that did not change and replaces the rest', () => {
26
+ const current: Snapshot = {
27
+ summary: { open: 3, merged: 1 },
28
+ queue: [{ number: 1 }, { number: 2 }],
29
+ readAt: 100,
30
+ } as const;
31
+
32
+ const next: Snapshot = {
33
+ summary: { open: 3, merged: 1 },
34
+ queue: [{ number: 1 }, { number: 3 }],
35
+ readAt: 200,
36
+ } as const;
37
+
38
+ const shared = replaceEqualDeep(current, next);
39
+
40
+ assert.deepStrictEqual(shared, next);
41
+
42
+ assert.notStrictEqual(shared, current);
43
+
44
+ assert.strictEqual(shared.summary, current.summary);
45
+
46
+ assert.notStrictEqual(shared.queue, current.queue);
47
+
48
+ assert.strictEqual(shared.queue[0], current.queue[0]);
49
+
50
+ assert.strictEqual(shared.queue[1], next.queue[1]);
51
+
52
+ assert.strictEqual(shared.readAt, 200);
53
+ });
54
+
55
+ test('returns the next primitive when it differs', () => {
56
+ assert.strictEqual(replaceEqualDeep(1, 2), 2);
57
+
58
+ assert.strictEqual(replaceEqualDeep('a', 'a'), 'a');
59
+
60
+ assert.isTrue(Number.isNaN(replaceEqualDeep(Number.NaN, Number.NaN)));
61
+ });
62
+
63
+ test('replaces a value whose kind changed whole', () => {
64
+ const next: unknown = { 0: 'a' } as const;
65
+
66
+ assert.strictEqual(replaceEqualDeep<unknown>(['a'], next), next);
67
+
68
+ const nextArray: unknown = ['a'] as const;
69
+
70
+ assert.strictEqual(
71
+ replaceEqualDeep<unknown>({ 0: 'a' }, nextArray),
72
+ nextArray,
73
+ );
74
+
75
+ const nextNull: unknown = null;
76
+
77
+ assert.strictEqual(replaceEqualDeep<unknown>({}, nextNull), null);
78
+ });
79
+
80
+ test('builds a new object when a property is added or removed', () => {
81
+ const inner = { x: 1 } as const;
82
+
83
+ const added = replaceEqualDeep<ReadonlyRecord<string, unknown>>(
84
+ { inner },
85
+ { inner: { x: 1 }, extra: true },
86
+ );
87
+
88
+ assert.deepStrictEqual(added, { inner: { x: 1 }, extra: true });
89
+
90
+ assert.strictEqual(added['inner'], inner);
91
+
92
+ const current: ReadonlyRecord<string, unknown> = {
93
+ inner,
94
+ gone: 1,
95
+ } as const;
96
+
97
+ const removed = replaceEqualDeep(current, { inner: { x: 1 } });
98
+
99
+ assert.deepStrictEqual(removed, { inner: { x: 1 } });
100
+
101
+ assert.notStrictEqual(removed, current);
102
+
103
+ assert.isFalse(Object.hasOwn(removed, 'gone'));
104
+
105
+ assert.strictEqual(removed['inner'], inner);
106
+ });
107
+
108
+ test('builds a new object when a property set to undefined goes missing', () => {
109
+ const current: Readonly<{ a?: number | undefined }> = {
110
+ a: undefined,
111
+ } as const;
112
+
113
+ const shared = replaceEqualDeep(current, {});
114
+
115
+ assert.notStrictEqual(shared, current);
116
+
117
+ assert.isFalse(Object.hasOwn(shared, 'a'));
118
+ });
119
+
120
+ test('matches array items by index by default', () => {
121
+ const one: Numbered = { number: 1 } as const;
122
+
123
+ const two: Numbered = { number: 2 } as const;
124
+
125
+ const current: readonly Numbered[] = [one, two] as const;
126
+
127
+ const shorter = replaceEqualDeep(current, [{ number: 1 }]);
128
+
129
+ assert.deepStrictEqual(shorter, [{ number: 1 }]);
130
+
131
+ assert.strictEqual(shorter[0], one);
132
+
133
+ const longer = replaceEqualDeep(current, [
134
+ { number: 1 },
135
+ { number: 2 },
136
+ { number: 3 },
137
+ ]);
138
+
139
+ assert.strictEqual(longer[0], one);
140
+
141
+ assert.strictEqual(longer[1], two);
142
+
143
+ // An item put in front moves every one after it, so nothing matches.
144
+ const shifted = replaceEqualDeep(current, [
145
+ { number: 0 },
146
+ { number: 1 },
147
+ { number: 2 },
148
+ ]);
149
+
150
+ assert.notStrictEqual(shifted[1], one);
151
+
152
+ assert.notStrictEqual(shifted[2], two);
153
+ });
154
+
155
+ test('matches array items by key when `key` returns one', () => {
156
+ const one: Pull = { number: 1, title: 'one' } as const;
157
+
158
+ const two: Pull = { number: 2, title: 'two' } as const;
159
+
160
+ const current: readonly Pull[] = [one, two] as const;
161
+
162
+ const key = (item: unknown): unknown =>
163
+ isRecord(item) && hasKey(item, 'number') ? item.number : undefined;
164
+
165
+ const shared = replaceEqualDeep(
166
+ current,
167
+ [
168
+ { number: 0, title: 'zero' },
169
+ { number: 1, title: 'one' },
170
+ { number: 2, title: 'two (edited)' },
171
+ ],
172
+ { key },
173
+ );
174
+
175
+ assert.strictEqual(shared[1], one);
176
+
177
+ assert.notStrictEqual(shared[2], two);
178
+
179
+ assert.deepStrictEqual(shared[2], { number: 2, title: 'two (edited)' });
180
+
181
+ const reordered = replaceEqualDeep(
182
+ current,
183
+ [
184
+ { number: 2, title: 'two' },
185
+ { number: 1, title: 'one' },
186
+ ],
187
+ { key },
188
+ );
189
+
190
+ assert.notStrictEqual(reordered, current);
191
+
192
+ assert.strictEqual(reordered[0], two);
193
+
194
+ assert.strictEqual(reordered[1], one);
195
+
196
+ const same = replaceEqualDeep(
197
+ current,
198
+ [
199
+ { number: 1, title: 'one' },
200
+ { number: 2, title: 'two' },
201
+ ],
202
+ { key },
203
+ );
204
+
205
+ assert.strictEqual(same, current);
206
+ });
207
+
208
+ test('matches by index the items `key` gives no key, at every depth', () => {
209
+ const key = (item: unknown): unknown =>
210
+ isRecord(item) && hasKey(item, 'id') ? item.id : undefined;
211
+
212
+ const labels: readonly string[] = ['bug', 'ui'] as const;
213
+
214
+ const current: readonly Labeled[] = [{ id: 'a', labels }] as const;
215
+
216
+ const shared = replaceEqualDeep(
217
+ current,
218
+ [
219
+ { id: 'b', labels: ['x'] },
220
+ { id: 'a', labels: ['bug', 'ui'] },
221
+ ],
222
+ { key },
223
+ );
224
+
225
+ assert.strictEqual(shared[1], current[0]);
226
+
227
+ assert.strictEqual(shared[1]?.labels, labels);
228
+ });
229
+
230
+ test('compares a Map, a Set, a Date or a class instance whole', () => {
231
+ const map = new Map([['a', { x: 1 }]]);
232
+
233
+ assert.strictEqual(replaceEqualDeep(map, new Map([['a', { x: 1 }]])), map);
234
+
235
+ const changedMap = new Map([
236
+ ['a', { x: 1 }],
237
+ ['b', { x: 2 }],
238
+ ]);
239
+
240
+ const sharedMap = replaceEqualDeep(map, changedMap);
241
+
242
+ assert.strictEqual(sharedMap, changedMap);
243
+
244
+ // Nothing inside is reused.
245
+ assert.notStrictEqual(sharedMap.get('a'), map.get('a'));
246
+
247
+ const set = new Set([1, 2]);
248
+
249
+ assert.strictEqual(replaceEqualDeep(set, new Set([1, 2])), set);
250
+
251
+ const date = new Date(0);
252
+
253
+ assert.strictEqual(replaceEqualDeep(date, new Date(0)), date);
254
+
255
+ const laterDate = new Date(1);
256
+
257
+ assert.strictEqual(replaceEqualDeep(date, laterDate), laterDate);
258
+
259
+ class Point {
260
+ readonly x: number;
261
+
262
+ readonly inner: Readonly<{ y: number }>;
263
+
264
+ constructor(x: number, y: number) {
265
+ this.x = x;
266
+
267
+ this.inner = { y };
268
+ }
269
+ }
270
+
271
+ const point = new Point(1, 2);
272
+
273
+ assert.strictEqual(replaceEqualDeep(point, new Point(1, 2)), point);
274
+
275
+ const moved = new Point(3, 2);
276
+
277
+ const sharedPoint = replaceEqualDeep(point, moved);
278
+
279
+ assert.strictEqual(sharedPoint, moved);
280
+
281
+ assert.notStrictEqual(sharedPoint.inner, point.inner);
282
+ });
283
+
284
+ test('reuses a Map compared whole inside a plain object', () => {
285
+ const map = new Map([['a', 1]]);
286
+
287
+ const current = { map, count: 1 } as const;
288
+
289
+ const shared = replaceEqualDeep(current, {
290
+ map: new Map([['a', 1]]),
291
+ count: 2,
292
+ });
293
+
294
+ assert.strictEqual(shared.map, map);
295
+
296
+ assert.strictEqual(shared.count, 2);
297
+ });
298
+
299
+ test('changes neither argument', () => {
300
+ const current: Listed = { list: [{ a: 1 }, { a: 2 }] } as const;
301
+
302
+ const next: Listed = { list: [{ a: 1 }, { a: 3 }] } as const;
303
+
304
+ const currentCopy = structuredClone(current);
305
+
306
+ const nextCopy = structuredClone(next);
307
+
308
+ replaceEqualDeep(current, next);
309
+
310
+ assert.deepStrictEqual(current, currentCopy);
311
+
312
+ assert.deepStrictEqual(next, nextCopy);
313
+ });
314
+
315
+ test('keeps the type of its arguments', () => {
316
+ type PullReport = Readonly<{
317
+ entries: readonly Readonly<{ number: number }>[];
318
+ }>;
319
+
320
+ const report: PullReport = { entries: [] } as const;
321
+
322
+ const shared = replaceEqualDeep(report, { entries: [{ number: 1 }] });
323
+
324
+ expectType<typeof shared, PullReport>('=');
325
+
326
+ assert.deepStrictEqual(shared, { entries: [{ number: 1 }] });
327
+ });
328
+ });
329
+
330
+ type Snapshot = DeepReadonly<{
331
+ summary: { open: number; merged: number };
332
+ queue: { number: number }[];
333
+ readAt: number;
334
+ }>;
335
+
336
+ type Numbered = Readonly<{ number: number }>;
337
+
338
+ type Pull = Readonly<{ number: number; title: string }>;
339
+
340
+ type Labeled = Readonly<{ id: string; labels: readonly string[] }>;
341
+
342
+ type Listed = DeepReadonly<{ list: { a: number }[] }>;