@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.
Files changed (64) hide show
  1. package/DOCS.md +790 -0
  2. package/README.md +9 -1
  3. package/dist-cjs/index.js +1 -1
  4. package/dist-cjs/lib/ImmutableMap.js +0 -13
  5. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  6. package/dist-cjs/lib/RecordType.js +1 -1
  7. package/dist-cjs/lib/RecordType.js.map +2 -2
  8. package/dist-cjs/lib/RecordsDiff.js +26 -14
  9. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  10. package/dist-cjs/lib/Store.js +6 -5
  11. package/dist-cjs/lib/Store.js.map +2 -2
  12. package/dist-cjs/lib/StoreSchema.js +1 -1
  13. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  14. package/dist-cjs/lib/executeQuery.js +1 -1
  15. package/dist-cjs/lib/executeQuery.js.map +2 -2
  16. package/dist-esm/index.mjs +1 -1
  17. package/dist-esm/lib/ImmutableMap.mjs +0 -13
  18. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  19. package/dist-esm/lib/RecordType.mjs +1 -1
  20. package/dist-esm/lib/RecordType.mjs.map +2 -2
  21. package/dist-esm/lib/RecordsDiff.mjs +26 -14
  22. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  23. package/dist-esm/lib/Store.mjs +7 -6
  24. package/dist-esm/lib/Store.mjs.map +2 -2
  25. package/dist-esm/lib/StoreSchema.mjs +1 -1
  26. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  27. package/dist-esm/lib/executeQuery.mjs +1 -1
  28. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  29. package/package.json +9 -5
  30. package/src/lib/{test/AtomMap.test.ts → AtomMap.test.ts} +77 -111
  31. package/src/lib/AtomSet.test.ts +116 -0
  32. package/src/lib/BaseRecord.test.ts +10 -22
  33. package/src/lib/ImmutableMap.test.ts +114 -71
  34. package/src/lib/ImmutableMap.ts +1 -0
  35. package/src/lib/IncrementalSetConstructor.test.ts +76 -81
  36. package/src/lib/RecordType.test.ts +216 -0
  37. package/src/lib/RecordType.ts +1 -1
  38. package/src/lib/RecordsDiff.test.ts +112 -106
  39. package/src/lib/RecordsDiff.ts +43 -18
  40. package/src/lib/Store.test.ts +570 -630
  41. package/src/lib/Store.ts +9 -10
  42. package/src/lib/StoreListeners.test.ts +462 -0
  43. package/src/lib/StoreQueries.test.ts +586 -434
  44. package/src/lib/StoreSchema.test.ts +1012 -174
  45. package/src/lib/StoreSchema.ts +1 -1
  46. package/src/lib/StoreSideEffects.test.ts +546 -158
  47. package/src/lib/devFreeze.test.ts +94 -124
  48. package/src/lib/executeQuery.test.ts +77 -31
  49. package/src/lib/executeQuery.ts +3 -1
  50. package/src/lib/migrate.test.ts +273 -296
  51. package/src/lib/setUtils.test.ts +38 -79
  52. package/src/lib/test/createMigrations.test.ts +0 -75
  53. package/src/lib/test/dependsOn.test.ts +0 -166
  54. package/src/lib/test/getMigrationsSince.test.ts +0 -121
  55. package/src/lib/test/migrate.test.ts +0 -118
  56. package/src/lib/test/migratePersistedRecord.test.ts +0 -265
  57. package/src/lib/test/migrationCaching.test.ts +0 -209
  58. package/src/lib/test/recordStore.test.ts +0 -1567
  59. package/src/lib/test/recordStoreQueries.test.ts +0 -814
  60. package/src/lib/test/recordType.test.ts +0 -19
  61. package/src/lib/test/sortMigrations.test.ts +0 -83
  62. package/src/lib/test/upgradeSchema.test.ts +0 -80
  63. package/src/lib/test/validate.test.ts +0 -178
  64. 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
