@tldraw/store 4.1.0-canary.e653ec63c99b → 4.1.0-canary.e87046ba1a0c

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 (72) hide show
  1. package/dist-cjs/index.d.ts +1884 -153
  2. package/dist-cjs/index.js +1 -1
  3. package/dist-cjs/lib/AtomMap.js +241 -1
  4. package/dist-cjs/lib/AtomMap.js.map +2 -2
  5. package/dist-cjs/lib/BaseRecord.js.map +2 -2
  6. package/dist-cjs/lib/ImmutableMap.js +141 -0
  7. package/dist-cjs/lib/ImmutableMap.js.map +2 -2
  8. package/dist-cjs/lib/IncrementalSetConstructor.js +45 -5
  9. package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
  10. package/dist-cjs/lib/RecordType.js +116 -21
  11. package/dist-cjs/lib/RecordType.js.map +2 -2
  12. package/dist-cjs/lib/RecordsDiff.js.map +2 -2
  13. package/dist-cjs/lib/Store.js +233 -39
  14. package/dist-cjs/lib/Store.js.map +2 -2
  15. package/dist-cjs/lib/StoreQueries.js +135 -22
  16. package/dist-cjs/lib/StoreQueries.js.map +2 -2
  17. package/dist-cjs/lib/StoreSchema.js +207 -2
  18. package/dist-cjs/lib/StoreSchema.js.map +2 -2
  19. package/dist-cjs/lib/StoreSideEffects.js +102 -10
  20. package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
  21. package/dist-cjs/lib/executeQuery.js.map +2 -2
  22. package/dist-cjs/lib/migrate.js.map +2 -2
  23. package/dist-cjs/lib/setUtils.js.map +2 -2
  24. package/dist-esm/index.d.mts +1884 -153
  25. package/dist-esm/index.mjs +1 -1
  26. package/dist-esm/lib/AtomMap.mjs +241 -1
  27. package/dist-esm/lib/AtomMap.mjs.map +2 -2
  28. package/dist-esm/lib/BaseRecord.mjs.map +2 -2
  29. package/dist-esm/lib/ImmutableMap.mjs +141 -0
  30. package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
  31. package/dist-esm/lib/IncrementalSetConstructor.mjs +45 -5
  32. package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
  33. package/dist-esm/lib/RecordType.mjs +116 -21
  34. package/dist-esm/lib/RecordType.mjs.map +2 -2
  35. package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
  36. package/dist-esm/lib/Store.mjs +233 -39
  37. package/dist-esm/lib/Store.mjs.map +2 -2
  38. package/dist-esm/lib/StoreQueries.mjs +135 -22
  39. package/dist-esm/lib/StoreQueries.mjs.map +2 -2
  40. package/dist-esm/lib/StoreSchema.mjs +207 -2
  41. package/dist-esm/lib/StoreSchema.mjs.map +2 -2
  42. package/dist-esm/lib/StoreSideEffects.mjs +102 -10
  43. package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
  44. package/dist-esm/lib/executeQuery.mjs.map +2 -2
  45. package/dist-esm/lib/migrate.mjs.map +2 -2
  46. package/dist-esm/lib/setUtils.mjs.map +2 -2
  47. package/package.json +3 -3
  48. package/src/lib/AtomMap.ts +241 -1
  49. package/src/lib/BaseRecord.test.ts +44 -0
  50. package/src/lib/BaseRecord.ts +118 -4
  51. package/src/lib/ImmutableMap.test.ts +103 -0
  52. package/src/lib/ImmutableMap.ts +212 -0
  53. package/src/lib/IncrementalSetConstructor.test.ts +111 -0
  54. package/src/lib/IncrementalSetConstructor.ts +63 -6
  55. package/src/lib/RecordType.ts +149 -25
  56. package/src/lib/RecordsDiff.test.ts +144 -0
  57. package/src/lib/RecordsDiff.ts +145 -10
  58. package/src/lib/Store.test.ts +827 -0
  59. package/src/lib/Store.ts +533 -67
  60. package/src/lib/StoreQueries.test.ts +627 -0
  61. package/src/lib/StoreQueries.ts +194 -27
  62. package/src/lib/StoreSchema.test.ts +226 -0
  63. package/src/lib/StoreSchema.ts +386 -8
  64. package/src/lib/StoreSideEffects.test.ts +239 -19
  65. package/src/lib/StoreSideEffects.ts +266 -19
  66. package/src/lib/devFreeze.test.ts +137 -0
  67. package/src/lib/executeQuery.test.ts +481 -0
  68. package/src/lib/executeQuery.ts +80 -2
  69. package/src/lib/migrate.test.ts +400 -0
  70. package/src/lib/migrate.ts +187 -14
  71. package/src/lib/setUtils.test.ts +105 -0
  72. package/src/lib/setUtils.ts +44 -4
