@tldraw/store 5.2.0-next.e2b8d10bf10e → 5.2.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/DOCS.md +790 -0
- package/README.md +9 -1
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/ImmutableMap.js +0 -13
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +1 -1
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js +26 -14
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +6 -5
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +1 -1
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js +1 -1
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/ImmutableMap.mjs +0 -13
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +1 -1
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs +26 -14
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +7 -6
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +1 -1
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs +1 -1
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/package.json +9 -5
- package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
- package/src/lib/AtomSet.test.ts +116 -0
- package/src/lib/BaseRecord.test.ts +10 -22
- package/src/lib/ImmutableMap.test.ts +114 -71
- package/src/lib/ImmutableMap.ts +1 -0
- package/src/lib/IncrementalSetConstructor.test.ts +76 -81
- package/src/lib/RecordType.test.ts +216 -0
- package/src/lib/RecordType.ts +1 -1
- package/src/lib/RecordsDiff.test.ts +112 -106
- package/src/lib/RecordsDiff.ts +43 -18
- package/src/lib/Store.test.ts +570 -630
- package/src/lib/Store.ts +9 -10
- package/src/lib/StoreListeners.test.ts +462 -0
- package/src/lib/StoreQueries.test.ts +586 -434
- package/src/lib/StoreSchema.test.ts +1012 -174
- package/src/lib/StoreSchema.ts +1 -1
- package/src/lib/StoreSideEffects.test.ts +546 -158
- package/src/lib/devFreeze.test.ts +94 -124
- package/src/lib/executeQuery.test.ts +77 -31
- package/src/lib/executeQuery.ts +3 -1
- package/src/lib/migrate.test.ts +273 -296
- package/src/lib/setUtils.test.ts +38 -79
- package/src/lib/test/createMigrations.test.ts +0 -75
- package/src/lib/test/dependsOn.test.ts +0 -166
- package/src/lib/test/getMigrationsSince.test.ts +0 -121
- package/src/lib/test/migrate.test.ts +0 -118
- package/src/lib/test/migratePersistedRecord.test.ts +0 -265
- package/src/lib/test/migrationCaching.test.ts +0 -209
- package/src/lib/test/recordStore.test.ts +0 -1567
- package/src/lib/test/recordStoreQueries.test.ts +0 -814
- package/src/lib/test/recordType.test.ts +0 -19
- package/src/lib/test/sortMigrations.test.ts +0 -83
- package/src/lib/test/upgradeSchema.test.ts +0 -80
- package/src/lib/test/validate.test.ts +0 -178
- package/src/lib/test/validateMigrations.test.ts +0 -165
|
@@ -1,101 +1,144 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { ImmutableMap, emptyMap } from './ImmutableMap'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
// Tests for SPEC.md §27 (ImmutableMap, internal).
|
|
5
|
+
// Rule IDs like [IM1] in test names refer to that document.
|
|
6
|
+
|
|
7
|
+
describe('ImmutableMap (IM)', () => {
|
|
8
|
+
it('[IM1] set returns a new map and leaves the original unchanged', () => {
|
|
9
|
+
const map = new ImmutableMap([['key', 'value']])
|
|
10
|
+
const newMap = map.set('key', 'newValue')
|
|
11
|
+
|
|
12
|
+
expect(newMap.get('key')).toBe('newValue')
|
|
13
|
+
expect(map.get('key')).toBe('value')
|
|
14
|
+
|
|
15
|
+
const withExtra = map.set('other', 'x')
|
|
16
|
+
expect(withExtra.size).toBe(2)
|
|
17
|
+
expect(map.size).toBe(1)
|
|
16
18
|
})
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
it('[IM1] delete returns a new map without the key, leaving the original unchanged', () => {
|
|
21
|
+
const map = new ImmutableMap([
|
|
22
|
+
['a', 1],
|
|
23
|
+
['b', 2],
|
|
24
|
+
])
|
|
25
|
+
const newMap = map.delete('a')
|
|
26
|
+
|
|
27
|
+
expect(newMap.size).toBe(1)
|
|
28
|
+
expect(newMap.get('a')).toBeUndefined()
|
|
29
|
+
expect(newMap.get('b')).toBe(2)
|
|
30
|
+
|
|
31
|
+
expect(map.size).toBe(2)
|
|
32
|
+
expect(map.get('a')).toBe(1)
|
|
25
33
|
})
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const map = new ImmutableMap([['key', 'value']])
|
|
30
|
-
const newMap = map.set('key', 'newValue')
|
|
35
|
+
it('[IM2] get returns the value, undefined for missing keys, or the given notSetValue', () => {
|
|
36
|
+
const map = new ImmutableMap([['a', 1]])
|
|
31
37
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
expect(map.get('a')).toBe(1)
|
|
39
|
+
expect(map.get('missing')).toBeUndefined()
|
|
40
|
+
expect(map.get('missing', 42)).toBe(42)
|
|
41
|
+
expect(map.get('a', 42)).toBe(1)
|
|
42
|
+
})
|
|
35
43
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
44
|
+
it('[IM3] object keys are hashed by identity', () => {
|
|
45
|
+
const obj1 = { id: 1 }
|
|
46
|
+
const obj2 = { id: 2 }
|
|
39
47
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
48
|
+
let map = new ImmutableMap<{ id: number }, string>()
|
|
49
|
+
map = map.set(obj1, 'first')
|
|
50
|
+
map = map.set(obj2, 'second')
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
})
|
|
52
|
+
expect(map.size).toBe(2)
|
|
53
|
+
expect(map.get(obj1)).toBe('first')
|
|
54
|
+
expect(map.get(obj2)).toBe('second')
|
|
55
|
+
expect(map.get({ id: 1 })).toBeUndefined() // structurally equal, but a different key
|
|
48
56
|
})
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
it('[IM3] a constructor given duplicate keys keeps the last value', () => {
|
|
59
|
+
const map = new ImmutableMap([
|
|
60
|
+
['a', 1],
|
|
61
|
+
['a', 2],
|
|
62
|
+
['a', 3],
|
|
63
|
+
])
|
|
64
|
+
expect(map.size).toBe(1)
|
|
65
|
+
expect(map.get('a')).toBe(3)
|
|
66
|
+
})
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
68
|
+
it('[IM4] withMutations batches changes into one new map', () => {
|
|
69
|
+
const map = new ImmutableMap([['a', 1]])
|
|
70
|
+
const newMap = map.withMutations((mutable) => {
|
|
71
|
+
mutable.set('b', 2)
|
|
72
|
+
mutable.set('c', 3)
|
|
73
|
+
mutable.delete('a')
|
|
61
74
|
})
|
|
75
|
+
|
|
76
|
+
expect(newMap.size).toBe(2)
|
|
77
|
+
expect(newMap.get('a')).toBeUndefined()
|
|
78
|
+
expect(newMap.get('b')).toBe(2)
|
|
79
|
+
expect(newMap.get('c')).toBe(3)
|
|
80
|
+
|
|
81
|
+
expect(map.size).toBe(1)
|
|
82
|
+
expect(map.get('a')).toBe(1)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('[IM4] withMutations that changes nothing returns the same instance', () => {
|
|
86
|
+
const map = new ImmutableMap([['a', 1]])
|
|
87
|
+
const same = map.withMutations(() => {})
|
|
88
|
+
expect(same).toBe(map)
|
|
62
89
|
})
|
|
63
90
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
it('[IM5] deleteAll removes all the given keys', () => {
|
|
92
|
+
const map = new ImmutableMap([
|
|
93
|
+
['a', 1],
|
|
94
|
+
['b', 2],
|
|
95
|
+
['c', 3],
|
|
96
|
+
])
|
|
97
|
+
const newMap = map.deleteAll(['a', 'c', 'missing'])
|
|
98
|
+
|
|
99
|
+
expect(newMap.size).toBe(1)
|
|
100
|
+
expect(newMap.get('a')).toBeUndefined()
|
|
101
|
+
expect(newMap.get('b')).toBe(2)
|
|
102
|
+
expect(newMap.get('c')).toBeUndefined()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('[IM6] entries, keys, values, and iteration yield every entry exactly once', () => {
|
|
106
|
+
const map = new ImmutableMap([
|
|
107
|
+
['a', 1],
|
|
108
|
+
['b', 2],
|
|
109
|
+
['c', 3],
|
|
110
|
+
])
|
|
111
|
+
|
|
112
|
+
const entries = Array.from(map.entries())
|
|
113
|
+
expect(entries.length).toBe(map.size)
|
|
114
|
+
expect(new Map(entries)).toEqual(
|
|
115
|
+
new Map([
|
|
67
116
|
['a', 1],
|
|
68
117
|
['b', 2],
|
|
69
118
|
['c', 3],
|
|
70
119
|
])
|
|
71
|
-
|
|
120
|
+
)
|
|
72
121
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
expect(newMap.get('c')).toBeUndefined()
|
|
77
|
-
})
|
|
122
|
+
expect(new Set(map.keys())).toEqual(new Set(['a', 'b', 'c']))
|
|
123
|
+
expect([...map.values()].sort()).toEqual([1, 2, 3])
|
|
124
|
+
expect(Array.from(map)).toEqual(entries)
|
|
78
125
|
})
|
|
79
126
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
expect(newMap.get('a')).toBeUndefined()
|
|
91
|
-
expect(newMap.get('b')).toBe(2)
|
|
92
|
-
expect(newMap.get('c')).toBe(3)
|
|
93
|
-
})
|
|
127
|
+
it('[IM6] handles many entries consistently (trie depth)', () => {
|
|
128
|
+
let map = new ImmutableMap<number, number>()
|
|
129
|
+
for (let i = 0; i < 200; i++) {
|
|
130
|
+
map = map.set(i, i * 2)
|
|
131
|
+
}
|
|
132
|
+
expect(map.size).toBe(200)
|
|
133
|
+
for (let i = 0; i < 200; i++) {
|
|
134
|
+
expect(map.get(i)).toBe(i * 2)
|
|
135
|
+
}
|
|
136
|
+
expect(Array.from(map.entries()).length).toBe(200)
|
|
94
137
|
})
|
|
95
138
|
})
|
|
96
139
|
|
|
97
|
-
describe('emptyMap', () => {
|
|
98
|
-
it('
|
|
140
|
+
describe('emptyMap (IM)', () => {
|
|
141
|
+
it('[IM2] emptyMap has zero size and returns undefined for any key', () => {
|
|
99
142
|
const empty = emptyMap()
|
|
100
143
|
expect(empty.size).toBe(0)
|
|
101
144
|
expect(empty.get('anything')).toBeUndefined()
|
package/src/lib/ImmutableMap.ts
CHANGED
|
@@ -273,6 +273,7 @@ export class ImmutableMap<K, V> {
|
|
|
273
273
|
* console.log(map.get('missing', 'default')) // 'default'
|
|
274
274
|
* ```
|
|
275
275
|
*/
|
|
276
|
+
get(k: K, notSetValue: V): V
|
|
276
277
|
get(k: K, notSetValue?: V): V {
|
|
277
278
|
return this._root ? this._root.get(0, undefined as any, k, notSetValue)! : notSetValue!
|
|
278
279
|
}
|
|
@@ -1,111 +1,106 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
it('should return undefined when no net changes occur', () => {
|
|
7
|
-
const originalSet = new Set(['a', 'b', 'c'])
|
|
8
|
-
const constructor = new IncrementalSetConstructor(originalSet)
|
|
4
|
+
// Tests for SPEC.md §26 (IncrementalSetConstructor, internal).
|
|
5
|
+
// Rule IDs like [ISC1] in test names refer to that document.
|
|
9
6
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it('should return correct result when items are added', () => {
|
|
18
|
-
const originalSet = new Set(['a', 'b'])
|
|
19
|
-
const constructor = new IncrementalSetConstructor(originalSet)
|
|
20
|
-
|
|
21
|
-
constructor.add('c')
|
|
22
|
-
constructor.add('d')
|
|
7
|
+
describe('IncrementalSetConstructor (ISC)', () => {
|
|
8
|
+
it('[ISC1] get returns undefined when nothing was done', () => {
|
|
9
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
10
|
+
expect(constructor.get()).toBeUndefined()
|
|
11
|
+
})
|
|
23
12
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
13
|
+
it('[ISC1] adding already-present items is a no-op', () => {
|
|
14
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
15
|
+
constructor.add('a')
|
|
16
|
+
constructor.add('b')
|
|
17
|
+
expect(constructor.get()).toBeUndefined()
|
|
18
|
+
})
|
|
30
19
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
20
|
+
it('[ISC1] removing absent items is a no-op', () => {
|
|
21
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
|
|
22
|
+
constructor.remove('c')
|
|
23
|
+
constructor.remove('d')
|
|
24
|
+
expect(constructor.get()).toBeUndefined()
|
|
25
|
+
})
|
|
34
26
|
|
|
35
|
-
|
|
36
|
-
|
|
27
|
+
it('[ISC1] add/remove round trips cancel out', () => {
|
|
28
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
29
|
+
constructor.add('d')
|
|
30
|
+
constructor.remove('d')
|
|
31
|
+
expect(constructor.get()).toBeUndefined()
|
|
37
32
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})
|
|
33
|
+
const constructor2 = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
34
|
+
constructor2.remove('a')
|
|
35
|
+
constructor2.add('a')
|
|
36
|
+
expect(constructor2.get()).toBeUndefined()
|
|
37
|
+
})
|
|
44
38
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
it('[ISC2] additions produce the new set and an added diff', () => {
|
|
40
|
+
const original = new Set(['a', 'b'])
|
|
41
|
+
const constructor = new IncrementalSetConstructor(original)
|
|
48
42
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
constructor.add('e')
|
|
43
|
+
constructor.add('c')
|
|
44
|
+
constructor.add('d')
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
expect(result!.diff.added).toEqual(new Set(['d', 'e']))
|
|
57
|
-
expect(result!.diff.removed).toEqual(new Set(['a']))
|
|
46
|
+
expect(constructor.get()).toEqual({
|
|
47
|
+
value: new Set(['a', 'b', 'c', 'd']),
|
|
48
|
+
diff: { added: new Set(['c', 'd']) },
|
|
58
49
|
})
|
|
50
|
+
expect(original).toEqual(new Set(['a', 'b']))
|
|
51
|
+
})
|
|
59
52
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
53
|
+
it('[ISC2] removals produce the new set and a removed diff', () => {
|
|
54
|
+
const original = new Set(['a', 'b', 'c', 'd'])
|
|
55
|
+
const constructor = new IncrementalSetConstructor(original)
|
|
63
56
|
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
constructor.remove('c')
|
|
58
|
+
constructor.remove('d')
|
|
66
59
|
|
|
67
|
-
|
|
68
|
-
|
|
60
|
+
expect(constructor.get()).toEqual({
|
|
61
|
+
value: new Set(['a', 'b']),
|
|
62
|
+
diff: { removed: new Set(['c', 'd']) },
|
|
69
63
|
})
|
|
64
|
+
expect(original).toEqual(new Set(['a', 'b', 'c', 'd']))
|
|
65
|
+
})
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
const constructor = new IncrementalSetConstructor(originalSet)
|
|
67
|
+
it('[ISC2] mixed adds and removes report both sides of the diff', () => {
|
|
68
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
74
69
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
constructor.add('a') // Restore one removed item
|
|
70
|
+
constructor.remove('a')
|
|
71
|
+
constructor.add('d')
|
|
72
|
+
constructor.add('e')
|
|
79
73
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
expect(result!.diff.removed).toEqual(new Set(['b']))
|
|
74
|
+
expect(constructor.get()).toEqual({
|
|
75
|
+
value: new Set(['b', 'c', 'd', 'e']),
|
|
76
|
+
diff: { added: new Set(['d', 'e']), removed: new Set(['a']) },
|
|
84
77
|
})
|
|
78
|
+
})
|
|
85
79
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const constructor = new IncrementalSetConstructor(originalSet)
|
|
80
|
+
it('[ISC3] re-adding a removed item restores it', () => {
|
|
81
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
|
|
89
82
|
|
|
90
|
-
|
|
91
|
-
|
|
83
|
+
constructor.remove('a')
|
|
84
|
+
constructor.remove('b')
|
|
85
|
+
constructor.add('d')
|
|
86
|
+
constructor.add('a') // restore one removed item
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
88
|
+
expect(constructor.get()).toEqual({
|
|
89
|
+
value: new Set(['a', 'c', 'd']),
|
|
90
|
+
diff: { added: new Set(['d']), removed: new Set(['b']) },
|
|
95
91
|
})
|
|
92
|
+
})
|
|
96
93
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const constructor = new IncrementalSetConstructor(originalSet)
|
|
94
|
+
it('[ISC3] removing an item added earlier cancels the add', () => {
|
|
95
|
+
const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
|
|
100
96
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
97
|
+
constructor.add('c')
|
|
98
|
+
constructor.add('d')
|
|
99
|
+
constructor.remove('c')
|
|
104
100
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
expect(result!.diff.removed).toBeUndefined()
|
|
101
|
+
expect(constructor.get()).toEqual({
|
|
102
|
+
value: new Set(['a', 'b', 'd']),
|
|
103
|
+
diff: { added: new Set(['d']) },
|
|
109
104
|
})
|
|
110
105
|
})
|
|
111
106
|
})
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { BaseRecord, RecordId } from './BaseRecord'
|
|
3
|
+
import { assertIdType, createRecordType, RecordType } from './RecordType'
|
|
4
|
+
|
|
5
|
+
// Tests for SPEC.md §2 (records) and §3 (RecordType).
|
|
6
|
+
// Rule IDs like [RT2] in test names refer to that document.
|
|
7
|
+
|
|
8
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
9
|
+
name: string
|
|
10
|
+
living: boolean
|
|
11
|
+
age: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
15
|
+
title: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const Author = createRecordType<Author>('author', { scope: 'document' }).withDefaultProperties(
|
|
19
|
+
() => ({ living: true, age: 35 })
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const Book = createRecordType<Book>('book', { scope: 'document' })
|
|
23
|
+
|
|
24
|
+
describe('creating record types (RT)', () => {
|
|
25
|
+
it('[RT1] createRecordType uses the given scope and defaults to pass-through validation', () => {
|
|
26
|
+
const Session = createRecordType('session', { scope: 'session' })
|
|
27
|
+
expect(Session.scope).toBe('session')
|
|
28
|
+
|
|
29
|
+
const anything = { id: 'session:1', typeName: 'session', whatever: NaN }
|
|
30
|
+
expect(Session.validator.validate(anything)).toBe(anything)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('[RT1] the RecordType constructor defaults scope to document', () => {
|
|
34
|
+
const type = new RecordType<Book, never>('book', {
|
|
35
|
+
createDefaultProperties: () => ({}) as any,
|
|
36
|
+
})
|
|
37
|
+
expect(type.scope).toBe('document')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('[RT1] createRecordType uses the given validator', () => {
|
|
41
|
+
const validator = {
|
|
42
|
+
validate: (r: unknown) => {
|
|
43
|
+
if ((r as Book).title === 'invalid') throw new Error('bad')
|
|
44
|
+
return r as Book
|
|
45
|
+
},
|
|
46
|
+
}
|
|
47
|
+
const Strict = createRecordType<Book>('book', { scope: 'document', validator })
|
|
48
|
+
expect(() => Strict.validate({ title: 'invalid' })).toThrow('bad')
|
|
49
|
+
const ok = { id: 'book:1', typeName: 'book', title: 'fine' }
|
|
50
|
+
expect(Strict.validate(ok)).toBe(ok)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('[RT9] ephemeralKeySet contains exactly the keys mapped to true', () => {
|
|
54
|
+
const WithEphemeral = createRecordType<Author>('author', {
|
|
55
|
+
scope: 'document',
|
|
56
|
+
ephemeralKeys: { name: false, living: true, age: true },
|
|
57
|
+
})
|
|
58
|
+
expect(WithEphemeral.ephemeralKeySet).toEqual(new Set(['living', 'age']))
|
|
59
|
+
|
|
60
|
+
expect(Author.ephemeralKeySet).toEqual(new Set())
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('creating records (RT)', () => {
|
|
65
|
+
it('[RT2] create merges defaults with the given props', () => {
|
|
66
|
+
const author = Author.create({ name: 'Le Guin' })
|
|
67
|
+
expect(author).toEqual({
|
|
68
|
+
id: author.id,
|
|
69
|
+
typeName: 'author',
|
|
70
|
+
name: 'Le Guin',
|
|
71
|
+
living: true,
|
|
72
|
+
age: 35,
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const dead = Author.create({ name: 'Tolstoy', living: false })
|
|
76
|
+
expect(dead.living).toBe(false)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('[RT2] undefined prop values do not override defaults', () => {
|
|
80
|
+
const author = Author.create({ name: 'Le Guin', living: undefined })
|
|
81
|
+
expect(author.living).toBe(true)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('[RT3] create uses the given id, else generates one', () => {
|
|
85
|
+
const id = Author.createId('ursula')
|
|
86
|
+
expect(Author.create({ name: 'Le Guin', id }).id).toBe(id)
|
|
87
|
+
|
|
88
|
+
const generated = Author.create({ name: 'Le Guin' })
|
|
89
|
+
expect(generated.id).toMatch(/^author:/)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('[RT3] an explicitly undefined id is treated as absent', () => {
|
|
93
|
+
const author = Author.create({ name: 'Le Guin', id: undefined })
|
|
94
|
+
expect(author.id).toMatch(/^author:/)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('[R2] [RT3] createId returns typeName:uniquePart, honoring a custom part', () => {
|
|
98
|
+
expect(Author.createId('123')).toBe('author:123')
|
|
99
|
+
expect(Book.createId('xyz')).toBe('book:xyz')
|
|
100
|
+
|
|
101
|
+
const a = Author.createId()
|
|
102
|
+
const b = Author.createId()
|
|
103
|
+
expect(a).toMatch(/^author:/)
|
|
104
|
+
expect(a).not.toBe(b)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('[RT4] clone deep-clones the record and assigns a fresh id', () => {
|
|
108
|
+
const Nested = createRecordType<Book & { props: { tags: string[] } }>('book', {
|
|
109
|
+
scope: 'document',
|
|
110
|
+
})
|
|
111
|
+
const original = Nested.create({ title: '1984', props: { tags: ['dystopia'] } })
|
|
112
|
+
const copy = Nested.clone(original)
|
|
113
|
+
|
|
114
|
+
expect(copy.id).not.toBe(original.id)
|
|
115
|
+
expect(copy.title).toBe(original.title)
|
|
116
|
+
expect(copy.props).toEqual(original.props)
|
|
117
|
+
expect(copy.props).not.toBe(original.props)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('ids and type guards (RT)', () => {
|
|
122
|
+
it('[RT5] parseId returns the part after the colon and throws for other types', () => {
|
|
123
|
+
expect(Author.parseId('author:123' as any)).toBe('123')
|
|
124
|
+
expect(Book.parseId('book:xyz' as any)).toBe('xyz')
|
|
125
|
+
|
|
126
|
+
expect(() => Author.parseId('book:123' as any)).toThrow()
|
|
127
|
+
expect(() => Book.parseId('author:xyz' as any)).toThrow()
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('[RT5] isId is true exactly for ids starting with typeName:', () => {
|
|
131
|
+
expect(Author.isId('author:123')).toBe(true)
|
|
132
|
+
expect(Author.isId('book:123')).toBe(false)
|
|
133
|
+
expect(Author.isId('author')).toBe(false)
|
|
134
|
+
expect(Author.isId('authors:123')).toBe(false)
|
|
135
|
+
expect(Author.isId('')).toBe(false)
|
|
136
|
+
expect(Author.isId(undefined)).toBe(false)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('[RT5] isInstance checks the typeName', () => {
|
|
140
|
+
const author = Author.create({ name: 'Le Guin' })
|
|
141
|
+
const book = Book.create({ title: '1984' })
|
|
142
|
+
|
|
143
|
+
expect(Author.isInstance(author)).toBe(true)
|
|
144
|
+
expect(Author.isInstance(book)).toBe(false)
|
|
145
|
+
expect(Author.isInstance(undefined)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('[RT6] assertIdType throws for invalid ids and passes for valid ones', () => {
|
|
149
|
+
expect(() => assertIdType(Author.createId('ok'), Author)).not.toThrow()
|
|
150
|
+
|
|
151
|
+
expect(() => assertIdType(undefined, Author)).toThrow()
|
|
152
|
+
expect(() => assertIdType('', Author)).toThrow()
|
|
153
|
+
expect(() => assertIdType('book:123', Author)).toThrow()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe('extending and validating (RT)', () => {
|
|
158
|
+
it('[RT7] withDefaultProperties keeps the type name, validator, scope, and ephemeral keys', () => {
|
|
159
|
+
const validator = { validate: (r: unknown) => r as Author }
|
|
160
|
+
const base = createRecordType<Author>('author', {
|
|
161
|
+
scope: 'presence',
|
|
162
|
+
validator,
|
|
163
|
+
ephemeralKeys: { name: false, living: false, age: true },
|
|
164
|
+
})
|
|
165
|
+
const extended = base.withDefaultProperties(() => ({ living: false }))
|
|
166
|
+
|
|
167
|
+
expect(extended.typeName).toBe('author')
|
|
168
|
+
expect(extended.scope).toBe('presence')
|
|
169
|
+
expect(extended.validator).toBe(validator)
|
|
170
|
+
expect(extended.ephemeralKeys).toBe(base.ephemeralKeys)
|
|
171
|
+
expect(extended.ephemeralKeySet).toEqual(new Set(['age']))
|
|
172
|
+
|
|
173
|
+
const author = extended.create({ name: 'Woolf', age: 59 })
|
|
174
|
+
expect(author.living).toBe(false)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('[RT8] validate uses validateUsingKnownGoodVersion when a previous record is given', () => {
|
|
178
|
+
const calls: string[] = []
|
|
179
|
+
const Tracked = createRecordType<Book>('book', {
|
|
180
|
+
scope: 'document',
|
|
181
|
+
validator: {
|
|
182
|
+
validate: (r) => {
|
|
183
|
+
calls.push('validate')
|
|
184
|
+
return r as Book
|
|
185
|
+
},
|
|
186
|
+
validateUsingKnownGoodVersion: (_prev, r) => {
|
|
187
|
+
calls.push('validateUsingKnownGoodVersion')
|
|
188
|
+
return r as Book
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
const book = Tracked.create({ title: '1984' })
|
|
193
|
+
|
|
194
|
+
Tracked.validate({ ...book, title: 'Animal Farm' })
|
|
195
|
+
expect(calls).toEqual(['validate'])
|
|
196
|
+
|
|
197
|
+
Tracked.validate({ ...book, title: 'Animal Farm' }, book)
|
|
198
|
+
expect(calls).toEqual(['validate', 'validateUsingKnownGoodVersion'])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('[RT8] validate falls back to validate when validateUsingKnownGoodVersion is absent', () => {
|
|
202
|
+
const calls: string[] = []
|
|
203
|
+
const Plain = createRecordType<Book>('book', {
|
|
204
|
+
scope: 'document',
|
|
205
|
+
validator: {
|
|
206
|
+
validate: (r) => {
|
|
207
|
+
calls.push('validate')
|
|
208
|
+
return r as Book
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
})
|
|
212
|
+
const book = Plain.create({ title: '1984' })
|
|
213
|
+
Plain.validate({ ...book }, book)
|
|
214
|
+
expect(calls).toEqual(['validate'])
|
|
215
|
+
})
|
|
216
|
+
})
|
package/src/lib/RecordType.ts
CHANGED
|
@@ -135,7 +135,7 @@ export class RecordType<
|
|
|
135
135
|
): R {
|
|
136
136
|
const result = {
|
|
137
137
|
...this.createDefaultProperties(),
|
|
138
|
-
id:
|
|
138
|
+
id: (properties as Partial<R>).id ?? this.createId(),
|
|
139
139
|
} as any
|
|
140
140
|
|
|
141
141
|
for (const [k, v] of Object.entries(properties)) {
|