- describe('ImmutableMap', () => {
5
- describe('constructor', () => {
6
- it('should handle duplicate keys by keeping last value', () => {
7
- const pairs: Array<[string, number]> = [
8
- ['a', 1],
9
- ['a', 2],
10
- ['a', 3],
11
- ]
12
- const map = new ImmutableMap(pairs)
13
- expect(map.size).toBe(1)
14
- expect(map.get('a')).toBe(3)
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
- describe('get', () => {
19
- it('should handle object keys', () => {
20
- const objKey = { id: 1 }
21
- const map = new ImmutableMap([[objKey, 'object']])
22
- expect(map.get(objKey)).toBe('object')
23
- expect(map.get({ id: 1 })).toBeUndefined() // Different object
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
- describe('set', () => {
28
- it('should create new map with updated value', () => {
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
- expect(newMap.get('key')).toBe('newValue')
33
- expect(map.get('key')).toBe('value') // Original unchanged
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
- it('should handle different object keys correctly', () => {
37
- const obj1 = { id: 1 }
38
- const obj2 = { id: 2 }
44
+ it('[IM3] object keys are hashed by identity', () => {
45
+ const obj1 = { id: 1 }
46
+ const obj2 = { id: 2 }
39
47
 
40
- let map = new ImmutableMap()
41
- map = map.set(obj1, 'first')
42
- map = map.set(obj2, 'second')
48
+ let map = new ImmutableMap<{ id: number }, string>()
49
+ map = map.set(obj1, 'first')
50
+ map = map.set(obj2, 'second')
43
51
 
44
- expect(map.size).toBe(2)
45
- expect(map.get(obj1)).toBe('first')
46
- expect(map.get(obj2)).toBe('second')
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
- describe('delete', () => {
51
- it('should remove existing keys', () => {
52
- const map = new ImmutableMap([
53
- ['a', 1],
54
- ['b', 2],
55
- ])
56
- const newMap = map.delete('a')
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
- expect(newMap.size).toBe(1)
59
- expect(newMap.get('a')).toBeUndefined()
60
- expect(newMap.get('b')).toBe(2)
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
- describe('deleteAll', () => {
65
- it('should remove multiple keys', () => {
66
- const map = new ImmutableMap([
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
- const newMap = map.deleteAll(['a', 'c'])
120
+ )
72
121
 
73
- expect(newMap.size).toBe(1)
74
- expect(newMap.get('a')).toBeUndefined()
75
- expect(newMap.get('b')).toBe(2)
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
- describe('withMutations', () => {
81
- it('should allow efficient batch operations', () => {
82
- const map = new ImmutableMap([['a', 1]])
83
- const newMap = map.withMutations((mutable) => {
84
- mutable.set('b', 2)
85
- mutable.set('c', 3)
86
- mutable.delete('a')
87
- })
88
-
89
- expect(newMap.size).toBe(2)
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('should create empty map with zero size', () => {
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()
@@ -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
- describe('IncrementalSetConstructor', () => {
5
- describe('core functionality', () => {
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
- constructor.add('d')
11
- constructor.remove('d')
12
-
13
- const result = constructor.get()
14
- expect(result).toBeUndefined()
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
- const result = constructor.get()
25
- expect(result).toBeDefined()
26
- expect(result!.value).toEqual(new Set(['a', 'b', 'c', 'd']))
27
- expect(result!.diff.added).toEqual(new Set(['c', 'd']))
28
- expect(result!.diff.removed).toBeUndefined()
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
- it('should return correct result when items are removed', () => {
32
- const originalSet = new Set(['a', 'b', 'c', 'd'])
33
- const constructor = new IncrementalSetConstructor(originalSet)
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
- constructor.remove('c')
36
- constructor.remove('d')
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
- const result = constructor.get()
39
- expect(result).toBeDefined()
40
- expect(result!.value).toEqual(new Set(['a', 'b']))
41
- expect(result!.diff.removed).toEqual(new Set(['c', 'd']))
42
- expect(result!.diff.added).toBeUndefined()
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
- it('should handle mixed add and remove operations correctly', () => {
46
- const originalSet = new Set(['a', 'b', 'c'])
47
- const constructor = new IncrementalSetConstructor(originalSet)
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
- constructor.remove('a')
50
- constructor.add('d')
51
- constructor.add('e')
43
+ constructor.add('c')
44
+ constructor.add('d')
52
45
 
53
- const result = constructor.get()
54
- expect(result).toBeDefined()
55
- expect(result!.value).toEqual(new Set(['b', 'c', 'd', 'e']))
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
- it('should handle adding existing items as no-op', () => {
61
- const originalSet = new Set(['a', 'b', 'c'])
62
- const constructor = new IncrementalSetConstructor(originalSet)
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
- constructor.add('a')
65
- constructor.add('b')
57
+ constructor.remove('c')
58
+ constructor.remove('d')
66
59
 
67
- const result = constructor.get()
68
- expect(result).toBeUndefined()
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
- it('should handle complex restore and cancel scenarios', () => {
72
- const originalSet = new Set(['a', 'b', 'c'])
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
- constructor.remove('a')
76
- constructor.remove('b')
77
- constructor.add('d')
78
- constructor.add('a') // Restore one removed item
70
+ constructor.remove('a')
71
+ constructor.add('d')
72
+ constructor.add('e')
79
73
 
80
- const result = constructor.get()
81
- expect(result!.value).toEqual(new Set(['a', 'c', 'd']))
82
- expect(result!.diff.added).toEqual(new Set(['d']))
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
- it('should handle removing non-existent items as no-op', () => {
87
- const originalSet = new Set(['a', 'b'])
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
- constructor.remove('c')
91
- constructor.remove('d')
83
+ constructor.remove('a')
84
+ constructor.remove('b')
85
+ constructor.add('d')
86
+ constructor.add('a') // restore one removed item
92
87
 
93
- const result = constructor.get()
94
- expect(result).toBeUndefined()
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
- it('should remove recently added items correctly', () => {
98
- const originalSet = new Set(['a', 'b'])
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
- constructor.add('c')
102
- constructor.add('d')
103
- constructor.remove('c') // Remove recently added item
97
+ constructor.add('c')
98
+ constructor.add('d')
99
+ constructor.remove('c')
104
100
 
105
- const result = constructor.get()
106
- expect(result!.value).toEqual(new Set(['a', 'b', 'd']))
107
- expect(result!.diff.added).toEqual(new Set(['d']))
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
+ })
@@ -135,7 +135,7 @@ export class RecordType<
135
135
  ): R {
136
136
  const result = {
137
137
  ...this.createDefaultProperties(),
138
- id: 'id' in properties ? properties.id : this.createId(),
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)) {