@@ -2,16 +2,69 @@ import { IdOf, UnknownRecord } from './BaseRecord'
2
2
  import { intersectSets } from './setUtils'
3
3
  import { StoreQueries } from './StoreQueries'
4
4
 
5
- /** @public */
5
+ /**
6
+ * Defines matching criteria for query values. Supports equality, inequality, and greater-than comparisons.
7
+ *
8
+ * @example
9
+ * ```ts
10
+ * // Exact match
11
+ * const exactMatch: QueryValueMatcher<string> = { eq: 'Science Fiction' }
12
+ *
13
+ * // Not equal to
14
+ * const notMatch: QueryValueMatcher<string> = { neq: 'Romance' }
15
+ *
16
+ * // Greater than (numeric values only)
17
+ * const greaterThan: QueryValueMatcher<number> = { gt: 2020 }
18
+ * ```
19
+ *
20
+ * @public
21
+ */
6
22
  export type QueryValueMatcher<T> = { eq: T } | { neq: T } | { gt: number }
7
23
 
8
- /** @public */
24
+ /**
25
+ * Query expression for filtering records by their property values. Maps record property names
26
+ * to matching criteria.
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * // Query for books published after 2020 that are in stock
31
+ * const bookQuery: QueryExpression<Book> = {
32
+ * publishedYear: { gt: 2020 },
33
+ * inStock: { eq: true }
34
+ * }
35
+ *
36
+ * // Query for books not by a specific author
37
+ * const notByAuthor: QueryExpression<Book> = {
38
+ * authorId: { neq: 'author:tolkien' }
39
+ * }
40
+ * ```
41
+ *
42
+ * @public
43
+ */
9
44
  export type QueryExpression<R extends object> = {
10
45
  [k in keyof R & string]?: QueryValueMatcher<R[k]>
11
46
  // todo: handle nesting
12
47
  // | (R[k] extends object ? { match: QueryExpression<R[k]> } : never)
13
48
  }
14
49
 
