@tldraw/store 5.2.0-canary.fe03bcdddf34 → 5.2.0-canary.fff413eea248
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 +6 -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
package/DOCS.md
ADDED
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
# @tldraw/store
|
|
2
|
+
|
|
3
|
+
A reactive record storage library built on `@tldraw/state` that provides type-safe, event-driven database functionality for managing collections of records. You can think of it as a reactive, in-memory database that automatically tracks changes and provides powerful querying capabilities while maintaining excellent performance and type safety.
|
|
4
|
+
|
|
5
|
+
> Important: This documentation assumes you have a basic understanding of reactive programming concepts and TypeScript. The store is built on top of `@tldraw/state` signals, so familiarity with atoms and computed values will be helpful.
|
|
6
|
+
|
|
7
|
+
## 1. Introduction
|
|
8
|
+
|
|
9
|
+
The `@tldraw/store` manages collections of typed **records** - immutable objects that represent your application's data. Unlike traditional databases, every piece of data in the store is reactive, meaning your application will automatically update when the underlying data changes.
|
|
10
|
+
|
|
11
|
+
The store provides:
|
|
12
|
+
|
|
13
|
+
- **Reactive storage** - Data changes automatically trigger updates throughout your application
|
|
14
|
+
- **Type safety** - Full TypeScript support with compile-time validation
|
|
15
|
+
- **Change tracking** - Complete history of all modifications with undo/redo support
|
|
16
|
+
- **Migrations** - Seamless schema evolution as your application grows
|
|
17
|
+
- **Side effects** - Lifecycle hooks for implementing business logic
|
|
18
|
+
- **Queries** - Reactive indexes and filtering for efficient data access
|
|
19
|
+
|
|
20
|
+
## 2. Core Concepts
|
|
21
|
+
|
|
22
|
+
### Records: The Foundation
|
|
23
|
+
|
|
24
|
+
**Records** are immutable data objects that extend the `BaseRecord` interface. Every record has an `id` and a `typeName` that identifies its type:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { BaseRecord, RecordId } from '@tldraw/store'
|
|
28
|
+
|
|
29
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
30
|
+
title: string
|
|
31
|
+
author: string
|
|
32
|
+
publishedYear: number
|
|
33
|
+
inStock: boolean
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### Record Types: Factories for Records
|
|
38
|
+
|
|
39
|
+
A **RecordType** is a factory that creates and manages records of a specific type. You define how records are created, validated, and what their default properties are:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import { createRecordType } from '@tldraw/store'
|
|
43
|
+
|
|
44
|
+
const Book = createRecordType<Book>('book', {
|
|
45
|
+
validator: bookValidator, // Optional validation function
|
|
46
|
+
scope: 'document', // Persistence behavior
|
|
47
|
+
}).withDefaultProperties(() => ({
|
|
48
|
+
inStock: true,
|
|
49
|
+
publishedYear: new Date().getFullYear(),
|
|
50
|
+
}))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
// Create a new book
|
|
54
|
+
|
|
55
|
+
## 2. Core Concepts
|
|
56
|
+
|
|
57
|
+
### Records: The Foundation
|
|
58
|
+
|
|
59
|
+
**Records** are immutable data objects that extend the `BaseRecord` interface. Every record has an `id` and a `typeName` that identifies its type:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { BaseRecord, RecordId } from '@tldraw/store'
|
|
63
|
+
|
|
64
|
+
interface Author extends BaseRecord<'author', RecordId<Author>> {
|
|
65
|
+
name: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface Book extends BaseRecord<'book', RecordId<Book>> {
|
|
69
|
+
title: string
|
|
70
|
+
authorId: RecordId<Author>
|
|
71
|
+
publishedYear: number
|
|
72
|
+
inStock: boolean
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Record Types: Factories for Records
|
|
77
|
+
|
|
78
|
+
A **RecordType** is a factory that creates and manages records of a specific type. You define how records are created, validated, and what their default properties are:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { createRecordType } from '@tldraw/store'
|
|
82
|
+
|
|
83
|
+
const Author = createRecordType<Author>('author', {
|
|
84
|
+
scope: 'document',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const Book = createRecordType<Book>('book', {
|
|
88
|
+
scope: 'document', // Persistence behavior
|
|
89
|
+
}).withDefaultProperties(() => ({
|
|
90
|
+
inStock: true,
|
|
91
|
+
publishedYear: new Date().getFullYear(),
|
|
92
|
+
}))
|
|
93
|
+
|
|
94
|
+
// Create a new author and book
|
|
95
|
+
const orwell = Author.create({ name: 'George Orwell' })
|
|
96
|
+
const book = Book.create({
|
|
97
|
+
title: '1984',
|
|
98
|
+
authorId: orwell.id,
|
|
99
|
+
})
|
|
100
|
+
// Results in: { id: 'book:abc123', typeName: 'book', title: '1984', authorId: 'author:xyz789', publishedYear: 2025, inStock: true }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## 3. Basic Usage
|
|
104
|
+
|
|
105
|
+
### Creating and Storing Records
|
|
106
|
+
|
|
107
|
+
You add records to the store using the `put` method:
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
// Create some authors and books
|
|
111
|
+
const orwell = Author.create({ name: 'George Orwell' })
|
|
112
|
+
const huxley = Author.create({ name: 'Aldous Huxley' })
|
|
113
|
+
|
|
114
|
+
const books = [
|
|
115
|
+
Book.create({ title: '1984', authorId: orwell.id }),
|
|
116
|
+
Book.create({ title: 'Animal Farm', authorId: orwell.id }),
|
|
117
|
+
Book.create({ title: 'Brave New World', authorId: huxley.id }),
|
|
118
|
+
]
|
|
119
|
+
|
|
120
|
+
// Add authors and books to the store
|
|
121
|
+
store.put([orwell, huxley, ...books])
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> Tip: The `put` method handles both creating new records and updating existing ones. If a record with the same ID already exists, it will be updated.
|
|
125
|
+
|
|
126
|
+
### Reading Records
|
|
127
|
+
|
|
128
|
+
You can read individual records or access all records of a type:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
// Get a specific book by ID
|
|
132
|
+
const book = store.get(books[0].id)
|
|
133
|
+
console.log(book?.title) // "1984"
|
|
134
|
+
|
|
135
|
+
// Get all records in the store
|
|
136
|
+
const allRecords = store.allRecords()
|
|
137
|
+
|
|
138
|
+
// Check if a record exists
|
|
139
|
+
const hasBook = store.has(books[0].id) // true
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Updating Records
|
|
143
|
+
|
|
144
|
+
Use the `update` method to modify existing records:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
// Update a book's stock status
|
|
148
|
+
store.update(book.id, (currentBook) => ({
|
|
149
|
+
...currentBook,
|
|
150
|
+
inStock: false,
|
|
151
|
+
}))
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The update function receives the current record and returns a new record with your changes applied.
|
|
155
|
+
|
|
156
|
+
### Removing Records
|
|
157
|
+
|
|
158
|
+
Remove records using their IDs:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// Remove a single book
|
|
162
|
+
store.remove([book.id])
|
|
163
|
+
|
|
164
|
+
// Remove multiple books
|
|
165
|
+
store.remove([book1.id, book2.id, book3.id])
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
## 4. Reactive Queries and Indexing
|
|
169
|
+
|
|
170
|
+
### Understanding Reactive Indexes
|
|
171
|
+
|
|
172
|
+
The store automatically maintains **reactive indexes** that allow efficient querying of your data. These indexes update automatically when records change:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Create an index by author
|
|
176
|
+
// (assuming `orwell` is an Author record we've created)
|
|
177
|
+
const booksByAuthor = store.query.index('book', 'authorId')
|
|
178
|
+
|
|
179
|
+
// Get all books by George Orwell
|
|
180
|
+
const orwellBooks = booksByAuthor.get().get(orwell.id)
|
|
181
|
+
console.log(orwellBooks) // Set<RecordId<Book>>
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The index returns a `Map` where keys are property values and values are `Set`s of record IDs that have that property value.
|
|
185
|
+
|
|
186
|
+
### Reactive Queries with Computed Values
|
|
187
|
+
|
|
188
|
+
Combine indexes with computed values to create reactive queries:
|
|
189
|
+
|
|
190
|
+
```ts
|
|
191
|
+
import { computed } from '@tldraw/state'
|
|
192
|
+
|
|
193
|
+
// Create a reactive query for in-stock books
|
|
194
|
+
const inStockBooks = store.query.records('book', () => ({
|
|
195
|
+
inStock: { eq: true },
|
|
196
|
+
}))
|
|
197
|
+
|
|
198
|
+
// Then, group them by author in a second computed value
|
|
199
|
+
const inStockBooksByAuthor = computed('inStockBooksByAuthor', () => {
|
|
200
|
+
const results = new Map<RecordId<Author>, Book[]>()
|
|
201
|
+
for (const book of inStockBooks.get()) {
|
|
202
|
+
const authorBooks = results.get(book.authorId) || []
|
|
203
|
+
authorBooks.push(book)
|
|
204
|
+
results.set(book.authorId, authorBooks)
|
|
205
|
+
}
|
|
206
|
+
return results
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// The computed value automatically updates when in-stock books change
|
|
210
|
+
console.log(inStockBooksByAuthor.get()) // Map<RecordId<Author>, Book[]>
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### The Store: Your Reactive Database
|
|
214
|
+
|
|
215
|
+
The **Store** is the central container that manages all your records. It provides reactive access to data and automatically tracks changes:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { Store, StoreSchema } from '@tldraw/store'
|
|
219
|
+
|
|
220
|
+
// Create a schema that defines all your record types
|
|
221
|
+
const schema = StoreSchema.create({
|
|
222
|
+
book: Book,
|
|
223
|
+
// ... other record types
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
// Create the store
|
|
227
|
+
const store = new Store({
|
|
228
|
+
schema,
|
|
229
|
+
props: {}, // Custom properties for your application
|
|
230
|
+
})
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## 3. Basic Usage
|
|
234
|
+
|
|
235
|
+
### Creating and Storing Records
|
|
236
|
+
|
|
237
|
+
You add records to the store using the `put` method:
|
|
238
|
+
|
|
239
|
+
```ts
|
|
240
|
+
// Create some books
|
|
241
|
+
const books = [
|
|
242
|
+
Book.create({ title: '1984', author: 'George Orwell' }),
|
|
243
|
+
Book.create({ title: 'Animal Farm', author: 'George Orwell' }),
|
|
244
|
+
Book.create({ title: 'Brave New World', author: 'Aldous Huxley' }),
|
|
245
|
+
]
|
|
246
|
+
|
|
247
|
+
// Add them to the store
|
|
248
|
+
store.put(books)
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
> Tip: The `put` method handles both creating new records and updating existing ones. If a record with the same ID already exists, it will be updated.
|
|
252
|
+
|
|
253
|
+
### Reading Records
|
|
254
|
+
|
|
255
|
+
You can read individual records or access all records of a type:
|
|
256
|
+
|
|
257
|
+
```ts
|
|
258
|
+
// Get a specific book by ID
|
|
259
|
+
const book = store.get(books[0].id)
|
|
260
|
+
console.log(book?.title) // "1984"
|
|
261
|
+
|
|
262
|
+
// Get all records in the store
|
|
263
|
+
const allRecords = store.allRecords()
|
|
264
|
+
|
|
265
|
+
// Check if a record exists
|
|
266
|
+
const hasBook = store.has(books[0].id) // true
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Updating Records
|
|
270
|
+
|
|
271
|
+
Use the `update` method to modify existing records:
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
// Update a book's stock status
|
|
275
|
+
store.update(book.id, (currentBook) => ({
|
|
276
|
+
...currentBook,
|
|
277
|
+
inStock: false,
|
|
278
|
+
}))
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
The update function receives the current record and returns a new record with your changes applied.
|
|
282
|
+
|
|
283
|
+
### Removing Records
|
|
284
|
+
|
|
285
|
+
Remove records using their IDs:
|
|
286
|
+
|
|
287
|
+
```ts
|
|
288
|
+
// Remove a single book
|
|
289
|
+
store.remove([book.id])
|
|
290
|
+
|
|
291
|
+
// Remove multiple books
|
|
292
|
+
store.remove([book1.id, book2.id, book3.id])
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## 4. Reactive Queries and Indexing
|
|
296
|
+
|
|
297
|
+
### Understanding Reactive Indexes
|
|
298
|
+
|
|
299
|
+
The store automatically maintains **reactive indexes** that allow efficient querying of your data. These indexes update automatically when records change:
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
// Create an index by author
|
|
303
|
+
const booksByAuthor = store.query.index('book', 'author')
|
|
304
|
+
|
|
305
|
+
// Get all books by George Orwell
|
|
306
|
+
const orwellBooks = booksByAuthor.get().get('George Orwell')
|
|
307
|
+
console.log(orwellBooks) // Set<RecordId<Book>>
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
The index returns a `Map` where keys are property values and values are `Set`s of record IDs that have that property value.
|
|
311
|
+
|
|
312
|
+
### Reactive Queries with Computed Values
|
|
313
|
+
|
|
314
|
+
Combine indexes with computed values to create reactive queries:
|
|
315
|
+
|
|
316
|
+
```ts
|
|
317
|
+
import { computed } from '@tldraw/state'
|
|
318
|
+
|
|
319
|
+
// Create a reactive query for books in stock by author
|
|
320
|
+
const inStockBooksByAuthor = computed('inStockBooksByAuthor', () => {
|
|
321
|
+
const results = new Map<string, Book[]>()
|
|
322
|
+
|
|
323
|
+
// Get all books
|
|
324
|
+
for (const book of store.allRecords()) {
|
|
325
|
+
if (book.typeName === 'book' && book.inStock) {
|
|
326
|
+
const authorBooks = results.get(book.author) || []
|
|
327
|
+
authorBooks.push(book)
|
|
328
|
+
results.set(book.author, authorBooks)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return results
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// The computed value automatically updates when books change
|
|
336
|
+
console.log(inStockBooksByAuthor.get()) // Map<string, Book[]>
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
### Filtering History for Specific Record Types
|
|
340
|
+
|
|
341
|
+
You can create reactive computations that track changes to specific record types:
|
|
342
|
+
|
|
343
|
+
```ts
|
|
344
|
+
import { react } from '@tldraw/state'
|
|
345
|
+
|
|
346
|
+
// Get a reactive history computation for books only
|
|
347
|
+
const bookHistory = store.query.filterHistory('book')
|
|
348
|
+
|
|
349
|
+
// React to book changes
|
|
350
|
+
const dispose = react('book-changes', () => {
|
|
351
|
+
const currentEpoch = bookHistory.get()
|
|
352
|
+
console.log('Book history updated, current epoch:', currentEpoch)
|
|
353
|
+
|
|
354
|
+
// You can get the actual changes using getDiffSince if needed
|
|
355
|
+
// const changes = bookHistory.getDiffSince(previousEpoch)
|
|
356
|
+
})
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
> Tip: The `filterHistory` method returns a `Computed` that tracks changes to records of a specific type. Use it with `react()` from `@tldraw/state` to respond to changes.
|
|
360
|
+
|
|
361
|
+
## 5. Record Scopes and Persistence
|
|
362
|
+
|
|
363
|
+
### Understanding Record Scopes
|
|
364
|
+
|
|
365
|
+
Records have different **scopes** that determine how they're persisted and synchronized:
|
|
366
|
+
|
|
367
|
+
```ts
|
|
368
|
+
const DocumentRecord = createRecordType<DocumentData>('document', {
|
|
369
|
+
scope: 'document', // Persisted and synced across instances
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
const SessionRecord = createRecordType<SessionData>('session', {
|
|
373
|
+
scope: 'session', // Per-instance, may be persisted but not synced
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
const PresenceRecord = createRecordType<PresenceData>('presence', {
|
|
377
|
+
scope: 'presence', // Per-instance, synced but not persisted (like cursors)
|
|
378
|
+
})
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
- **`document`** - Permanent data that should be saved and shared
|
|
382
|
+
- **`session`** - Per-instance data that might be saved locally
|
|
383
|
+
- **`presence`** - Temporary data that's shared but not saved
|
|
384
|
+
|
|
385
|
+
### Serialization and Snapshots
|
|
386
|
+
|
|
387
|
+
You can serialize the store's data for persistence:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
// Get a snapshot of all document records
|
|
391
|
+
const snapshot = store.getStoreSnapshot('document')
|
|
392
|
+
|
|
393
|
+
// Save it somewhere
|
|
394
|
+
localStorage.setItem('myApp', JSON.stringify(snapshot))
|
|
395
|
+
|
|
396
|
+
// Later, restore the data
|
|
397
|
+
const saved = JSON.parse(localStorage.getItem('myApp'))
|
|
398
|
+
store.loadStoreSnapshot(saved)
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
> Note: The store automatically handles migrations when loading snapshots from older versions of your schema.
|
|
402
|
+
|
|
403
|
+
## 6. Side Effects and Business Logic
|
|
404
|
+
|
|
405
|
+
### Understanding Side Effects
|
|
406
|
+
|
|
407
|
+
**Side effects** are hooks that let you implement business logic in response to record changes. They run automatically when records are created, updated, or deleted:
|
|
408
|
+
|
|
409
|
+
```ts
|
|
410
|
+
// React when books are created
|
|
411
|
+
store.sideEffects.registerAfterCreateHandler('book', (book, source) => {
|
|
412
|
+
console.log(`New book added: ${book.title}`)
|
|
413
|
+
|
|
414
|
+
// Update author statistics
|
|
415
|
+
updateAuthorBookCount(book.authorId, 1)
|
|
416
|
+
})
|
|
417
|
+
|
|
418
|
+
// Validate before updates
|
|
419
|
+
store.sideEffects.registerBeforeChangeHandler('book', (prev, next, source) => {
|
|
420
|
+
// Ensure price never goes negative
|
|
421
|
+
if (next.price < 0) {
|
|
422
|
+
return { ...next, price: 0 }
|
|
423
|
+
}
|
|
424
|
+
return next
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// Clean up when books are deleted
|
|
428
|
+
store.sideEffects.registerAfterDeleteHandler('book', (book, source) => {
|
|
429
|
+
console.log(`Book removed: ${book.title}`)
|
|
430
|
+
updateAuthorBookCount(book.authorId, -1)
|
|
431
|
+
})
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
### Change Sources
|
|
435
|
+
|
|
436
|
+
Side effects receive a `source` parameter that tells you where the change originated:
|
|
437
|
+
|
|
438
|
+
- `'user'` - Changes from your application logic
|
|
439
|
+
- `'remote'` - Changes from synchronization or external sources
|
|
440
|
+
|
|
441
|
+
```ts
|
|
442
|
+
store.sideEffects.registerAfterCreateHandler('book', (book, source) => {
|
|
443
|
+
if (source === 'user') {
|
|
444
|
+
// Only send notifications for local changes
|
|
445
|
+
notifyUser(`You added "${book.title}" to your library`)
|
|
446
|
+
}
|
|
447
|
+
})
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Before Handlers: Validation and Transformation
|
|
451
|
+
|
|
452
|
+
**Before handlers** run before changes are applied and can validate or transform the data:
|
|
453
|
+
|
|
454
|
+
```ts
|
|
455
|
+
// Prevent deletion of books that are checked out
|
|
456
|
+
store.sideEffects.registerBeforeDeleteHandler('book', (book, source) => {
|
|
457
|
+
if (book.checkedOut) {
|
|
458
|
+
// Return false to prevent the deletion
|
|
459
|
+
return false
|
|
460
|
+
}
|
|
461
|
+
})
|
|
462
|
+
|
|
463
|
+
// Transform data before storing
|
|
464
|
+
store.sideEffects.registerBeforeCreateHandler('book', (book, source) => {
|
|
465
|
+
// Always store titles in title case
|
|
466
|
+
return {
|
|
467
|
+
...book,
|
|
468
|
+
title: toTitleCase(book.title),
|
|
469
|
+
}
|
|
470
|
+
})
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
## 7. Schema Evolution with Migrations
|
|
474
|
+
|
|
475
|
+
### Creating Migration Sequences
|
|
476
|
+
|
|
477
|
+
As your application evolves, you'll need to update your data structure. **Migrations** handle this automatically:
|
|
478
|
+
|
|
479
|
+
```ts
|
|
480
|
+
import { createMigrationSequence } from '@tldraw/store'
|
|
481
|
+
|
|
482
|
+
const bookMigrations = createMigrationSequence({
|
|
483
|
+
sequenceId: 'com.myapp.book',
|
|
484
|
+
sequence: [
|
|
485
|
+
// Migration 1: Add publishedYear field
|
|
486
|
+
{
|
|
487
|
+
id: 'com.myapp.book/add-published-year',
|
|
488
|
+
scope: 'record',
|
|
489
|
+
up: (record: any) => {
|
|
490
|
+
// Convert publishDate string to publishedYear number
|
|
491
|
+
record.publishedYear = new Date(record.publishDate).getFullYear()
|
|
492
|
+
delete record.publishDate
|
|
493
|
+
return record
|
|
494
|
+
},
|
|
495
|
+
down: (record: any) => {
|
|
496
|
+
// Reverse the migration if needed
|
|
497
|
+
record.publishDate = new Date(record.publishedYear, 0, 1).toISOString()
|
|
498
|
+
delete record.publishedYear
|
|
499
|
+
return record
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
// Migration 2: Add genre field with default
|
|
504
|
+
{
|
|
505
|
+
id: 'com.myapp.book/add-genre',
|
|
506
|
+
scope: 'record',
|
|
507
|
+
up: (record: any) => {
|
|
508
|
+
record.genre = record.genre || 'Fiction'
|
|
509
|
+
return record
|
|
510
|
+
},
|
|
511
|
+
down: (record: any) => {
|
|
512
|
+
delete record.genre
|
|
513
|
+
return record
|
|
514
|
+
},
|
|
515
|
+
},
|
|
516
|
+
],
|
|
517
|
+
})
|
|
518
|
+
|
|
519
|
+
// Include migrations in your schema
|
|
520
|
+
const schema = StoreSchema.create(
|
|
521
|
+
{
|
|
522
|
+
book: Book,
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
migrations: [bookMigrations],
|
|
526
|
+
}
|
|
527
|
+
)
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Migration Safety
|
|
531
|
+
|
|
532
|
+
The store automatically applies migrations when loading data from older versions:
|
|
533
|
+
|
|
534
|
+
```ts
|
|
535
|
+
// This snapshot might be from an older version
|
|
536
|
+
const oldSnapshot = JSON.parse(localStorage.getItem('myApp'))
|
|
537
|
+
|
|
538
|
+
// Migrations run automatically during load
|
|
539
|
+
store.loadStoreSnapshot(oldSnapshot)
|
|
540
|
+
// All records are now up to date with current schema
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
> Tip: Always test your migrations thoroughly with real data before deploying to production.
|
|
544
|
+
|
|
545
|
+
## 8. Advanced Topics
|
|
546
|
+
|
|
547
|
+
### Transactional Operations
|
|
548
|
+
|
|
549
|
+
The store automatically ensures that related operations happen atomically. When you perform multiple operations in response to user actions or side effects, they are automatically grouped together for consistency and performance.
|
|
550
|
+
|
|
551
|
+
### Custom Computed Caches
|
|
552
|
+
|
|
553
|
+
Create **computed caches** for expensive derivations that should be memoized per record:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
const expensiveBookData = store.createComputedCache('expensiveBookData', (book: Book) => {
|
|
557
|
+
// This expensive computation is cached per book
|
|
558
|
+
return performExpensiveAnalysis(book)
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
// Access cached data
|
|
562
|
+
const analysis = expensiveBookData.get(book.id)
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
The cache automatically updates when the underlying record changes and cleans up when records are deleted.
|
|
566
|
+
|
|
567
|
+
### Extracting Changes
|
|
568
|
+
|
|
569
|
+
You can capture changes that occur within a function:
|
|
570
|
+
|
|
571
|
+
```ts
|
|
572
|
+
const changes = store.extractingChanges(() => {
|
|
573
|
+
// Make various changes
|
|
574
|
+
store.put([newBook])
|
|
575
|
+
store.update(book.id, (b) => ({ ...b, title: 'New Title' }))
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
// `changes` contains a diff of what was modified
|
|
579
|
+
console.log(changes) // { added: {...}, updated: {...}, removed: {...} }
|
|
580
|
+
```
|
|
581
|
+
|
|
582
|
+
This is useful for implementing undo/redo systems or understanding what changed during complex operations.
|
|
583
|
+
|
|
584
|
+
## 9. Debugging
|
|
585
|
+
|
|
586
|
+
The store provides several tools for understanding what's happening in your application.
|
|
587
|
+
|
|
588
|
+
### Store Listeners
|
|
589
|
+
|
|
590
|
+
Add listeners to react to changes in your store:
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
// Listen to all changes
|
|
594
|
+
const removeListener = store.listen((entry) => {
|
|
595
|
+
console.log('Changes occurred:', entry.changes)
|
|
596
|
+
console.log('Source:', entry.source) // 'user' or 'remote'
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
// Listen only to document changes from user actions
|
|
600
|
+
const removeDocumentListener = store.listen(
|
|
601
|
+
(entry) => {
|
|
602
|
+
console.log('User made document changes:', entry.changes)
|
|
603
|
+
},
|
|
604
|
+
{
|
|
605
|
+
source: 'user',
|
|
606
|
+
scope: 'document',
|
|
607
|
+
}
|
|
608
|
+
)
|
|
609
|
+
```
|
|
610
|
+
|
|
611
|
+
### Understanding Record Validation
|
|
612
|
+
|
|
613
|
+
The store validates all records when they're created or updated. Validation errors provide detailed information:
|
|
614
|
+
|
|
615
|
+
```ts
|
|
616
|
+
try {
|
|
617
|
+
store.put([invalidBook])
|
|
618
|
+
} catch (error) {
|
|
619
|
+
console.log('Validation failed:', error.message)
|
|
620
|
+
// Check your record's structure and validator function
|
|
621
|
+
}
|
|
622
|
+
```
|
|
623
|
+
|
|
624
|
+
> Tip: Validation runs in development mode to help catch errors early. Make sure your validators are efficient since they run on every change.
|
|
625
|
+
|
|
626
|
+
## 10. Integration
|
|
627
|
+
|
|
628
|
+
### Framework Integration
|
|
629
|
+
|
|
630
|
+
The store is framework-agnostic but integrates well with React through `@tldraw/state-react`:
|
|
631
|
+
|
|
632
|
+
```ts
|
|
633
|
+
import { track } from '@tldraw/state-react'
|
|
634
|
+
|
|
635
|
+
const BookList = track(() => {
|
|
636
|
+
const books = store.allRecords().filter(r => r.typeName === 'book')
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<ul>
|
|
640
|
+
{books.map(book => (
|
|
641
|
+
<li key={book.id}>{book.title} by {book.author}</li>
|
|
642
|
+
))}
|
|
643
|
+
</ul>
|
|
644
|
+
)
|
|
645
|
+
})
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
The `track` function automatically subscribes the component to relevant store changes.
|
|
649
|
+
|
|
650
|
+
### Persistence Strategies
|
|
651
|
+
|
|
652
|
+
Implement different persistence strategies based on your needs:
|
|
653
|
+
|
|
654
|
+
```ts
|
|
655
|
+
// Auto-save on every change
|
|
656
|
+
store.listen((entry) => {
|
|
657
|
+
if (entry.source === 'user') {
|
|
658
|
+
const snapshot = store.getStoreSnapshot()
|
|
659
|
+
saveToDatabase(snapshot)
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
|
|
663
|
+
// Batch saves every few seconds
|
|
664
|
+
let saveTimeout: NodeJS.Timeout
|
|
665
|
+
store.listen(() => {
|
|
666
|
+
clearTimeout(saveTimeout)
|
|
667
|
+
saveTimeout = setTimeout(() => {
|
|
668
|
+
const snapshot = store.getStoreSnapshot()
|
|
669
|
+
saveToDatabase(snapshot)
|
|
670
|
+
}, 5000)
|
|
671
|
+
})
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
### Synchronization
|
|
675
|
+
|
|
676
|
+
For multi-user applications, use the store with `@tldraw/sync`:
|
|
677
|
+
|
|
678
|
+
```ts
|
|
679
|
+
// Merge remote changes
|
|
680
|
+
store.mergeRemoteChanges(() => {
|
|
681
|
+
// Apply changes from other users
|
|
682
|
+
store.put(remoteRecords)
|
|
683
|
+
})
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
Changes merged this way are marked with `source: 'remote'` so your side effects can handle them appropriately.
|
|
687
|
+
|
|
688
|
+
## 11. Performance Considerations
|
|
689
|
+
|
|
690
|
+
### Memory Management
|
|
691
|
+
|
|
692
|
+
The store uses several strategies to maintain good performance:
|
|
693
|
+
|
|
694
|
+
- **Lazy atom creation** - Atoms are only created when records are accessed reactively
|
|
695
|
+
- **Automatic cleanup** - Atoms are cleaned up when records are deleted
|
|
696
|
+
- **Efficient indexes** - Reactive indexes use incremental updates rather than full rebuilds
|
|
697
|
+
- **Change batching** - Multiple changes are batched together before notifying listeners
|
|
698
|
+
|
|
699
|
+
### Query Optimization
|
|
700
|
+
|
|
701
|
+
For best performance with large datasets:
|
|
702
|
+
|
|
703
|
+
```ts
|
|
704
|
+
// Use indexes for common queries
|
|
705
|
+
const booksByGenre = store.query.index('book', 'genre')
|
|
706
|
+
const sciFiBooks = booksByGenre.get().get('Science Fiction')
|
|
707
|
+
|
|
708
|
+
// Avoid reactive access in hot paths
|
|
709
|
+
const book = store.unsafeGetWithoutCapture(bookId) // No reactive subscription
|
|
710
|
+
|
|
711
|
+
// Use computed caches for expensive derivations
|
|
712
|
+
const expensiveData = store.createComputedCache('expensive', computeExpensiveData)
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
### Side Effect Performance
|
|
716
|
+
|
|
717
|
+
Keep side effects fast since they run synchronously:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
// Good: Fast side effect
|
|
721
|
+
store.sideEffects.registerAfterCreateHandler('book', (book) => {
|
|
722
|
+
updateQuickStats(book)
|
|
723
|
+
})
|
|
724
|
+
|
|
725
|
+
// Better: Async work in background
|
|
726
|
+
store.sideEffects.registerAfterCreateHandler('book', (book) => {
|
|
727
|
+
queueAsyncWork(book) // Handle async work separately
|
|
728
|
+
})
|
|
729
|
+
```
|
|
730
|
+
|
|
731
|
+
## 12. Common Patterns
|
|
732
|
+
|
|
733
|
+
### Repository Pattern
|
|
734
|
+
|
|
735
|
+
Create typed repositories for cleaner APIs:
|
|
736
|
+
|
|
737
|
+
```ts
|
|
738
|
+
class BookRepository {
|
|
739
|
+
constructor(private store: Store<Book>) {}
|
|
740
|
+
|
|
741
|
+
findByAuthor(author: string): Book[] {
|
|
742
|
+
return this.store.allRecords().filter((book) => book.author === author)
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
getInStock(): Book[] {
|
|
746
|
+
return this.store.allRecords().filter((book) => book.inStock)
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
create(data: Omit<Book, 'id' | 'typeName'>): Book {
|
|
750
|
+
const book = Book.create(data)
|
|
751
|
+
this.store.put([book])
|
|
752
|
+
return book
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
### State Machines with Records
|
|
758
|
+
|
|
759
|
+
Use records to represent state machine states:
|
|
760
|
+
|
|
761
|
+
```ts
|
|
762
|
+
interface OrderState extends BaseRecord<'orderState', RecordId<OrderState>> {
|
|
763
|
+
orderId: string
|
|
764
|
+
status: 'pending' | 'confirmed' | 'shipped' | 'delivered'
|
|
765
|
+
createdAt: number
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
const OrderState = createRecordType<OrderState>('orderState', { scope: 'document' })
|
|
769
|
+
|
|
770
|
+
// Transition states with side effects
|
|
771
|
+
store.sideEffects.registerAfterChangeHandler('orderState', (prev, next) => {
|
|
772
|
+
if (prev.status !== next.status) {
|
|
773
|
+
handleStatusChange(prev.status, next.status, next.orderId)
|
|
774
|
+
}
|
|
775
|
+
})
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
### Computed Relationships
|
|
779
|
+
|
|
780
|
+
Model relationships between records using efficient queries:
|
|
781
|
+
|
|
782
|
+
```ts
|
|
783
|
+
// Get all books by a specific author
|
|
784
|
+
const authorId = 'author:123'
|
|
785
|
+
const authorBooks = store.query.records('book', () => ({
|
|
786
|
+
authorId: { eq: authorId },
|
|
787
|
+
}))
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
The store provides a powerful, reactive foundation for managing your application's data. By understanding these patterns and concepts, you can build applications that automatically stay in sync as data changes, while maintaining excellent performance and type safety throughout.
|