@tldraw/store 4.1.0-canary.c62140a07605 → 4.1.0-canary.c9992319dc92
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/dist-cjs/index.d.ts +1884 -153
- package/dist-cjs/index.js +1 -1
- package/dist-cjs/lib/AtomMap.js +241 -1
- package/dist-cjs/lib/AtomMap.js.map +2 -2
- package/dist-cjs/lib/BaseRecord.js.map +2 -2
- package/dist-cjs/lib/ImmutableMap.js +141 -0
- package/dist-cjs/lib/ImmutableMap.js.map +2 -2
- package/dist-cjs/lib/IncrementalSetConstructor.js +45 -5
- package/dist-cjs/lib/IncrementalSetConstructor.js.map +2 -2
- package/dist-cjs/lib/RecordType.js +116 -21
- package/dist-cjs/lib/RecordType.js.map +2 -2
- package/dist-cjs/lib/RecordsDiff.js.map +2 -2
- package/dist-cjs/lib/Store.js +233 -39
- package/dist-cjs/lib/Store.js.map +2 -2
- package/dist-cjs/lib/StoreQueries.js +135 -22
- package/dist-cjs/lib/StoreQueries.js.map +2 -2
- package/dist-cjs/lib/StoreSchema.js +207 -2
- package/dist-cjs/lib/StoreSchema.js.map +2 -2
- package/dist-cjs/lib/StoreSideEffects.js +102 -10
- package/dist-cjs/lib/StoreSideEffects.js.map +2 -2
- package/dist-cjs/lib/executeQuery.js.map +2 -2
- package/dist-cjs/lib/migrate.js.map +2 -2
- package/dist-cjs/lib/setUtils.js.map +2 -2
- package/dist-esm/index.d.mts +1884 -153
- package/dist-esm/index.mjs +1 -1
- package/dist-esm/lib/AtomMap.mjs +241 -1
- package/dist-esm/lib/AtomMap.mjs.map +2 -2
- package/dist-esm/lib/BaseRecord.mjs.map +2 -2
- package/dist-esm/lib/ImmutableMap.mjs +141 -0
- package/dist-esm/lib/ImmutableMap.mjs.map +2 -2
- package/dist-esm/lib/IncrementalSetConstructor.mjs +45 -5
- package/dist-esm/lib/IncrementalSetConstructor.mjs.map +2 -2
- package/dist-esm/lib/RecordType.mjs +116 -21
- package/dist-esm/lib/RecordType.mjs.map +2 -2
- package/dist-esm/lib/RecordsDiff.mjs.map +2 -2
- package/dist-esm/lib/Store.mjs +233 -39
- package/dist-esm/lib/Store.mjs.map +2 -2
- package/dist-esm/lib/StoreQueries.mjs +135 -22
- package/dist-esm/lib/StoreQueries.mjs.map +2 -2
- package/dist-esm/lib/StoreSchema.mjs +207 -2
- package/dist-esm/lib/StoreSchema.mjs.map +2 -2
- package/dist-esm/lib/StoreSideEffects.mjs +102 -10
- package/dist-esm/lib/StoreSideEffects.mjs.map +2 -2
- package/dist-esm/lib/executeQuery.mjs.map +2 -2
- package/dist-esm/lib/migrate.mjs.map +2 -2
- package/dist-esm/lib/setUtils.mjs.map +2 -2
- package/package.json +3 -3
- package/src/lib/AtomMap.ts +241 -1
- package/src/lib/BaseRecord.test.ts +44 -0
- package/src/lib/BaseRecord.ts +118 -4
- package/src/lib/ImmutableMap.test.ts +103 -0
- package/src/lib/ImmutableMap.ts +212 -0
- package/src/lib/IncrementalSetConstructor.test.ts +111 -0
- package/src/lib/IncrementalSetConstructor.ts +63 -6
- package/src/lib/RecordType.ts +149 -25
- package/src/lib/RecordsDiff.test.ts +144 -0
- package/src/lib/RecordsDiff.ts +145 -10
- package/src/lib/Store.test.ts +827 -0
- package/src/lib/Store.ts +533 -67
- package/src/lib/StoreQueries.test.ts +627 -0
- package/src/lib/StoreQueries.ts +194 -27
- package/src/lib/StoreSchema.test.ts +226 -0
- package/src/lib/StoreSchema.ts +386 -8
- package/src/lib/StoreSideEffects.test.ts +239 -19
- package/src/lib/StoreSideEffects.ts +266 -19
- package/src/lib/devFreeze.test.ts +137 -0
- package/src/lib/executeQuery.test.ts +481 -0
- package/src/lib/executeQuery.ts +80 -2
- package/src/lib/migrate.test.ts +400 -0
- package/src/lib/migrate.ts +187 -14
- package/src/lib/setUtils.test.ts +105 -0
- package/src/lib/setUtils.ts +44 -4
package/src/lib/StoreQueries.ts
CHANGED
|
@@ -16,30 +16,92 @@ import { RecordsDiff } from './RecordsDiff'
|
|
|
16
16
|
import { diffSets } from './setUtils'
|
|
17
17
|
import { CollectionDiff } from './Store'
|
|
18
18
|
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* A type representing the diff of changes to a reactive store index.
|
|
21
|
+
* Maps property values to the collection differences for record IDs that have that property value.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* ```ts
|
|
25
|
+
* // For an index on book titles, the diff might look like:
|
|
26
|
+
* const titleIndexDiff: RSIndexDiff<Book, 'title'> = new Map([
|
|
27
|
+
* ['The Lathe of Heaven', { added: new Set(['book:1']), removed: new Set() }],
|
|
28
|
+
* ['Animal Farm', { added: new Set(), removed: new Set(['book:2']) }]
|
|
29
|
+
* ])
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* @public
|
|
33
|
+
*/
|
|
20
34
|
export type RSIndexDiff<
|
|
21
35
|
R extends UnknownRecord,
|
|
22
36
|
Property extends string & keyof R = string & keyof R,
|
|
23
37
|
> = Map<R[Property], CollectionDiff<IdOf<R>>>
|
|
24
38
|
|
|
25
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* A type representing a reactive store index as a map from property values to sets of record IDs.
|
|
41
|
+
* This is used to efficiently look up records by a specific property value.
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* // Index mapping book titles to the IDs of books with that title
|
|
46
|
+
* const titleIndex: RSIndexMap<Book, 'title'> = new Map([
|
|
47
|
+
* ['The Lathe of Heaven', new Set(['book:1'])],
|
|
48
|
+
* ['Animal Farm', new Set(['book:2', 'book:3'])]
|
|
49
|
+
* ])
|
|
50
|
+
* ```
|
|
51
|
+
*
|
|
52
|
+
* @public
|
|
53
|
+
*/
|
|
26
54
|
export type RSIndexMap<
|
|
27
55
|
R extends UnknownRecord,
|
|
28
56
|
Property extends string & keyof R = string & keyof R,
|
|
29
57
|
> = Map<R[Property], Set<IdOf<R>>>
|
|
30
58
|
|
|
31
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* A reactive computed index that provides efficient lookups of records by property values.
|
|
61
|
+
* Returns a computed value containing an RSIndexMap with diffs for change tracking.
|
|
62
|
+
*
|
|
63
|
+
* @example
|
|
64
|
+
* ```ts
|
|
65
|
+
* // Create an index on book authors
|
|
66
|
+
* const authorIndex: RSIndex<Book, 'authorId'> = store.query.index('book', 'authorId')
|
|
67
|
+
*
|
|
68
|
+
* // Get all books by a specific author
|
|
69
|
+
* const leguinBooks = authorIndex.get().get('author:leguin')
|
|
70
|
+
* ```
|
|
71
|
+
*
|
|
72
|
+
* @public
|
|
73
|
+
*/
|
|
32
74
|
export type RSIndex<
|
|
33
75
|
R extends UnknownRecord,
|
|
34
76
|
Property extends string & keyof R = string & keyof R,
|
|
35
77
|
> = Computed<RSIndexMap<R, Property>, RSIndexDiff<R, Property>>
|
|
36
78
|
|
|
37
79
|
/**
|
|
38
|
-
* A class that provides
|
|
39
|
-
*
|
|
80
|
+
* A class that provides reactive querying capabilities for a record store.
|
|
81
|
+
* Offers methods to create indexes, filter records, and perform efficient lookups with automatic cache management.
|
|
82
|
+
* All queries are reactive and will automatically update when the underlying store data changes.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* ```ts
|
|
86
|
+
* // Create a store with books
|
|
87
|
+
* const store = new Store({ schema: StoreSchema.create({ book: Book, author: Author }) })
|
|
88
|
+
*
|
|
89
|
+
* // Get reactive queries for books
|
|
90
|
+
* const booksByAuthor = store.query.index('book', 'authorId')
|
|
91
|
+
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
|
|
92
|
+
* ```
|
|
93
|
+
*
|
|
40
94
|
* @public
|
|
41
95
|
*/
|
|
42
96
|
export class StoreQueries<R extends UnknownRecord> {
|
|
97
|
+
/**
|
|
98
|
+
* Creates a new StoreQueries instance.
|
|
99
|
+
*
|
|
100
|
+
* recordMap - The atom map containing all records in the store
|
|
101
|
+
* history - The atom tracking the store's change history with diffs
|
|
102
|
+
*
|
|
103
|
+
* @internal
|
|
104
|
+
*/
|
|
43
105
|
constructor(
|
|
44
106
|
private readonly recordMap: AtomMap<IdOf<R>, R>,
|
|
45
107
|
private readonly history: Atom<number, RecordsDiff<R>>
|
|
@@ -60,10 +122,25 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
60
122
|
private historyCache = new Map<string, Computed<number, RecordsDiff<R>>>()
|
|
61
123
|
|
|
62
124
|
/**
|
|
63
|
-
*
|
|
125
|
+
* Creates a reactive computed that tracks the change history for records of a specific type.
|
|
126
|
+
* The returned computed provides incremental diffs showing what records of the given type
|
|
127
|
+
* have been added, updated, or removed.
|
|
128
|
+
*
|
|
129
|
+
* @param typeName - The type name to filter the history by
|
|
130
|
+
* @returns A computed value containing the current epoch and diffs of changes for the specified type
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```ts
|
|
134
|
+
* // Track changes to book records only
|
|
135
|
+
* const bookHistory = store.query.filterHistory('book')
|
|
136
|
+
*
|
|
137
|
+
* // React to book changes
|
|
138
|
+
* react('book-changes', () => {
|
|
139
|
+
* const currentEpoch = bookHistory.get()
|
|
140
|
+
* console.log('Books updated at epoch:', currentEpoch)
|
|
141
|
+
* })
|
|
142
|
+
* ```
|
|
64
143
|
*
|
|
65
|
-
* @param typeName - The name of the type to filter by.
|
|
66
|
-
* @returns A derivation that returns the ids of all records of the given type.
|
|
67
144
|
* @public
|
|
68
145
|
*/
|
|
69
146
|
public filterHistory<TypeName extends R['typeName']>(
|
|
@@ -156,10 +233,28 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
156
233
|
}
|
|
157
234
|
|
|
158
235
|
/**
|
|
159
|
-
*
|
|
236
|
+
* Creates a reactive index that maps property values to sets of record IDs for efficient lookups.
|
|
237
|
+
* The index automatically updates when records are added, updated, or removed, and results are cached
|
|
238
|
+
* for performance.
|
|
239
|
+
*
|
|
240
|
+
* @param typeName - The type name of records to index
|
|
241
|
+
* @param property - The property name to index by
|
|
242
|
+
* @returns A reactive computed containing the index map with change diffs
|
|
243
|
+
*
|
|
244
|
+
* @example
|
|
245
|
+
* ```ts
|
|
246
|
+
* // Create an index of books by author ID
|
|
247
|
+
* const booksByAuthor = store.query.index('book', 'authorId')
|
|
248
|
+
*
|
|
249
|
+
* // Get all books by a specific author
|
|
250
|
+
* const authorBooks = booksByAuthor.get().get('author:leguin')
|
|
251
|
+
* console.log(authorBooks) // Set<RecordId<Book>>
|
|
252
|
+
*
|
|
253
|
+
* // Index by title for quick title lookups
|
|
254
|
+
* const booksByTitle = store.query.index('book', 'title')
|
|
255
|
+
* const booksLatheOfHeaven = booksByTitle.get().get('The Lathe of Heaven')
|
|
256
|
+
* ```
|
|
160
257
|
*
|
|
161
|
-
* @param typeName - The name of the type.
|
|
162
|
-
* @param property - The name of the property.
|
|
163
258
|
* @public
|
|
164
259
|
*/
|
|
165
260
|
public index<
|
|
@@ -180,10 +275,13 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
180
275
|
}
|
|
181
276
|
|
|
182
277
|
/**
|
|
183
|
-
*
|
|
278
|
+
* Creates a new index without checking the cache. This method performs the actual work
|
|
279
|
+
* of building the reactive index computation that tracks property values to record ID sets.
|
|
280
|
+
*
|
|
281
|
+
* @param typeName - The type name of records to index
|
|
282
|
+
* @param property - The property name to index by
|
|
283
|
+
* @returns A reactive computed containing the index map with change diffs
|
|
184
284
|
*
|
|
185
|
-
* @param typeName - The name of the type?.
|
|
186
|
-
* @param property - The name of the property?.
|
|
187
285
|
* @internal
|
|
188
286
|
*/
|
|
189
287
|
__uncached_createIndex<
|
|
@@ -293,13 +391,26 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
293
391
|
}
|
|
294
392
|
|
|
295
393
|
/**
|
|
296
|
-
*
|
|
394
|
+
* Creates a reactive query that returns the first record matching the given query criteria.
|
|
395
|
+
* Returns undefined if no matching record is found. The query automatically updates
|
|
396
|
+
* when records change.
|
|
397
|
+
*
|
|
398
|
+
* @param typeName - The type name of records to query
|
|
399
|
+
* @param queryCreator - Function that returns the query expression object to match against
|
|
400
|
+
* @param name - Optional name for the query computation (used for debugging)
|
|
401
|
+
* @returns A computed value containing the first matching record or undefined
|
|
402
|
+
*
|
|
403
|
+
* @example
|
|
404
|
+
* ```ts
|
|
405
|
+
* // Find the first book with a specific title
|
|
406
|
+
* const bookLatheOfHeaven = store.query.record('book', () => ({ title: { eq: 'The Lathe of Heaven' } }))
|
|
407
|
+
* console.log(bookLatheOfHeaven.get()?.title) // 'The Lathe of Heaven' or undefined
|
|
297
408
|
*
|
|
298
|
-
*
|
|
409
|
+
* // Find any book in stock
|
|
410
|
+
* const anyInStockBook = store.query.record('book', () => ({ inStock: { eq: true } }))
|
|
411
|
+
* ```
|
|
299
412
|
*
|
|
300
|
-
* @
|
|
301
|
-
* @param queryCreator - A function that returns the query expression.
|
|
302
|
-
* @param name - (optional) The name of the query.
|
|
413
|
+
* @public
|
|
303
414
|
*/
|
|
304
415
|
record<TypeName extends R['typeName']>(
|
|
305
416
|
typeName: TypeName,
|
|
@@ -318,11 +429,28 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
318
429
|
}
|
|
319
430
|
|
|
320
431
|
/**
|
|
321
|
-
*
|
|
432
|
+
* Creates a reactive query that returns an array of all records matching the given query criteria.
|
|
433
|
+
* The array automatically updates when records are added, updated, or removed.
|
|
434
|
+
*
|
|
435
|
+
* @param typeName - The type name of records to query
|
|
436
|
+
* @param queryCreator - Function that returns the query expression object to match against
|
|
437
|
+
* @param name - Optional name for the query computation (used for debugging)
|
|
438
|
+
* @returns A computed value containing an array of all matching records
|
|
439
|
+
*
|
|
440
|
+
* @example
|
|
441
|
+
* ```ts
|
|
442
|
+
* // Get all books in stock
|
|
443
|
+
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
|
|
444
|
+
* console.log(inStockBooks.get()) // Book[]
|
|
322
445
|
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
446
|
+
* // Get all books by a specific author
|
|
447
|
+
* const leguinBooks = store.query.records('book', () => ({ authorId: { eq: 'author:leguin' } }))
|
|
448
|
+
*
|
|
449
|
+
* // Get all books (no filter)
|
|
450
|
+
* const allBooks = store.query.records('book')
|
|
451
|
+
* ```
|
|
452
|
+
*
|
|
453
|
+
* @public
|
|
326
454
|
*/
|
|
327
455
|
records<TypeName extends R['typeName']>(
|
|
328
456
|
typeName: TypeName,
|
|
@@ -344,11 +472,29 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
344
472
|
}
|
|
345
473
|
|
|
346
474
|
/**
|
|
347
|
-
*
|
|
475
|
+
* Creates a reactive query that returns a set of record IDs matching the given query criteria.
|
|
476
|
+
* This is more efficient than `records()` when you only need the IDs and not the full record objects.
|
|
477
|
+
* The set automatically updates with collection diffs when records change.
|
|
478
|
+
*
|
|
479
|
+
* @param typeName - The type name of records to query
|
|
480
|
+
* @param queryCreator - Function that returns the query expression object to match against
|
|
481
|
+
* @param name - Optional name for the query computation (used for debugging)
|
|
482
|
+
* @returns A computed value containing a set of matching record IDs with collection diffs
|
|
483
|
+
*
|
|
484
|
+
* @example
|
|
485
|
+
* ```ts
|
|
486
|
+
* // Get IDs of all books in stock
|
|
487
|
+
* const inStockBookIds = store.query.ids('book', () => ({ inStock: { eq: true } }))
|
|
488
|
+
* console.log(inStockBookIds.get()) // Set<RecordId<Book>>
|
|
348
489
|
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
490
|
+
* // Get all book IDs (no filter)
|
|
491
|
+
* const allBookIds = store.query.ids('book')
|
|
492
|
+
*
|
|
493
|
+
* // Use with other queries for efficient lookups
|
|
494
|
+
* const authorBookIds = store.query.ids('book', () => ({ authorId: { eq: 'author:leguin' } }))
|
|
495
|
+
* ```
|
|
496
|
+
*
|
|
497
|
+
* @public
|
|
352
498
|
*/
|
|
353
499
|
ids<TypeName extends R['typeName']>(
|
|
354
500
|
typeName: TypeName,
|
|
@@ -446,6 +592,27 @@ export class StoreQueries<R extends UnknownRecord> {
|
|
|
446
592
|
)
|
|
447
593
|
}
|
|
448
594
|
|
|
595
|
+
/**
|
|
596
|
+
* Executes a one-time query against the current store state and returns matching records.
|
|
597
|
+
* This is a non-reactive query that returns results immediately without creating a computed value.
|
|
598
|
+
* Use this when you need a snapshot of data at a specific point in time.
|
|
599
|
+
*
|
|
600
|
+
* @param typeName - The type name of records to query
|
|
601
|
+
* @param query - The query expression object to match against
|
|
602
|
+
* @returns An array of records that match the query at the current moment
|
|
603
|
+
*
|
|
604
|
+
* @example
|
|
605
|
+
* ```ts
|
|
606
|
+
* // Get current in-stock books (non-reactive)
|
|
607
|
+
* const currentInStockBooks = store.query.exec('book', { inStock: { eq: true } })
|
|
608
|
+
* console.log(currentInStockBooks) // Book[]
|
|
609
|
+
*
|
|
610
|
+
* // Unlike records(), this won't update when the data changes
|
|
611
|
+
* const staticBookList = store.query.exec('book', { authorId: { eq: 'author:leguin' } })
|
|
612
|
+
* ```
|
|
613
|
+
*
|
|
614
|
+
* @public
|
|
615
|
+
*/
|
|
449
616
|
exec<TypeName extends R['typeName']>(
|
|
450
617
|
typeName: TypeName,
|
|
451
618
|
query: QueryExpression<Extract<R, { typeName: TypeName }>>
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { BaseRecord, RecordId } from './BaseRecord'
|
|
3
|
+
import { createRecordType } from './RecordType'
|
|
4
|
+
import { Store } from './Store'
|
|
5
|
+
import { StoreSchema } from './StoreSchema'
|
|
6
|
+
import { createMigrationSequence } from './migrate'
|
|
7
|
+
|
|
8
|
+
// Test record types
|
|
9
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
10
|
+
title: string
|
|
11
|
+
author: string
|
|
12
|
+
publishedYear: number
|
|
13
|
+
genre?: string
|
|
14
|
+
inStock?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const Book = createRecordType<Book>('book', {
|
|
18
|
+
validator: { validate: (book) => book as Book },
|
|
19
|
+
scope: 'document',
|
|
20
|
+
}).withDefaultProperties(() => ({
|
|
21
|
+
inStock: true,
|
|
22
|
+
publishedYear: 2023,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
26
|
+
name: string
|
|
27
|
+
birthYear: number
|
|
28
|
+
isAlive?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const Author = createRecordType<Author>('author', {
|
|
32
|
+
validator: { validate: (author) => author as Author },
|
|
33
|
+
scope: 'document',
|
|
34
|
+
}).withDefaultProperties(() => ({
|
|
35
|
+
isAlive: true,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
describe('StoreSchema', () => {
|
|
39
|
+
describe('create', () => {
|
|
40
|
+
it('validates migration dependencies during creation', () => {
|
|
41
|
+
const invalidMigrations = createMigrationSequence({
|
|
42
|
+
sequenceId: 'com.tldraw.book',
|
|
43
|
+
sequence: [
|
|
44
|
+
{
|
|
45
|
+
id: 'com.tldraw.book/1',
|
|
46
|
+
scope: 'record',
|
|
47
|
+
dependsOn: ['com.tldraw.book/999'],
|
|
48
|
+
up: (record: any) => record,
|
|
49
|
+
},
|
|
50
|
+
],
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
expect(() => {
|
|
54
|
+
StoreSchema.create({ book: Book }, { migrations: [invalidMigrations] })
|
|
55
|
+
}).toThrow()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('prevents duplicate migration sequence IDs', () => {
|
|
59
|
+
const migration1 = createMigrationSequence({
|
|
60
|
+
sequenceId: 'com.tldraw.book',
|
|
61
|
+
sequence: [
|
|
62
|
+
{
|
|
63
|
+
id: 'com.tldraw.book/1',
|
|
64
|
+
scope: 'record',
|
|
65
|
+
up: (record: any) => record,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const migration2 = createMigrationSequence({
|
|
71
|
+
sequenceId: 'com.tldraw.book', // Same ID
|
|
72
|
+
sequence: [
|
|
73
|
+
{
|
|
74
|
+
id: 'com.tldraw.book/1',
|
|
75
|
+
scope: 'record',
|
|
76
|
+
up: (record: any) => record,
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(() => {
|
|
82
|
+
StoreSchema.create({ book: Book }, { migrations: [migration1, migration2] })
|
|
83
|
+
}).toThrow('Duplicate migration sequenceId com.tldraw.book')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('validateRecord', () => {
|
|
88
|
+
it('throws for invalid record without validation failure handler', () => {
|
|
89
|
+
// Create a validator that actually throws
|
|
90
|
+
const StrictBook = createRecordType<Book>('book', {
|
|
91
|
+
validator: {
|
|
92
|
+
validate: (book) => {
|
|
93
|
+
if (!(book as any).title || !(book as any).author) {
|
|
94
|
+
throw new Error('Missing required fields')
|
|
95
|
+
}
|
|
96
|
+
return book as Book
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
scope: 'document',
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const schema = StoreSchema.create({ book: StrictBook })
|
|
103
|
+
const store = new Store({ schema, props: {} })
|
|
104
|
+
|
|
105
|
+
// Missing required fields
|
|
106
|
+
const invalidBook = {
|
|
107
|
+
id: StrictBook.createId(),
|
|
108
|
+
typeName: 'book' as const,
|
|
109
|
+
// Missing title, author, publishedYear
|
|
110
|
+
} as any
|
|
111
|
+
|
|
112
|
+
expect(() => {
|
|
113
|
+
schema.validateRecord(store, invalidBook, 'createRecord', null)
|
|
114
|
+
}).toThrow('Missing required fields')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('calls validation failure handler for invalid record', () => {
|
|
118
|
+
const onValidationFailure = vi.fn().mockReturnValue({
|
|
119
|
+
id: Book.createId(),
|
|
120
|
+
typeName: 'book' as const,
|
|
121
|
+
title: 'Fixed Title',
|
|
122
|
+
author: 'Fixed Author',
|
|
123
|
+
publishedYear: 2023,
|
|
124
|
+
inStock: true,
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// Create a validator that throws
|
|
128
|
+
const StrictBook = createRecordType<Book>('book', {
|
|
129
|
+
validator: {
|
|
130
|
+
validate: (book) => {
|
|
131
|
+
if (!(book as any).title || !(book as any).author) {
|
|
132
|
+
throw new Error('Missing required fields')
|
|
133
|
+
}
|
|
134
|
+
return book as Book
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
scope: 'document',
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
const schema = StoreSchema.create({ book: StrictBook }, { onValidationFailure })
|
|
141
|
+
const store = new Store({ schema, props: {} })
|
|
142
|
+
|
|
143
|
+
const invalidBook = {
|
|
144
|
+
id: StrictBook.createId(),
|
|
145
|
+
typeName: 'book' as const,
|
|
146
|
+
} as any
|
|
147
|
+
|
|
148
|
+
const result = schema.validateRecord(store, invalidBook, 'createRecord', null)
|
|
149
|
+
|
|
150
|
+
expect(onValidationFailure).toHaveBeenCalledWith({
|
|
151
|
+
store,
|
|
152
|
+
record: invalidBook,
|
|
153
|
+
phase: 'createRecord',
|
|
154
|
+
recordBefore: null,
|
|
155
|
+
error: expect.any(Error),
|
|
156
|
+
})
|
|
157
|
+
expect(result.title).toBe('Fixed Title')
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
it('throws for unknown record type', () => {
|
|
161
|
+
const schema = StoreSchema.create({ book: Book })
|
|
162
|
+
const store = new Store({ schema, props: {} })
|
|
163
|
+
|
|
164
|
+
const unknownRecord = {
|
|
165
|
+
id: 'unknown:1',
|
|
166
|
+
typeName: 'unknown',
|
|
167
|
+
} as any
|
|
168
|
+
|
|
169
|
+
expect(() => {
|
|
170
|
+
schema.validateRecord(store, unknownRecord, 'createRecord', null)
|
|
171
|
+
}).toThrow('Missing definition for record type unknown')
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe('createIntegrityChecker', () => {
|
|
176
|
+
it('calls createIntegrityChecker option when provided', () => {
|
|
177
|
+
const createIntegrityChecker = vi.fn()
|
|
178
|
+
const schema = StoreSchema.create({ book: Book }, { createIntegrityChecker })
|
|
179
|
+
const store = new Store({ schema, props: {} })
|
|
180
|
+
|
|
181
|
+
schema.createIntegrityChecker(store)
|
|
182
|
+
|
|
183
|
+
expect(createIntegrityChecker).toHaveBeenCalledWith(store)
|
|
184
|
+
})
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
describe('serialize', () => {
|
|
188
|
+
it('serializes schema with current migration versions', () => {
|
|
189
|
+
const bookMigrations = createMigrationSequence({
|
|
190
|
+
sequenceId: 'com.tldraw.book',
|
|
191
|
+
sequence: [
|
|
192
|
+
{ id: 'com.tldraw.book/1', scope: 'record', up: (r: any) => r },
|
|
193
|
+
{ id: 'com.tldraw.book/2', scope: 'record', up: (r: any) => r },
|
|
194
|
+
],
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
const authorMigrations = createMigrationSequence({
|
|
198
|
+
sequenceId: 'com.tldraw.author',
|
|
199
|
+
sequence: [{ id: 'com.tldraw.author/1', scope: 'record', up: (r: any) => r }],
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const schema = StoreSchema.create(
|
|
203
|
+
{ book: Book, author: Author },
|
|
204
|
+
{ migrations: [bookMigrations, authorMigrations] }
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const serialized = schema.serialize()
|
|
208
|
+
|
|
209
|
+
expect(serialized).toEqual({
|
|
210
|
+
schemaVersion: 2,
|
|
211
|
+
sequences: {
|
|
212
|
+
'com.tldraw.book': 2,
|
|
213
|
+
'com.tldraw.author': 1,
|
|
214
|
+
},
|
|
215
|
+
})
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
describe('getType', () => {
|
|
220
|
+
it('throws for invalid type name', () => {
|
|
221
|
+
const schema = StoreSchema.create({ book: Book })
|
|
222
|
+
|
|
223
|
+
expect(() => schema.getType('nonexistent')).toThrow('record type does not exists')
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
})
|