50
+ /**
51
+ * Tests whether an object matches the given query expression by checking each property
52
+ * against its corresponding matcher criteria.
53
+ *
54
+ * @param query - The query expression containing matching criteria for object properties
55
+ * @param object - The object to test against the query
56
+ * @returns True if the object matches all criteria in the query, false otherwise
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * const book = { title: '1984', publishedYear: 1949, inStock: true }
61
+ * const query = { publishedYear: { gt: 1945 }, inStock: { eq: true } }
62
+ *
63
+ * const matches = objectMatchesQuery(query, book) // true
64
+ * ```
65
+ *
66
+ * @public
67
+ */
15
68
  export function objectMatchesQuery<T extends object>(query: QueryExpression<T>, object: T) {
16
69
  for (const [key, _matcher] of Object.entries(query)) {
17
70
  const matcher = _matcher as QueryValueMatcher<T>
@@ -26,6 +79,31 @@ export function objectMatchesQuery<T extends object>(query: QueryExpression<T>,
26
79
  return true
27
80
  }
28
81
 
82
+ /**
83
+ * Executes a query against the store using reactive indexes to efficiently find matching record IDs.
84
+ * Uses the store's internal indexes for optimal performance, especially for equality matches.
85
+ *
86
+ * @param store - The store queries interface providing access to reactive indexes
87
+ * @param typeName - The type name of records to query (e.g., 'book', 'author')
88
+ * @param query - Query expression defining the matching criteria
89
+ * @returns A Set containing the IDs of all records that match the query criteria
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * // Find IDs of all books published after 2020 that are in stock
94
+ * const bookIds = executeQuery(store, 'book', {
95
+ * publishedYear: { gt: 2020 },
96
+ * inStock: { eq: true }
97
+ * })
98
+ *
99
+ * // Find IDs of books not by a specific author
100
+ * const otherBookIds = executeQuery(store, 'book', {
101
+ * authorId: { neq: 'author:tolkien' }
102
+ * })
103
+ * ```
104
+ *
105
+ * @public
106
+ */
29
107
  export function executeQuery<R extends UnknownRecord, TypeName extends R['typeName']>(
30
108
  store: StoreQueries<R>,
31
109
  typeName: TypeName,
@@ -0,0 +1,400 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { UnknownRecord } from './BaseRecord'
3
+ import { SerializedStore } from './Store'
4
+ import {
5
+ type Migration,
6
+ type MigrationId,
7
+ type MigrationSequence,
8
+ type StandaloneDependsOn,
9
+ createMigrationIds,
10
+ createMigrationSequence,
11
+ createRecordMigrationSequence,
12
+ parseMigrationId,
13
+ sortMigrations,
14
+ validateMigrations,
15
+ } from './migrate'
16
+
17
+ describe('createMigrationIds', () => {
18
+ it('creates properly formatted migration IDs', () => {
19
+ const ids = createMigrationIds('com.myapp.book', {
20
+ addGenre: 1,
21
+ addPublisher: 2,
22
+ removeOldField: 3,
23
+ })
24
+
25
+ expect(ids).toEqual({
26
+ addGenre: 'com.myapp.book/1',
27
+ addPublisher: 'com.myapp.book/2',
28
+ removeOldField: 'com.myapp.book/3',
29
+ })
30
+ })
31
+ })
32
+
33
+ describe('parseMigrationId', () => {
34
+ it('parses migration IDs correctly', () => {
35
+ expect(parseMigrationId('com.myapp.book/5' as MigrationId)).toEqual({
36
+ sequenceId: 'com.myapp.book',
37
+ version: 5,
38
+ })
39
+ expect(parseMigrationId('test/1' as MigrationId)).toEqual({
40
+ sequenceId: 'test',
41
+ version: 1,
42
+ })
43
+ expect(parseMigrationId('com.example.app/42' as MigrationId)).toEqual({
44
+ sequenceId: 'com.example.app',
45
+ version: 42,
46
+ })
47
+ })
48
+ })
49
+
50
+ describe('createMigrationSequence', () => {
51
+ it('creates and validates migration sequences', () => {
52
+ const migration: Migration = {
53
+ id: 'test/1' as MigrationId,
54
+ scope: 'record',
55
+ up: (record: UnknownRecord) => ({ ...record, newField: 'default' }),
56
+ }
57
+
58
+ const sequence = createMigrationSequence({
59
+ sequenceId: 'test',
60
+ sequence: [migration],
61
+ retroactive: false,
62
+ })
63
+
64
+ expect(sequence.sequenceId).toBe('test')
65
+ expect(sequence.retroactive).toBe(false)
66
+ expect(sequence.sequence).toHaveLength(1)
67
+ })
68
+
69
+ it('squashes standalone dependsOn entries', () => {
70
+ const dependsOn: StandaloneDependsOn = {
71
+ dependsOn: ['other/1' as MigrationId, 'another/2' as MigrationId],
72
+ }
73
+
74
+ const migration: Migration = {
75
+ id: 'test/1' as MigrationId,
76
+ scope: 'record',
77
+ up: (record: UnknownRecord) => record,
78
+ dependsOn: ['existing/1' as MigrationId],
79
+ }
80
+
81
+ const sequence = createMigrationSequence({
82
+ sequenceId: 'test',
83
+ sequence: [dependsOn, migration],
84
+ })
85
+
86
+ expect(sequence.sequence).toHaveLength(1)
87
+ expect(sequence.sequence[0].dependsOn).toEqual(['other/1', 'another/2', 'existing/1'])
88
+ })
89
+
90
+ it('validates the migration sequence', () => {
91
+ expect(() => {
92
+ createMigrationSequence({
93
+ sequenceId: 'test/invalid',
94
+ sequence: [],
95
+ })
96
+ }).toThrow()
97
+ })
98
+ })
99
+
100
+ describe('createRecordMigrationSequence', () => {
101
+ it('creates record-scoped migrations with type filtering', () => {
102
+ const sequence = createRecordMigrationSequence({
103
+ recordType: 'book',
104
+ sequenceId: 'com.myapp.book',
105
+ sequence: [
106
+ {
107
+ id: 'com.myapp.book/1' as MigrationId,
108
+ up: (record: UnknownRecord) => ({ ...record, newField: 'value' }),
109
+ },
110
+ ],
111
+ })
112
+
113
+ expect(sequence.sequenceId).toBe('com.myapp.book')
114
+ expect(sequence.sequence).toHaveLength(1)
115
+ expect(sequence.sequence[0].scope).toBe('record')
116
+
117
+ // Test the filter function
118
+ const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
119
+ const bookRecord = { id: 'book-1' as any, typeName: 'book' } as any as UnknownRecord
120
+ const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
121
+
122
+ expect(migration.filter?.(bookRecord)).toBe(true)
123
+ expect(migration.filter?.(userRecord)).toBe(false)
124
+ })
125
+
126
+ it('combines record type filter with custom filter', () => {
127
+ const sequence = createRecordMigrationSequence({
128
+ recordType: 'shape',
129
+ filter: (record) => (record as any).shapeType === 'rectangle',
130
+ sequenceId: 'com.myapp.shape',
131
+ sequence: [
132
+ {
133
+ id: 'com.myapp.shape/1' as MigrationId,
134
+ up: (record: UnknownRecord) => record,
135
+ },
136
+ ],
137
+ })
138
+
139
+ const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
140
+ const rectangleShape = {
141
+ id: 'shape-1' as any,
142
+ typeName: 'shape',
143
+ shapeType: 'rectangle',
144
+ } as any as UnknownRecord
145
+ const circleShape = {
146
+ id: 'shape-2' as any,
147
+ typeName: 'shape',
148
+ shapeType: 'circle',
149
+ } as any as UnknownRecord
150
+ const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
151
+
152
+ expect(migration.filter?.(rectangleShape)).toBe(true)
153
+ expect(migration.filter?.(circleShape)).toBe(false)
154
+ expect(migration.filter?.(userRecord)).toBe(false)
155
+ })
156
+ })
157
+
158
+ describe('validateMigrations', () => {
159
+ it('validates sequential migration versions', () => {
160
+ const sequence: MigrationSequence = {
161
+ sequenceId: 'test',
162
+ retroactive: true,
163
+ sequence: [
164
+ {
165
+ id: 'test/1' as MigrationId,
166
+ scope: 'record',
167
+ up: (record: UnknownRecord) => record,
168
+ },
169
+ {
170
+ id: 'test/2' as MigrationId,
171
+ scope: 'record',
172
+ up: (record: UnknownRecord) => record,
173
+ },
174
+ {
175
+ id: 'test/3' as MigrationId,
176
+ scope: 'store',
177
+ up: (store: SerializedStore<UnknownRecord>) => store,
178
+ },
179
+ ],
180
+ }
181
+
182
+ expect(() => validateMigrations(sequence)).not.toThrow()
183
+ })
184
+
185
+ it('throws on sequence ID with slash', () => {
186
+ const sequence: MigrationSequence = {
187
+ sequenceId: 'test/invalid',
188
+ retroactive: true,
189
+ sequence: [],
190
+ }
191
+
192
+ expect(() => validateMigrations(sequence)).toThrow(
193
+ "sequenceId cannot contain a '/', got test/invalid"
194
+ )
195
+ })
196
+
197
+ it('throws on invalid migration ID format', () => {
198
+ const sequence: MigrationSequence = {
199
+ sequenceId: 'test',
200
+ retroactive: true,
201
+ sequence: [
202
+ {
203
+ id: 'invalid-id' as MigrationId,
204
+ scope: 'record',
205
+ up: (record: UnknownRecord) => record,
206
+ },
207
+ ],
208
+ }
209
+
210
+ expect(() => validateMigrations(sequence)).toThrow(
211
+ "Every migration in sequence 'test' must have an id starting with 'test/'. Got invalid id: 'invalid-id'"
212
+ )
213
+ })
214
+
215
+ it('throws on first migration not starting at version 1', () => {
216
+ const sequence: MigrationSequence = {
217
+ sequenceId: 'test',
218
+ retroactive: true,
219
+ sequence: [
220
+ {
221
+ id: 'test/5' as MigrationId,
222
+ scope: 'record',
223
+ up: (record: UnknownRecord) => record,
224
+ },
225
+ ],
226
+ }
227
+
228
+ expect(() => validateMigrations(sequence)).toThrow(
229
+ "Expected the first migrationId to be 'test/1' but got 'test/5'"
230
+ )
231
+ })
232
+
233
+ it('throws on non-sequential migration versions', () => {
234
+ const sequence: MigrationSequence = {
235
+ sequenceId: 'test',
236
+ retroactive: true,
237
+ sequence: [
238
+ {
239
+ id: 'test/1' as MigrationId,
240
+ scope: 'record',
241
+ up: (record: UnknownRecord) => record,
242
+ },
243
+ {
244
+ id: 'test/3' as MigrationId,
245
+ scope: 'record',
246
+ up: (record: UnknownRecord) => record,
247
+ },
248
+ ],
249
+ }
250
+
251
+ expect(() => validateMigrations(sequence)).toThrow(
252
+ "Migration id numbers must increase in increments of 1, expected test/2 but got 'test/3'"
253
+ )
254
+ })
255
+ })
256
+
257
+ describe('sortMigrations', () => {
258
+ it('sorts migrations by sequence order', () => {
259
+ const migration1: Migration = {
260
+ id: 'app/1' as MigrationId,
261
+ scope: 'record',
262
+ up: (record: UnknownRecord) => record,
263
+ }
264
+ const migration2: Migration = {
265
+ id: 'app/2' as MigrationId,
266
+ scope: 'record',
267
+ up: (record: UnknownRecord) => record,
268
+ }
269
+
270
+ expect(sortMigrations([migration2, migration1])).toEqual([migration1, migration2])
271
+ })
272
+
273
+ it('respects explicit dependencies', () => {
274
+ const libMigration: Migration = {
275
+ id: 'lib/1' as MigrationId,
276
+ scope: 'record',
277
+ up: (record: UnknownRecord) => record,
278
+ dependsOn: ['app/1' as MigrationId],
279
+ }
280
+ const appMigration: Migration = {
281
+ id: 'app/1' as MigrationId,
282
+ scope: 'record',
283
+ up: (record: UnknownRecord) => record,
284
+ }
285
+
286
+ const sorted = sortMigrations([libMigration, appMigration])
287
+ expect(sorted).toEqual([appMigration, libMigration])
288
+ })
289
+
290
+ it('handles complex dependency chains', () => {
291
+ const a1: Migration = {
292
+ id: 'a/1' as MigrationId,
293
+ scope: 'record',
294
+ up: (record: UnknownRecord) => record,
295
+ }
296
+ const b1: Migration = {
297
+ id: 'b/1' as MigrationId,
298
+ scope: 'record',
299
+ up: (record: UnknownRecord) => record,
300
+ dependsOn: ['a/1' as MigrationId],
301
+ }
302
+ const c1: Migration = {
303
+ id: 'c/1' as MigrationId,
304
+ scope: 'record',
305
+ up: (record: UnknownRecord) => record,
306
+ dependsOn: ['b/1' as MigrationId],
307
+ }
308
+ const d1: Migration = {
309
+ id: 'd/1' as MigrationId,
310
+ scope: 'record',
311
+ up: (record: UnknownRecord) => record,
312
+ dependsOn: ['a/1' as MigrationId, 'c/1' as MigrationId],
313
+ }
314
+
315
+ const sorted = sortMigrations([d1, c1, b1, a1])
316
+ expect(sorted).toEqual([a1, b1, c1, d1])
317
+ })
318
+
319
+ it('prioritizes explicit dependencies over sequence order', () => {
320
+ const later: Migration = {
321
+ id: 'z/1' as MigrationId,
322
+ scope: 'record',
323
+ up: (record: UnknownRecord) => record,
324
+ }
325
+ const earlier: Migration = {
326
+ id: 'a/1' as MigrationId,
327
+ scope: 'record',
328
+ up: (record: UnknownRecord) => record,
329
+ dependsOn: ['z/1' as MigrationId],
330
+ }
331
+
332
+ const sorted = sortMigrations([earlier, later])
333
+ expect(sorted).toEqual([later, earlier])
334
+ })
335
+
336
+ it('throws on circular dependencies', () => {
337
+ const a: Migration = {
338
+ id: 'a/1' as MigrationId,
339
+ scope: 'record',
340
+ up: (record: UnknownRecord) => record,
341
+ dependsOn: ['b/1' as MigrationId],
342
+ }
343
+ const b: Migration = {
344
+ id: 'b/1' as MigrationId,
345
+ scope: 'record',
346
+ up: (record: UnknownRecord) => record,
347
+ dependsOn: ['a/1' as MigrationId],
348
+ }
349
+
350
+ expect(() => sortMigrations([a, b])).toThrow('Circular dependency in migrations: a/1')
351
+ })
352
+
353
+ it('handles multiple sequences with cross-dependencies', () => {
354
+ const app1: Migration = {
355
+ id: 'app/1' as MigrationId,
356
+ scope: 'record',
357
+ up: (record: UnknownRecord) => record,
358
+ }
359
+ const app2: Migration = {
360
+ id: 'app/2' as MigrationId,
361
+ scope: 'record',
362
+ up: (record: UnknownRecord) => record,
363
+ }
364
+ const lib1: Migration = {
365
+ id: 'lib/1' as MigrationId,
366
+ scope: 'record',
367
+ up: (record: UnknownRecord) => record,
368
+ dependsOn: ['app/1' as MigrationId],
369
+ }
370
+ const lib2: Migration = {
371
+ id: 'lib/2' as MigrationId,
372
+ scope: 'record',
373
+ up: (record: UnknownRecord) => record,
374
+ }
375
+ const plugin1: Migration = {
376
+ id: 'plugin/1' as MigrationId,
377
+ scope: 'record',
378
+ up: (record: UnknownRecord) => record,
379
+ dependsOn: ['app/2' as MigrationId, 'lib/1' as MigrationId],
380
+ }
381
+
382
+ const sorted = sortMigrations([plugin1, lib2, lib1, app2, app1])
383
+
384
+ // Verify constraints are satisfied
385
+ const app1Index = sorted.indexOf(app1)
386
+ const app2Index = sorted.indexOf(app2)
387
+ const lib1Index = sorted.indexOf(lib1)
388
+ const lib2Index = sorted.indexOf(lib2)
389
+ const plugin1Index = sorted.indexOf(plugin1)
390
+
391
+ // Sequence dependencies
392
+ expect(app1Index).toBeLessThan(app2Index)
393
+ expect(lib1Index).toBeLessThan(lib2Index)
394
+
395
+ // Explicit dependencies
396
+ expect(app1Index).toBeLessThan(lib1Index) // lib/1 depends on app/1
397
+ expect(app2Index).toBeLessThan(plugin1Index) // plugin/1 depends on app/2
398
+ expect(lib1Index).toBeLessThan(plugin1Index) // plugin/1 depends on lib/1
399
+ })
400
+ })