snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

790
packages/store/DOCS.md Normal file
View File

@@ -0,0 +1,790 @@
# @tldraw/store
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.
> 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.
## 1. Introduction
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.
The store provides:
- **Reactive storage** - Data changes automatically trigger updates throughout your application
- **Type safety** - Full TypeScript support with compile-time validation
- **Change tracking** - Complete history of all modifications with undo/redo support
- **Migrations** - Seamless schema evolution as your application grows
- **Side effects** - Lifecycle hooks for implementing business logic
- **Queries** - Reactive indexes and filtering for efficient data access
## 2. Core concepts
### Records: the foundation
**Records** are immutable data objects that extend the `BaseRecord` interface. Every record has an `id` and a `typeName` that identifies its type:
```ts
import { BaseRecord, RecordId } from '@tldraw/store'
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
author: string
publishedYear: number
inStock: boolean
}
```
### Record types: factories for records
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:
```ts
import { createRecordType } from '@tldraw/store'
const Book = createRecordType<Book>('book', {
validator: bookValidator, // Optional validation function
scope: 'document', // Persistence behavior
}).withDefaultProperties(() => ({
inStock: true,
publishedYear: new Date().getFullYear(),
}))
```
// Create a new book
## 2. Core concepts
### Records: the foundation
**Records** are immutable data objects that extend the `BaseRecord` interface. Every record has an `id` and a `typeName` that identifies its type:
```ts
import { BaseRecord, RecordId } from '@tldraw/store'
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
}
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
authorId: RecordId<Author>
publishedYear: number
inStock: boolean
}
```
### Record types: factories for records
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:
```ts
import { createRecordType } from '@tldraw/store'
const Author = createRecordType<Author>('author', {
scope: 'document',
})
const Book = createRecordType<Book>('book', {
scope: 'document', // Persistence behavior
}).withDefaultProperties(() => ({
inStock: true,
publishedYear: new Date().getFullYear(),
}))
// Create a new author and book
const orwell = Author.create({ name: 'George Orwell' })
const book = Book.create({
title: '1984',
authorId: orwell.id,
})
// Results in: { id: 'book:abc123', typeName: 'book', title: '1984', authorId: 'author:xyz789', publishedYear: 2025, inStock: true }
```
## 3. Basic usage
### Creating and storing records
You add records to the store using the `put` method:
```ts
// Create some authors and books
const orwell = Author.create({ name: 'George Orwell' })
const huxley = Author.create({ name: 'Aldous Huxley' })
const books = [
Book.create({ title: '1984', authorId: orwell.id }),
Book.create({ title: 'Animal Farm', authorId: orwell.id }),
Book.create({ title: 'Brave New World', authorId: huxley.id }),
]
// Add authors and books to the store
store.put([orwell, huxley, ...books])
```
> 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.
### Reading records
You can read individual records or access all records of a type:
```ts
// Get a specific book by ID
const book = store.get(books[0].id)
console.log(book?.title) // "1984"
// Get all records in the store
const allRecords = store.allRecords()
// Check if a record exists
const hasBook = store.has(books[0].id) // true
```
### Updating records
Use the `update` method to modify existing records:
```ts
// Update a book's stock status
store.update(book.id, (currentBook) => ({
...currentBook,
inStock: false,
}))
```
The update function receives the current record and returns a new record with your changes applied.
### Removing records
Remove records using their IDs:
```ts
// Remove a single book
store.remove([book.id])
// Remove multiple books
store.remove([book1.id, book2.id, book3.id])
```
## 4. Reactive queries and indexing
### Understanding reactive indexes
The store automatically maintains **reactive indexes** that allow efficient querying of your data. These indexes update automatically when records change:
```ts
// Create an index by author
// (assuming `orwell` is an Author record we've created)
const booksByAuthor = store.query.index('book', 'authorId')
// Get all books by George Orwell
const orwellBooks = booksByAuthor.get().get(orwell.id)
console.log(orwellBooks) // Set<RecordId<Book>>
```
The index returns a `Map` where keys are property values and values are `Set`s of record IDs that have that property value.
### Reactive queries with computed values
Combine indexes with computed values to create reactive queries:
```ts
import { computed } from '@tldraw/state'
// Create a reactive query for in-stock books
const inStockBooks = store.query.records('book', () => ({
inStock: { eq: true },
}))
// Then, group them by author in a second computed value
const inStockBooksByAuthor = computed('inStockBooksByAuthor', () => {
const results = new Map<RecordId<Author>, Book[]>()
for (const book of inStockBooks.get()) {
const authorBooks = results.get(book.authorId) || []
authorBooks.push(book)
results.set(book.authorId, authorBooks)
}
return results
})
// The computed value automatically updates when in-stock books change
console.log(inStockBooksByAuthor.get()) // Map<RecordId<Author>, Book[]>
```
### The store: your reactive database
The **Store** is the central container that manages all your records. It provides reactive access to data and automatically tracks changes:
```ts
import { Store, StoreSchema } from '@tldraw/store'
// Create a schema that defines all your record types
const schema = StoreSchema.create({
book: Book,
// ... other record types
})
// Create the store
const store = new Store({
schema,
props: {}, // Custom properties for your application
})
```
## 3. Basic usage
### Creating and storing records
You add records to the store using the `put` method:
```ts
// Create some books
const books = [
Book.create({ title: '1984', author: 'George Orwell' }),
Book.create({ title: 'Animal Farm', author: 'George Orwell' }),
Book.create({ title: 'Brave New World', author: 'Aldous Huxley' }),
]
// Add them to the store
store.put(books)
```
> 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.
### Reading records
You can read individual records or access all records of a type:
```ts
// Get a specific book by ID
const book = store.get(books[0].id)
console.log(book?.title) // "1984"
// Get all records in the store
const allRecords = store.allRecords()
// Check if a record exists
const hasBook = store.has(books[0].id) // true
```
### Updating records
Use the `update` method to modify existing records:
```ts
// Update a book's stock status
store.update(book.id, (currentBook) => ({
...currentBook,
inStock: false,
}))
```
The update function receives the current record and returns a new record with your changes applied.
### Removing records
Remove records using their IDs:
```ts
// Remove a single book
store.remove([book.id])
// Remove multiple books
store.remove([book1.id, book2.id, book3.id])
```
## 4. Reactive queries and indexing
### Understanding reactive indexes
The store automatically maintains **reactive indexes** that allow efficient querying of your data. These indexes update automatically when records change:
```ts
// Create an index by author
const booksByAuthor = store.query.index('book', 'author')
// Get all books by George Orwell
const orwellBooks = booksByAuthor.get().get('George Orwell')
console.log(orwellBooks) // Set<RecordId<Book>>
```
The index returns a `Map` where keys are property values and values are `Set`s of record IDs that have that property value.
### Reactive queries with computed values
Combine indexes with computed values to create reactive queries:
```ts
import { computed } from '@tldraw/state'
// Create a reactive query for books in stock by author
const inStockBooksByAuthor = computed('inStockBooksByAuthor', () => {
const results = new Map<string, Book[]>()
// Get all books
for (const book of store.allRecords()) {
if (book.typeName === 'book' && book.inStock) {
const authorBooks = results.get(book.author) || []
authorBooks.push(book)
results.set(book.author, authorBooks)
}
}
return results
})
// The computed value automatically updates when books change
console.log(inStockBooksByAuthor.get()) // Map<string, Book[]>
```
### Filtering history for specific record types
You can create reactive computations that track changes to specific record types:
```ts
import { react } from '@tldraw/state'
// Get a reactive history computation for books only
const bookHistory = store.query.filterHistory('book')
// React to book changes
const dispose = react('book-changes', () => {
const currentEpoch = bookHistory.get()
console.log('Book history updated, current epoch:', currentEpoch)
// You can get the actual changes using getDiffSince if needed
// const changes = bookHistory.getDiffSince(previousEpoch)
})
```
> 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.
## 5. Record scopes and persistence
### Understanding record scopes
Records have different **scopes** that determine how they're persisted and synchronized:
```ts
const DocumentRecord = createRecordType<DocumentData>('document', {
scope: 'document', // Persisted and synced across instances
})
const SessionRecord = createRecordType<SessionData>('session', {
scope: 'session', // Per-instance, may be persisted but not synced
})
const PresenceRecord = createRecordType<PresenceData>('presence', {
scope: 'presence', // Per-instance, synced but not persisted (like cursors)
})
```
- **`document`** - Permanent data that should be saved and shared
- **`session`** - Per-instance data that might be saved locally
- **`presence`** - Temporary data that's shared but not saved
### Serialization and snapshots
You can serialize the store's data for persistence:
```ts
// Get a snapshot of all document records
const snapshot = store.getStoreSnapshot('document')
// Save it somewhere
localStorage.setItem('myApp', JSON.stringify(snapshot))
// Later, restore the data
const saved = JSON.parse(localStorage.getItem('myApp'))
store.loadStoreSnapshot(saved)
```
> Note: The store automatically handles migrations when loading snapshots from older versions of your schema.
## 6. Side effects and business logic
### Understanding side effects
**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:
```ts
// React when books are created
store.sideEffects.registerAfterCreateHandler('book', (book, source) => {
console.log(`New book added: ${book.title}`)
// Update author statistics
updateAuthorBookCount(book.authorId, 1)
})
// Validate before updates
store.sideEffects.registerBeforeChangeHandler('book', (prev, next, source) => {
// Ensure price never goes negative
if (next.price < 0) {
return { ...next, price: 0 }
}
return next
})
// Clean up when books are deleted
store.sideEffects.registerAfterDeleteHandler('book', (book, source) => {
console.log(`Book removed: ${book.title}`)
updateAuthorBookCount(book.authorId, -1)
})
```
### Change sources
Side effects receive a `source` parameter that tells you where the change originated:
- `'user'` - Changes from your application logic
- `'remote'` - Changes from synchronization or external sources
```ts
store.sideEffects.registerAfterCreateHandler('book', (book, source) => {
if (source === 'user') {
// Only send notifications for local changes
notifyUser(`You added "${book.title}" to your library`)
}
})
```
### Before handlers: validation and transformation
**Before handlers** run before changes are applied and can validate or transform the data:
```ts
// Prevent deletion of books that are checked out
store.sideEffects.registerBeforeDeleteHandler('book', (book, source) => {
if (book.checkedOut) {
// Return false to prevent the deletion
return false
}
})
// Transform data before storing
store.sideEffects.registerBeforeCreateHandler('book', (book, source) => {
// Always store titles in title case
return {
...book,
title: toTitleCase(book.title),
}
})
```
## 7. Schema evolution with migrations
### Creating migration sequences
As your application evolves, you'll need to update your data structure. **Migrations** handle this automatically:
```ts
import { createMigrationSequence } from '@tldraw/store'
const bookMigrations = createMigrationSequence({
sequenceId: 'com.myapp.book',
sequence: [
// Migration 1: Add publishedYear field
{
id: 'com.myapp.book/add-published-year',
scope: 'record',
up: (record: any) => {
// Convert publishDate string to publishedYear number
record.publishedYear = new Date(record.publishDate).getFullYear()
delete record.publishDate
return record
},
down: (record: any) => {
// Reverse the migration if needed
record.publishDate = new Date(record.publishedYear, 0, 1).toISOString()
delete record.publishedYear
return record
},
},
// Migration 2: Add genre field with default
{
id: 'com.myapp.book/add-genre',
scope: 'record',
up: (record: any) => {
record.genre = record.genre || 'Fiction'
return record
},
down: (record: any) => {
delete record.genre
return record
},
},
],
})
// Include migrations in your schema
const schema = StoreSchema.create(
{
book: Book,
},
{
migrations: [bookMigrations],
}
)
```
### Migration safety
The store automatically applies migrations when loading data from older versions:
```ts
// This snapshot might be from an older version
const oldSnapshot = JSON.parse(localStorage.getItem('myApp'))
// Migrations run automatically during load
store.loadStoreSnapshot(oldSnapshot)
// All records are now up to date with current schema
```
> Tip: Always test your migrations thoroughly with real data before deploying to production.
## 8. Advanced topics
### Transactional operations
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.
### Custom computed caches
Create **computed caches** for expensive derivations that should be memoized per record:
```ts
const expensiveBookData = store.createComputedCache('expensiveBookData', (book: Book) => {
// This expensive computation is cached per book
return performExpensiveAnalysis(book)
})
// Access cached data
const analysis = expensiveBookData.get(book.id)
```
The cache automatically updates when the underlying record changes and cleans up when records are deleted.
### Extracting changes
You can capture changes that occur within a function:
```ts
const changes = store.extractingChanges(() => {
// Make various changes
store.put([newBook])
store.update(book.id, (b) => ({ ...b, title: 'New Title' }))
})
// `changes` contains a diff of what was modified
console.log(changes) // { added: {...}, updated: {...}, removed: {...} }
```
This is useful for implementing undo/redo systems or understanding what changed during complex operations.
## 9. Debugging
The store provides several tools for understanding what's happening in your application.
### Store listeners
Add listeners to react to changes in your store:
```ts
// Listen to all changes
const removeListener = store.listen((entry) => {
console.log('Changes occurred:', entry.changes)
console.log('Source:', entry.source) // 'user' or 'remote'
})
// Listen only to document changes from user actions
const removeDocumentListener = store.listen(
(entry) => {
console.log('User made document changes:', entry.changes)
},
{
source: 'user',
scope: 'document',
}
)
```
### Understanding record validation
The store validates all records when they're created or updated. Validation errors provide detailed information:
```ts
try {
store.put([invalidBook])
} catch (error) {
console.log('Validation failed:', error.message)
// Check your record's structure and validator function
}
```
> Tip: Validation runs in development mode to help catch errors early. Make sure your validators are efficient since they run on every change.
## 10. Integration
### Framework integration
The store is framework-agnostic but integrates well with React through `@tldraw/state-react`:
```ts
import { track } from '@tldraw/state-react'
const BookList = track(() => {
const books = store.allRecords().filter(r => r.typeName === 'book')
return (
<ul>
{books.map(book => (
<li key={book.id}>{book.title} by {book.author}</li>
))}
</ul>
)
})
```
The `track` function automatically subscribes the component to relevant store changes.
### Persistence strategies
Implement different persistence strategies based on your needs:
```ts
// Auto-save on every change
store.listen((entry) => {
if (entry.source === 'user') {
const snapshot = store.getStoreSnapshot()
saveToDatabase(snapshot)
}
})
// Batch saves every few seconds
let saveTimeout: NodeJS.Timeout
store.listen(() => {
clearTimeout(saveTimeout)
saveTimeout = setTimeout(() => {
const snapshot = store.getStoreSnapshot()
saveToDatabase(snapshot)
}, 5000)
})
```
### Synchronization
For multi-user applications, use the store with `@tldraw/sync`:
```ts
// Merge remote changes
store.mergeRemoteChanges(() => {
// Apply changes from other users
store.put(remoteRecords)
})
```
Changes merged this way are marked with `source: 'remote'` so your side effects can handle them appropriately.
## 11. Performance considerations
### Memory management
The store uses several strategies to maintain good performance:
- **Lazy atom creation** - Atoms are only created when records are accessed reactively
- **Automatic cleanup** - Atoms are cleaned up when records are deleted
- **Efficient indexes** - Reactive indexes use incremental updates rather than full rebuilds
- **Change batching** - Multiple changes are batched together before notifying listeners
### Query optimization
For best performance with large datasets:
```ts
// Use indexes for common queries
const booksByGenre = store.query.index('book', 'genre')
const sciFiBooks = booksByGenre.get().get('Science Fiction')
// Avoid reactive access in hot paths
const book = store.unsafeGetWithoutCapture(bookId) // No reactive subscription
// Use computed caches for expensive derivations
const expensiveData = store.createComputedCache('expensive', computeExpensiveData)
```
### Side effect performance
Keep side effects fast since they run synchronously:
```ts
// Good: Fast side effect
store.sideEffects.registerAfterCreateHandler('book', (book) => {
updateQuickStats(book)
})
// Better: Async work in background
store.sideEffects.registerAfterCreateHandler('book', (book) => {
queueAsyncWork(book) // Handle async work separately
})
```
## 12. Common patterns
### Repository pattern
Create typed repositories for cleaner APIs:
```ts
class BookRepository {
constructor(private store: Store<Book>) {}
findByAuthor(author: string): Book[] {
return this.store.allRecords().filter((book) => book.author === author)
}
getInStock(): Book[] {
return this.store.allRecords().filter((book) => book.inStock)
}
create(data: Omit<Book, 'id' | 'typeName'>): Book {
const book = Book.create(data)
this.store.put([book])
return book
}
}
```
### State machines with records
Use records to represent state machine states:
```ts
interface OrderState extends BaseRecord<'orderState', RecordId<OrderState>> {
orderId: string
status: 'pending' | 'confirmed' | 'shipped' | 'delivered'
createdAt: number
}
const OrderState = createRecordType<OrderState>('orderState', { scope: 'document' })
// Transition states with side effects
store.sideEffects.registerAfterChangeHandler('orderState', (prev, next) => {
if (prev.status !== next.status) {
handleStatusChange(prev.status, next.status, next.orderId)
}
})
```
### Computed relationships
Model relationships between records using efficient queries:
```ts
// Get all books by a specific author
const authorId = 'author:123'
const authorBooks = store.query.records('book', () => ({
authorId: { eq: authorId },
}))
```
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.

View File

@@ -0,0 +1,9 @@
MIT License
Copyright (c) 2024 tldraw Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

366
packages/store/README.md Normal file
View File

@@ -0,0 +1,366 @@
# @tldraw/store
`@tldraw/store` is a library for creating and managing data. In this library, a "record" is an object that is stored under a typed id.
`@tldraw/store` is used by [tldraw](https://www.tldraw.com) to store its data. It is designed to be used with [@tldraw/state](https://github.com/tldraw/tldraw/tree/main/packages/state).
## Documentation
Documentation for the most recent release can be found on [tldraw.dev/docs](https://tldraw.dev/docs), including [reference docs](https://tldraw.dev/reference/editor/Editor). Our release notes can be found [here](https://tldraw.dev/releases).
For more agent-friendly docs, see our [LLMs.txt](https://tldraw.dev/llms.txt).
A `DOCS.md` file is included alongside this README in the published package, with detailed API documentation and usage examples.
# Usage
First create types for your records.
```ts
interface Book extends BaseRecord<'book'> {
title: string
author: ID<Author>
numPages: number
}
interface Author extends BaseRecord<'author'> {
name: string
isPseudonym: boolean
}
```
Then create your `RecordType` instances.
```ts
const Book = createRecordType<Book>('book')
const Author = createRecordType<Author>('author').withDefaultProperties(() => ({
isPseudonym: false,
}))
```
Then create your `RecordStore` instance.
```ts
const store = new RecordStore<Book | Author>()
```
Then you can create records, add them to the store, update, and remove them.
```ts
const tolkeinId = Author.createId('tolkein')
store.put([
Author.create({
id: tolkeinId,
name: 'J.R.R Tolkein',
}),
])
store.update(tolkeinId, (author) => ({
...author,
name: 'DJJ Tolkz',
isPseudonym: true,
}))
store.remove(tolkeinId)
```
---
# API
## `RecordStore`
The `RecordStore` class is the main class of the library.
```ts
const store = new RecordStore()
```
### `put(records: R[]): void`
Add some records to the store. It's an error if they already exist.
```ts
const record = Author.create({
name: 'J.R.R Tolkein',
id: Author.createId('tolkein'),
})
store.put([record])
```
### `update(id: ID<R>, updater: (record: R) => R): void`
Update a record. To update multiple records at once, use the `update` method of the `TypedRecordStore` class.
```ts
const id = Author.createId('tolkein')
store.update(id, (r) => ({ ...r, name: 'Jimmy Tolks' }))
```
### `remove(ids: ID<R>[]): void`
Remove some records from the store via their ids.
```ts
const id = Author.createId('tolkein')
store.remove([id])
```
### `get(id: ID<R>): R`
Get the value of a store record by its id.
```ts
const id = Author.createId('tolkein')
const result = store.get(id)
```
### `allRecords(): R[]`
Get an array of all values in the store.
```ts
const results = store.allRecords()
```
### `clear(): void`
Remove all records from the store.
```ts
store.clear()
```
### `has(id: ID<R>): boolean`
Get whether the record store has an record stored under the given id.
```ts
const id = Author.createId('tolkein')
const result = store.has(id)
```
### `serialize(filter?: (record: R) => boolean): RecordStoreSnapshot<R>`
Opposite of `deserialize`. Creates a JSON payload from the record store.
```ts
const serialized = store.serialize()
```
```ts
const serialized = store.serialize((record) => record.name === 'J.R.R Tolkein')
```
### `deserialize(snapshot: RecordStoreSnapshot<R>): void`
Opposite of `serialize`. Replace the store's current records with records as defined by a simple JSON structure into the stores.
```ts
const serialized = { ... }
store.deserialize(serialized)
```
### `listen(listener: ((entry: HistoryEntry) => void): () => void`
Add a new listener to the store. The store will call the function each time the history changes. Returns a function to remove the listener.
```ts
store.listen((entry) => doSomethingWith(entry))
```
### `mergeRemoteChanges(fn: () => void): void`
Merge changes from a remote source without triggering listeners.
```ts
store.mergeRemoteChanges(() => {
store.put(recordsFromRemoteSource)
})
```
### `createDerivationCache(name: string, derive: ((record: R) => R | undefined)): DerivationCache<R>`
Create a new derivation cache.
```ts
const derivationCache = createDerivationCache('popular_authors', (record) => {
return record.popularity > 62 ? record : undefined
})
```
---
## `RecordType`
The `RecordType` class is used to define the structure of a record.
```ts
const recordType = new RecordType('author', () => ({ living: true }))
```
`RecordType` instances are most often created with `createRecordType`.
### `create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R`
Create a new record of this type.
```ts
const record = recordType.create({ name: 'J.R.R Tolkein' })
```
### `clone(record: R): R`
Clone a record of this type.
```ts
const record = recordType.create({ name: 'J.R.R Tolkein' })
const clone = recordType.clone(record)
```
### `createId(): ID<R>`
Create an Id for a record of this type.
```ts
const id = recordType.createId()
```
### `createId(id: string): ID<R>`
Create a custom Id for a record of this type.
```ts
const id = recordType.createId('tolkein')
```
### `isInstance`
Check if a value is an instance of this record type.
```ts
const record = recordType.create({ name: 'J.R.R Tolkein' })
const result1 = recordType.isInstance(record) // true
const result2 = recordType.isInstance(someOtherRecord) // false
```
### `isId`
Check if a value is an id for a record of this type.
```ts
const id = recordType.createId('tolkein')
const result1 = recordType.isId(id) // true
const result2 = recordType.isId(someOtherId) // false
```
### `withDefaultProperties`
Create a new record type with default properties.
```ts
const youngLivingAuthor = new RecordType('author', () => ({ age: 28, living: true }))
const oldDeadAuthor = recordType.withDefaultProperties({ age: 93, living: false })
```
## `RecordStoreQueries`
TODO
## Helpers
### `executeQuery`
TODO
### `DerivationCache`
The `DerivationCache` class is used to create a cache of derived records.
```ts
const derivationCache = new DerivationCache('popular_authors', (record) => {
return record.popularity > 62 ? record : undefined
})
```
### `createRecordType`
A helper used to create a new `RecordType` instance with no default properties.
```ts
const recordType = createRecordType('author'))
```
### `assertIdType`
A helper used to assert that a value is an id for a record of a given type.
```ts
const id = recordType.createId('tolkein')
assertIdType(id, recordType)
```
---
## Types
### `ID`
A type used to represent a record's id.
```ts
const id: ID<Author> = Author.createId('tolkein')
```
### `BaseRecord`
A `BaseRecord` is a record that has an id and a type. It is the base type for all records.
```ts
type AuthorRecord extends BaseRecord<"author"> {
name: string
age: number
living: boolean
}
```
### `RecordsDiff`
A diff describing the changes to a record.
### `CollectionDiff`
A diff describing the changes to a collection.
## Contribution
Found a bug? Please [submit an issue](https://github.com/tldraw/tldraw/issues/new).
## License
This project is licensed under the MIT License found [here](https://github.com/tldraw/tldraw/blob/main/packages/store/LICENSE.md). The tldraw SDK is provided under the [tldraw license](https://github.com/tldraw/tldraw/blob/main/LICENSE.md).
## Trademarks
Copyright (c) 2024-present tldraw Inc. The tldraw name and logo are trademarks of tldraw. Please see our [trademark guidelines](https://github.com/tldraw/tldraw/blob/main/TRADEMARKS.md) for info on acceptable usage.
## Contact
Find us on Twitter/X at [@tldraw](https://twitter.com/tldraw).
## Community
Have questions, comments or feedback? [Join our discord](https://discord.tldraw.com/?utm_source=github&utm_medium=readme&utm_campaign=sociallink). For the latest news and release notes, visit [tldraw.dev](https://tldraw.dev).

243
packages/store/SPEC.md Normal file
View File

@@ -0,0 +1,243 @@
# @tldraw/store behavior specification
This document states the rules that `@tldraw/store` implements. It is written to drive testing: each rule has a stable ID (e.g. `S4`, `MG2`), each rule is independently observable through the public API (or the documented internal API where noted), and the unit tests should be an expression of these rules. When a test and this document disagree, one of them is wrong — figure out which and fix it.
Sections marked **internal** describe supporting machinery (`ImmutableMap`, `IncrementalSetConstructor`, set utilities) that has its own contract worth testing directly, even though users rarely touch it.
## 1. Model and vocabulary
- A **record** is a plain JSON-serializable object with an `id` and a `typeName`.
- A **RecordType** defines a kind of record: its type name, default properties, validator, scope, and ephemeral keys.
- A **scope** is one of `document` (persisted and synced), `session` (per store instance, not synced), or `presence` (per instance, synced but not persisted).
- The **store** holds records in a reactive map keyed by id. All reads are reactive signals from `@tldraw/state` unless explicitly "without capture".
- An **atomic operation** is a store-level batch: changes inside it are applied in one `@tldraw/state` transaction and produce one round of side-effect callbacks.
- A **RecordsDiff** describes a change-set: `added`, `updated` (`[from, to]` pairs), and `removed`, each keyed by id.
- A **source** is `'user'` (local changes) or `'remote'` (changes applied inside `mergeRemoteChanges`).
- **Side effects** are lifecycle handlers (`before`/`after` × `create`/`change`/`delete`, plus `operationComplete`) registered per type name.
- A **migration** transforms persisted data between schema versions. Migrations live in **sequences**; a **serialized schema** records the version of each sequence so the store knows which migrations a snapshot still needs.
## 2. Records (R)
- **R1** `isRecord(v)` is true exactly for non-null objects with both an `id` and a `typeName` property; false for `null`, `undefined`, primitives, and objects missing either property.
- **R2** Record ids are strings of the form `typeName:uniquePart`.
## 3. RecordType (RT)
- **RT1** `createRecordType(typeName, config)` makes a RecordType with the given `scope` and an optional `validator`; without a validator, records pass through validation unchanged. `RecordType`'s own constructor defaults `scope` to `'document'`.
- **RT2** `create(props)` returns `{ ...defaults, ...props, id, typeName }`: default properties from `createDefaultProperties()`, overridden by the given props. Properties whose value is `undefined` do not override defaults.
- **RT3** `create` uses the given `id` if the props contain a defined one, else generates `typeName:<unique>` via `createId()`; an explicitly undefined `id` is treated as absent (matching RT2). `createId(customUniquePart)` uses the custom part when given.
- **RT4** `clone(record)` deep-clones the record and assigns a fresh id.
- **RT5** `parseId(id)` returns the part after the colon and throws if the id does not belong to this type. `isId(id)` is true exactly for strings starting with `typeName:`; false for `undefined` and other types' ids. `isInstance(record)` checks `record?.typeName === typeName`.
- **RT6** `assertIdType(id, type)` throws for `undefined`, empty, or wrong-type ids, and passes for valid ones.
- **RT7** `withDefaultProperties(fn)` returns a new RecordType with the same type name, validator, scope, and ephemeral keys, whose `create` no longer requires the defaulted properties.
- **RT8** `validate(record, recordBefore?)` calls `validator.validateUsingKnownGoodVersion(recordBefore, record)` when both are available, else `validator.validate(record)`.
- **RT9** `ephemeralKeys` marks properties; `ephemeralKeySet` contains exactly the keys mapped to `true`.
## 4. RecordsDiff (D)
- **D1** `createEmptyRecordsDiff()` returns `{ added: {}, updated: {}, removed: {} }`; `isRecordsDiffEmpty` is true exactly when all three collections are empty.
- **D2** `reverseRecordsDiff` swaps added and removed and reverses each `[from, to]` pair. The result shares no collections or pairs with the input, so squashing into it leaves the input untouched.
- **D3** `squashRecordDiffs(diffs)` combines sequential diffs into one. Per record id: add then update → add with the final value; add then remove → nothing; update then update → one update from the original `from` to the final `to`; update then remove → remove of the original `from`; remove then add → update from the removed value to the added value, unless the added value is reference-identical to the removed one, in which case nothing.
- **D4** `squashRecordDiffs` does not mutate its inputs unless `mutateFirstDiff: true`, in which case the first diff is the (mutated) result (an empty diff when there are no diffs); its `[from, to]` pairs are replaced rather than mutated in place. `squashRecordDiffsMutable(target, diffs)` applies diffs onto `target` in place with the same semantics; the target must own its pairs (start from `createEmptyRecordsDiff()`).
## 5. Store: reading and writing (S)
- **S1** A new store is empty unless `initialData` is given; initial data is validated with phase `'initialize'`. Records read from the store are deep-frozen in dev/test builds (DF1).
- **S2** `put([record])` creates records whose ids are not present (validated with phase `'createRecord'`) and updates those that are (phase `'updateRecord'`, with the previous record as the known-good version). `get` returns the stored record; `has` reports presence.
- **S3** `put` skips a record entirely — no store change, no history entry — when the validated result is reference-equal to the currently stored record. (A `validateUsingKnownGoodVersion` implementation that returns the known-good object for equal values therefore makes equal re-puts complete no-ops; likewise a `beforeChange` handler returning `prev` blocks an update, provided the validator is reference-preserving.) If every record in a `put` is skipped, no history entry is produced.
- **S4** `update(id, updater)` is `put([updater(current)])`. For a missing id it logs an error and changes nothing (it does not throw).
- **S5** `remove(ids)` deletes the records with those ids; missing ids are ignored. If nothing is actually deleted, no history entry is produced. `clear()` removes all records.
- **S6** `get`/`has` are reactive (capture as parents of the running computed/effect); `unsafeGetWithoutCapture` reads without capturing. `allRecords()` returns all records as an array.
- **S7** `serialize(scope?)` returns the records whose type's scope matches; the default scope is `'document'`; `'all'` includes everything. `store.scopedTypes` maps each scope to the set of type names in it.
- **S8** `getStoreSnapshot(scope?)` is `{ store: serialize(scope), schema: schema.serialize() }`.
- **S9** `loadStoreSnapshot(snapshot)` migrates the snapshot, replaces all current records with the result, and runs the integrity checker — all with side effects disabled (restoring the previous enabled state afterwards). It throws if migration fails, leaving the store unchanged.
- **S10** `migrateSnapshot(snapshot)` returns the migrated snapshot stamped with the current serialized schema, and throws if migration fails.
## 6. Atomic operations and the side-effect flush (AO)
- **AO1** Every store mutation runs inside `store.atomic()` (directly or implicitly via `put`/`remove`/etc.), which wraps a `@tldraw/state` transaction: reactive effects observe all changes of the operation at once, when the outermost atomic completes.
- **AO2** Nested `atomic` calls join the outer operation: their after-events fold into the outer flush, and `mergeRemoteChanges` inside an atomic op throws.
- **AO3** `atomic(fn, runCallbacks = true)`: passing `false` to a top-level call disables side effects for the whole operation — `before*` handlers are bypassed while `fn` runs, and the flush skips the `after*` and `operationComplete` handlers. A nested `atomic(fn, false)` inside an enabled operation suppresses only the `before*` handlers for its duration: its after-events flush with the outer operation, where side effects are enabled again. A nested atomic can switch callbacks off but cannot switch them back on if an enclosing operation turned them off.
- **AO4** After-events are deduplicated per record id across the operation: one callback per record, using the first `before` and the final `after`. A record created and then deleted in the same operation produces no callback at all; created then updated produces a single `afterCreate` with the final value.
- **AO5** `afterChange` fires only when the before and after records differ by deep equality. (Putting a structurally-equal copy still records a history entry per S3/H1 — only the callback is suppressed.)
- **AO6** After-handlers run when the outermost atomic's function completes, still inside the transaction. Changes they make trigger another round of after-events, repeating until quiescent. More than 100 rounds throws `Maximum store update depth exceeded`.
- **AO7** `operationComplete` handlers fire after the after-events of an operation settle; if an `operationComplete` handler makes further changes, the flush (including `operationComplete`) runs again until quiescent.
- **AO8** `mergeRemoteChanges(fn)` runs `fn` atomically with source `'remote'`; nested calls inside another `mergeRemoteChanges` just run `fn`. After the merge the integrity checker runs. Changes that side-effect handlers — `after*` and `operationComplete` alike — make in response to remote changes are attributed to `'user'`, as are the callback rounds they trigger.
## 7. Side effect handlers (SE)
- **SE1** Handlers are registered per type name and called in registration order; each `register*Handler` call returns a remover. `register({ type: { beforeCreate, ... } })` registers many at once and returns one cleanup that removes them all. Removing or registering a handler while handlers are being dispatched takes effect from the next dispatch: every handler registered when an event started is still called for it.
- **SE2** `beforeCreate` runs before validation on create; its return value is what gets validated and stored. Multiple handlers chain, each receiving the previous one's output.
- **SE3** `beforeChange` runs before validation on update, receiving `(prev, next, source)`; its return value is stored. Returning `prev` blocks the update (with a reference-preserving validator this makes the put a complete no-op per S3).
- **SE4** `beforeDelete` may return `false` to prevent that record's deletion; other records in the same `remove` call are still deleted.
- **SE5** `afterCreate`/`afterChange`/`afterDelete` run per AO4–AO6 and observe the final state of the operation; all handlers receive the source (`'user'` or `'remote'`).
- **SE6** While side effects are disabled (`setIsEnabled(false)`, a top-level `atomic(fn, false)`, `applyDiff(diff, { runCallbacks: false })`, snapshot loads), `before*` handlers pass values through unchanged, `beforeDelete` cannot block, and after-events flushed while disabled are dropped — `after*` and `operationComplete` handlers do not run. (A nested `atomic(fn, false)` is weaker: per AO3 its after-events fire with the outer, enabled flush.) An operation may switch side effects off, but never on while they are disabled: `setIsEnabled(false)` keeps handlers off across subsequent operations until `setIsEnabled(true)`.
## 8. History and listeners (H)
- **H1** `store.history` is an atom that increments by exactly one for each committed change-set, carrying the `RecordsDiff` as its history diff (`historyLength` 1000).
- **H2** `listen(fn, filters?)` registers a listener and returns a remover. Listener notification is asynchronous: accumulated entries are flushed on the next frame, not synchronously with the change.
- **H3** A flush squashes adjacent same-source entries: a listener called after changes `[user, user, remote, user]` receives three entries (`user`, `remote`, `user`), each with the squashed (D3) diff and its source.
- **H4** `filters.source` (`'user' | 'remote' | 'all'`) drops entries from other sources. `filters.scope` (`'document' | 'session' | 'presence' | 'all'`) filters each entry's diff down to records of that scope; if nothing remains, the listener is not called for that entry.
- **H5** `listen` flushes pending history first, so a new listener never sees changes made before it subscribed.
- **H6** While no listeners are attached, accumulated history is discarded rather than retained.
- **H7** `extractingChanges(fn)` returns the squashed diff of exactly the changes made during `fn` (listeners still see those changes normally).
- **H8** `addHistoryInterceptor(fn)` calls `fn(entry, source)` synchronously for every change-set as it happens and returns a remover.
- **H9** `applyDiff(diff)` puts the `added` and `updated` records and removes the `removed` ids. `runCallbacks: false` disables side effects for the application (AO3). Applying a diff and then its `reverseRecordsDiff` (D2) restores the prior state.
- **H10** `applyDiff` with `ignoreEphemeralKeys: true` ignores changes to keys in the type's `ephemeralKeySet` when applying updates to existing records: non-ephemeral changed keys are merged onto the stored record (including the removal of a non-ephemeral key that the update leaves out), and an update touching only ephemeral keys is dropped. Updates for records that don't exist are applied in full, as are records in `added`.
- **H12** `dispose()` delivers any pending change-sets to the attached listeners and then cancels the scheduled flush; it does not remove listeners.
- **H13** A listener that throws does not prevent the other listeners from receiving the same flush; the first error is rethrown once every listener has been called.
## 9. Validation (V)
- **V1** Records are validated on initialization, creation, and update (phases `'initialize'`, `'createRecord'`, `'updateRecord'`). A record whose `typeName` has no RecordType in the schema fails validation with `Missing definition for record type <name>`.
- **V2** Updates pass the previous record to the validator via `validateUsingKnownGoodVersion` when the validator implements it (RT8).
- **V3** A validation throw propagates by default and aborts the whole operation: the transaction rolls back and the store is left unchanged, even for records earlier in the same `put`.
- **V4** With `onValidationFailure`, the handler receives `{ error, store, record, phase, recordBefore }` and its return value is stored instead of throwing — on creation and update alike. More generally, the validator's return value is what gets stored, so validators may substitute a transformed record.
- **V5** `store.validate(phase)` re-validates every record currently in the store.
## 10. Computed caches (CC)
- **CC1** `store.createComputedCache(name, derive)` returns a cache whose `get(id)` returns `derive(record)` for an existing record and `undefined` for a missing one.
- **CC2** Cached values are computed signals: `derive` runs at most once per record change, and re-runs (with a fresh result) when the record changes.
- **CC3** `areRecordsEqual` controls which record changes invalidate the cache: when the old and new record are "equal", `derive` does not re-run. `areResultsEqual` controls change propagation: an "equal" result keeps the previous value object.
- **CC4** The standalone `createComputedCache(name, derive)` works with any `StoreObject` (a store or `{ store }`), keeping a separate cache per context object, and passes the context to `derive`.
- **CC5** `store.createCache(create)` is the low-level form: `create(id, recordSignal)` returns the signal to cache; `get(id)` on a missing record returns `undefined` without calling `create`.
## 11. Queries: filtered history (QH)
- **QH1** `store.query.filterHistory(typeName)` returns a computed change counter whose history diffs contain only records of that type; it is cached per type name.
- **QH2** Within a flush window the diff is squashed per D3 semantics (add+remove cancels, add+update folds into the add, update+update collapses, update+remove removes the oldest `from`).
- **QH3** Changes to other record types produce no observable change for downstream consumers of the filtered history, except that a reset (QH4) may report a change for every type.
- **QH4** The filtered history's value strictly increases on every relevant change or reset, so indexes and queries that were read during a transaction that later rolled back are rebuilt rather than left stale. A rolled-back transaction that changed the store causes a reset. The value is not the store's history counter and can be ahead of it after a reset.
## 12. Queries: indexes (QI)
- **QI1** `store.query.index(typeName, property)` returns a computed `Map` from each distinct property value to the `Set` of ids of records with that value. Records whose value is `undefined` are not indexed.
- **QI2** Indexes are cached per `(typeName, property)` pair.
- **QI3** The index updates when records are added, updated (the id moves between value sets), or removed; a value whose set becomes empty is dropped from the map. Each update carries an `RSIndexDiff` (map of value → `CollectionDiff` of ids) in the computed's history.
- **QI4** If a change does not affect the index (no relevant records, or values unchanged), the index keeps the same map object — downstream consumers observe no change.
- **QI5** A `property` containing backslashes indexes a nested path: `'metadata\\sessionId'` indexes `record.metadata.sessionId`. Missing intermediate objects yield `undefined` (not indexed).
## 13. Queries: ids, records, record, exec (QQ)
- **QQ1** `store.query.ids(typeName, queryCreator?)` returns a computed `Set` of the ids of matching records; with no query it contains all ids of that type. Its history diffs are `CollectionDiff`s.
- **QQ2** The query expression is itself reactive: `queryCreator` is called with no arguments and may read signals, and when the expression it returns changes (by deep equality), the query rebuilds and emits a correct diff. An expression that is deep-equal to the previous one causes no rebuild.
- **QQ3** Changes that do not affect the result (unrelated types, updates that keep a record matching, irrelevant property changes) leave the same `Set` object in place.
- **QQ4** `records(typeName, queryCreator?)` returns the matching records as an array, with shallow-array equality (same members → no change). `record(typeName, queryCreator?)` returns one matching record or `undefined`.
- **QQ5** `exec(typeName, query)` runs one non-reactive query and returns matching records; with no matches it returns the shared empty array.
## 14. Query execution (QE)
- **QE1** `{ eq: v }` matches strict equality; `{ neq: v }` matches records whose value is defined and differs from `v` — records missing the property do not match, mirroring the indexes, which only track defined values; `{ gt: n }` matches only numbers strictly greater than `n` (non-numeric values never match `gt`).
- **QE2** Multiple properties in one expression are ANDed; the result is the intersection of the per-property matches.
- **QE3** A nested object in the expression matches into the corresponding nested record object, to any depth. If the record's value at that level is missing or not an object, the record does not match.
- **QE4** The empty expression `{}` matches every record of the type: `objectMatchesQuery({}, r)` is true and `ids(type)` contains all ids. (The raw `executeQuery` helper instead returns an empty set for an empty expression; `StoreQueries.ids` special-cases it before calling `executeQuery`.)
- **QE5** `executeQuery` (index-based) and `objectMatchesQuery` (predicate) agree: the set of ids returned equals the set of records matching the predicate, including for nested paths and across record types.
## 15. Schema (SC)
- **SC1** `StoreSchema.create(types, options?)` builds a schema. It throws at creation time for duplicate migration `sequenceId`s, invalid migration sequences (M3), and migrations whose `dependsOn` references a migration that does not exist.
- **SC2** `serialize()` returns `{ schemaVersion: 2, sequences }` mapping each sequence id to the version of its last migration (0 for an empty sequence).
- **SC3** `serializeEarliestVersion()` maps every sequence to version 0.
- **SC4** `getType(typeName)` returns the RecordType and throws for unknown type names.
- **SC5** `upgradeSchema` converts a v1 serialized schema to v2: `storeVersion` becomes `com.tldraw.store`, each record version becomes `com.tldraw.<typeName>`, and each subtype version becomes `com.tldraw.<typeName>.<subType>`. v2 schemas pass through unchanged; schema versions other than 1 or 2 produce an error result.
## 16. Migrations: authoring (M)
- **M1** `createMigrationSequence({ sequenceId, sequence, retroactive? })` validates and returns the sequence; `retroactive` defaults to `true`.
- **M2** A standalone `{ dependsOn }` entry in a sequence is squashed into the next migration in the sequence, prepending to that migration's own `dependsOn`. A standalone entry with no following migration is dropped.
- **M3** `validateMigrations` throws when: the sequence id is empty or contains `/`; a migration id does not have the form `<sequenceId>/<integer>`; the first migration's version is not 1; or versions do not increase in increments of exactly 1.
- **M4** `createMigrationIds(sequenceId, { name: version })` returns `{ name: 'sequenceId/version' }` for each entry. `parseMigrationId('seq/3')` returns `{ sequenceId: 'seq', version: 3 }`.
- **M5** `createRecordMigrationSequence` produces record-scope migrations filtered to the given `recordType`; a per-migration `filter` and a sequence-level `filter` compose (all must pass).
## 17. Migrations: sorting (MS)
- **MS1** `sortMigrations` orders migrations within a sequence by version (`foo/1` before `foo/2`), regardless of input order.
- **MS2** A migration with `dependsOn` sorts after all of its dependencies, across sequences. A dependency that is also the implicit predecessor in the sequence, or that is listed more than once, is redundant rather than circular.
- **MS3** Among valid orderings, a migration that others explicitly depend on is scheduled close to (immediately before) its dependents.
- **MS4** Circular dependencies (direct or via the implicit sequence order) throw.
## 18. Migrations: selection (MG)
`getMigrationsSince(persistedSchema)` decides which migrations a snapshot still needs.
- **MG1** For a sequence present in both the persisted schema and the current schema, the migrations after the persisted version are returned, in sorted (MS) order. A persisted version of 0 selects the whole sequence.
- **MG2** A sequence already at the current version contributes nothing; if no sequence contributes anything the result is the empty array.
- **MG3** Sequences in the persisted schema that the current schema does not know are ignored.
- **MG4** A sequence missing from the persisted schema is included in full if it is `retroactive`, and skipped entirely if not.
- **MG5** A persisted version that does not exist in the current sequence produces an error result (`Incompatible schema?`).
- **MG6** Results (success or error) are cached per persisted-schema object identity: calling again with the same object returns the same array instance.
- **MG7** v1 persisted schemas are upgraded (SC5) before comparison.
## 19. Migrations: applying to records (MP)
- **MP1** `migratePersistedRecord(record, persistedSchema, 'up')` applies the needed record-scope migrations in order and returns `{ type: 'success', value }`. The input record is not mutated.
- **MP2** With no migrations to apply, the input record itself is returned as the success value.
- **MP3** A migrator may mutate its argument in place (returning nothing) or return a new record; both work, including in the same sequence.
- **MP4** A migration `filter` skips non-matching records (the migration applies to others).
- **MP5** If any needed migration is store- or storage-scope, the result is an error: `target-version-too-new` going up, `target-version-too-old` going down.
- **MP6** Going `'down'` requires every needed migration to have a `down` migrator (else `target-version-too-old`) and applies them in reverse order.
- **MP7** A migrator that throws produces a `migration-error` result rather than propagating.
## 20. Migrations: applying to snapshots and storage (MA)
- **MA1** `migrateStoreSnapshot(snapshot)` migrates every record and returns the new store object; the input snapshot is not modified unless `mutateInputStore: true`, in which case `snapshot.store` is updated in place (including deletions) and returned. (In dev builds the input snapshot's records are deep-frozen as a side effect of migration, even without `mutateInputStore` — their contents are unchanged, but they become immutable.)
- **MA2** With no migrations to apply, the snapshot's store is returned as-is.
- **MA3** Store-scope migrations receive the whole record map and may add, change, and delete records.
- **MA4** Storage-scope migrations receive a `SynchronousStorage` (get/set/delete/keys/values/entries) and may use it to read and write records directly.
- **MA5** When migrations are applied, records whose type's scope is not `'document'` are removed from the result (legacy cleanup). A snapshot needing no migrations keeps such records.
- **MA6** An unknown record type encountered during migration, or a migrator that throws, produces a `migration-error` result.
- **MA7** `migrateStorage(storage)` applies the same process to external storage, writing the current serialized schema via `setSchema` and updating only records that actually changed (deep equality).
## 21. Integrity (IC)
- **IC1** `ensureStoreIsUsable()` creates the schema's integrity checker on first use and runs it; it runs automatically after `mergeRemoteChanges` and during `loadStoreSnapshot`.
- **IC2** `markAsPossiblyCorrupted()` sets a flag readable via `isPossiblyCorrupted()`; new stores start unflagged.
## 22. AtomMap (AM)
`AtomMap` is the store's reactive record container: a drop-in `Map` whose reads are reactive.
- **AM1** `get`/`has` are reactive per key: an effect reading a present key re-runs when that key's value changes or the key is deleted, but not when other keys are set, updated, or deleted.
- **AM2** Reading an absent key subscribes to the map's key set, so the reader re-runs when that key is later added (a reader of an absent key may also re-run when the key set otherwise changes — per-key isolation applies to present keys).
- **AM3** `set` adds or updates and returns the map. `update(key, fn)` replaces an existing value and throws for a missing key.
- **AM4** `delete` returns whether the key existed. `deleteMany(keys)` deletes in one transaction (one reaction for the whole batch), returns the `[key, value]` pairs actually deleted, and ignores missing keys.
- **AM5** `clear()` empties the map.
- **AM6** `entries`, `keys`, `values`, `forEach`, `[Symbol.iterator]`, and `size` see exactly the live entries and are reactive. `forEach` honors `thisArg`. As with `Map`, an entry deleted during iteration before it is visited is skipped.
- **AM7** Changes made inside a rolled-back `@tldraw/state` transaction are restored: additions disappear, updates revert, deletions reappear.
- **AM8** `Object.prototype.toString.call(map)` is `[object AtomMap]`.
- **AM9** `getOrInsert(key, defaultValue)` and `getOrInsertComputed(key, callback)` return the existing value for a present key, and otherwise insert and return the new value. `callback` runs only when the key is absent.
## 23. AtomSet (AS)
- **AS1** `AtomSet` behaves as a reactive set: `add` (returns the set), `has`, `delete` (returns whether present), `clear`, `size`, `forEach`, `keys`/`values`/iteration yield the elements, and `entries` yields `[value, value]` pairs.
- **AS2** Membership reads are reactive: an effect checking `has(x)` re-runs when `x` is added or removed.
## 24. devFreeze (DF)
- **DF1** In dev/test builds, `devFreeze(object)` recursively freezes the object and its nested objects and returns it; mutation afterwards throws in strict mode.
- **DF2** In dev/test builds it throws (`cannot include non-js data in a record`) for objects with non-plain prototypes (class instances, `Map`s, etc.); arrays, plain objects, null-prototype objects, and structured-clone objects are allowed.
- **DF3** In production builds it returns the object unchanged, without freezing or prototype validation.
## 25. Set utilities (SU)
- **SU1** `intersectSets(sets)` returns the elements present in every set; `[]` yields an empty set; a single set yields a copy.
- **SU2** `diffSets(prev, next)` returns `{ added?, removed? }` with only the populated keys, or `undefined` when the sets have the same members. Membership is by reference for objects.
## 26. IncrementalSetConstructor — internal (ISC)
- **ISC1** `get()` returns `undefined` when there are no net changes: nothing done, adding already-present items, removing absent items, or add/remove round trips that cancel out.
- **ISC2** Otherwise `get()` returns `{ value, diff }`, where `value` is the new set and `diff` is the `CollectionDiff` relative to the original set, with `added`/`removed` present only when non-empty; the original set is not mutated.
- **ISC3** Re-adding an item removed earlier in the construction restores it (and removes it from `diff.removed`); removing an item added earlier cancels the add.
## 27. ImmutableMap — internal (IM)
`ImmutableMap` is the persistent (HAMT) map under `AtomMap`.
- **IM1** `set` and `delete` return a new map and leave the original unchanged.
- **IM2** `get(k)` returns the value or `undefined`; `get(k, notSetValue)` returns `notSetValue` for missing keys.
- **IM3** Keys may be objects (hashed by identity): distinct object keys with equal contents are distinct keys. Key equality is SameValueZero (`NaN` equals `NaN`, `0` equals `-0`), and string keys named after `Object.prototype` members are ordinary keys. A constructor given duplicate keys keeps the last value.
- **IM4** `withMutations(fn)` batches many changes into one new map; if `fn` changes nothing, the same instance is returned.
- **IM5** `deleteAll(keys)` removes all the given keys.
- **IM6** `entries`/`keys`/`values`/iteration yield every entry exactly once, consistent with `size`.

View File

@@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json",
"extends": "../../internal/config/api-extractor.json"
}

View File

@@ -0,0 +1,707 @@
## API Report File for "@tldraw/store"
> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
```ts
import { Atom } from '@tldraw/state';
import { Computed } from '@tldraw/state';
import { Expand } from '@tldraw/utils';
import { Result } from '@tldraw/utils';
import { Signal } from '@tldraw/state';
import { UNINITIALIZED } from '@tldraw/state';
// @public
export function assertIdType<R extends UnknownRecord>(id: string | undefined, type: RecordType<R, any>): asserts id is IdOf<R>;
// @public
export class AtomMap<K, V> implements Map<K, V> {
[Symbol.iterator](): Generator<[K, V], undefined, unknown>;
[Symbol.toStringTag]: string;
constructor(name: string, entries?: Iterable<readonly [K, V]>);
__unsafe__getWithoutCapture(key: K): undefined | V;
__unsafe__hasWithoutCapture(key: K): boolean;
clear(): void;
delete(key: K): boolean;
deleteMany(keys: Iterable<K>): [K, V][];
entries(): Generator<[K, V], undefined, unknown>;
forEach(callbackfn: (value: V, key: K, map: AtomMap<K, V>) => void, thisArg?: any): void;
get(key: K): undefined | V;
// @internal
getAtom(key: K): Atom<UNINITIALIZED | V> | undefined;
getOrInsert(key: K, defaultValue: V): V;
getOrInsertComputed(key: K, callback: (key: K) => V): V;
has(key: K): boolean;
keys(): Generator<K, undefined, unknown>;
set(key: K, value: V): this;
get size(): number;
update(key: K, updater: (value: V) => V): void;
values(): Generator<V, undefined, unknown>;
}
// @public
export class AtomSet<T> {
// (undocumented)
[Symbol.iterator](): Generator<T, undefined, unknown>;
// (undocumented)
[Symbol.toStringTag]: string;
constructor(name: string, keys?: Iterable<T>);
// (undocumented)
add(value: T): this;
// (undocumented)
clear(): void;
// (undocumented)
delete(value: T): boolean;
// (undocumented)
entries(): Generator<[T, T], undefined, unknown>;
// (undocumented)
forEach(callbackfn: (value: T, value2: T, set: AtomSet<T>) => void, thisArg?: any): void;
// (undocumented)
has(value: T): boolean;
// (undocumented)
keys(): Generator<T, undefined, unknown>;
// (undocumented)
get size(): number;
// (undocumented)
values(): Generator<T, undefined, unknown>;
}
// @public
export interface BaseRecord<TypeName extends string, Id extends RecordId<UnknownRecord>> {
// (undocumented)
readonly id: Id;
// (undocumented)
readonly typeName: TypeName;
}
// @public
export type ChangeSource = 'remote' | 'user';
// @public
export interface CollectionDiff<T> {
added?: Set<T>;
removed?: Set<T>;
}
// @public
export interface ComputedCache<Data, R extends UnknownRecord> {
get(id: IdOf<R>): Data | undefined;
}
// @public
export function createComputedCache<Context extends StoreObject<any>, Result, Record extends StoreObjectRecordType<Context> = StoreObjectRecordType<Context>>(name: string, derive: (context: Context, record: Record) => Result | undefined, opts?: CreateComputedCacheOpts<Result, Record>): {
get(context: Context, id: IdOf<Record>): Result | undefined;
};
// @public
export interface CreateComputedCacheOpts<Data, R extends UnknownRecord> {
areRecordsEqual?(a: R, b: R): boolean;
areResultsEqual?(a: Data, b: Data): boolean;
}
// @internal
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R>;
// @public
export function createMigrationIds<const ID extends string, const Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
[K in keyof Versions]: `${ID}/${Versions[K]}`;
};
// @public
export function createMigrationSequence({ sequence, sequenceId, retroactive, }: {
retroactive?: boolean;
sequence: Array<Migration | StandaloneDependsOn>;
sequenceId: string;
}): MigrationSequence;
// @internal
export function createRecordMigrationSequence(opts: {
filter?(record: UnknownRecord): boolean;
recordType: string;
retroactive?: boolean;
sequence: Omit<Extract<Migration, {
scope: 'record';
}>, 'scope'>[];
sequenceId: string;
}): MigrationSequence;
// @public
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
scope: RecordScope;
validator?: StoreValidator<R>;
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
// @public
export function devFreeze<T>(object: T): T;
// @public
export interface HistoryEntry<R extends UnknownRecord = UnknownRecord> {
changes: RecordsDiff<R>;
source: ChangeSource;
}
// @public
export type IdOf<R extends UnknownRecord> = R['id'];
// @internal
export class IncrementalSetConstructor<T> {
constructor(
previousValue: Set<T>);
// @public
add(item: T): void;
// @public
get(): {
diff: CollectionDiff<T>;
value: Set<T>;
} | undefined;
// @public
remove(item: T): void;
}
// @public
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>): boolean;
// @public
export interface LegacyBaseMigrationsInfo {
// (undocumented)
currentVersion: number;
// (undocumented)
firstVersion: number;
// (undocumented)
migrators: {
[version: number]: LegacyMigration;
};
}
// @public
export interface LegacyMigration<Before = any, After = any> {
// (undocumented)
down: (newState: After) => Before;
// (undocumented)
up: (oldState: Before) => After;
}
// @public
export interface LegacyMigrations extends LegacyBaseMigrationsInfo {
// (undocumented)
subTypeKey?: string;
// (undocumented)
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>;
}
// @public
export type Migration = {
readonly dependsOn?: readonly MigrationId[] | undefined;
readonly id: MigrationId;
} & ({
readonly down?: (newState: SerializedStore<UnknownRecord>) => SerializedStore<UnknownRecord> | void;
readonly scope: 'store';
readonly up: (oldState: SerializedStore<UnknownRecord>) => SerializedStore<UnknownRecord> | void;
} | {
readonly down?: (newState: UnknownRecord) => UnknownRecord | void;
readonly filter?: (record: UnknownRecord) => boolean;
readonly scope: 'record';
readonly up: (oldState: UnknownRecord) => UnknownRecord | void;
} | {
readonly down?: never;
readonly scope: 'storage';
readonly up: (storage: SynchronousRecordStorage<UnknownRecord>) => void;
});
// @public (undocumented)
export const MigrationFailureReason: {
readonly IncompatibleSubtype: 'incompatible-subtype';
readonly MigrationError: 'migration-error';
readonly TargetVersionTooNew: 'target-version-too-new';
readonly TargetVersionTooOld: 'target-version-too-old';
readonly UnknownType: 'unknown-type';
readonly UnrecognizedSubtype: 'unrecognized-subtype';
};
// @public (undocumented)
export type MigrationFailureReason = (typeof MigrationFailureReason)[keyof typeof MigrationFailureReason];
// @public (undocumented)
export namespace MigrationFailureReason {
// (undocumented)
export type IncompatibleSubtype = typeof MigrationFailureReason.IncompatibleSubtype;
// (undocumented)
export type MigrationError = typeof MigrationFailureReason.MigrationError;
// (undocumented)
export type TargetVersionTooNew = typeof MigrationFailureReason.TargetVersionTooNew;
// (undocumented)
export type TargetVersionTooOld = typeof MigrationFailureReason.TargetVersionTooOld;
// (undocumented)
export type UnknownType = typeof MigrationFailureReason.UnknownType;
// (undocumented)
export type UnrecognizedSubtype = typeof MigrationFailureReason.UnrecognizedSubtype;
}
// @public
export type MigrationId = `${string}/${number}`;
// @public
export type MigrationResult<T> = {
reason: MigrationFailureReason;
type: 'error';
} | {
type: 'success';
value: T;
};
// @public
export interface MigrationSequence {
retroactive: boolean;
// (undocumented)
sequence: Migration[];
// (undocumented)
sequenceId: string;
}
// @internal
export function parseMigrationId(id: MigrationId): {
sequenceId: string;
version: number;
};
// @public (undocumented)
export type QueryExpression<R extends object> = {
[k in keyof R & string]?: R[k] extends boolean | null | number | string | undefined ? QueryValueMatcher<R[k]> : R[k] extends object ? QueryExpression<R[k]> : QueryValueMatcher<R[k]>;
};
// @public
export type QueryValueMatcher<T> = {
eq: T;
} | {
gt: number;
} | {
neq: T;
};
// @public
export type RecordFromId<K extends RecordId<UnknownRecord>> = K extends RecordId<infer R> ? R : never;
// @public
export type RecordId<R extends UnknownRecord> = string & {
__type__: R;
};
// @public
export type RecordScope = 'document' | 'presence' | 'session';
// @public
export interface RecordsDiff<R extends UnknownRecord> {
added: Record<IdOf<R>, R>;
removed: Record<IdOf<R>, R>;
updated: Record<IdOf<R>, [from: R, to: R]>;
}
// @public
export class RecordType<R extends UnknownRecord, RequiredProperties extends keyof Omit<R, 'id' | 'typeName'>> {
constructor(
typeName: R['typeName'], config: {
readonly createDefaultProperties: () => Exclude<Omit<R, 'id' | 'typeName'>, RequiredProperties>;
readonly ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
readonly scope?: RecordScope;
readonly validator?: StoreValidator<R>;
});
clone(record: R): R;
create(properties: Expand<Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>>): R;
readonly createDefaultProperties: () => Exclude<Omit<R, 'id' | 'typeName'>, RequiredProperties>;
createId(customUniquePart?: string): IdOf<R>;
readonly ephemeralKeys?: {
readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean;
};
readonly ephemeralKeySet: ReadonlySet<string>;
isId(id?: string): id is IdOf<R>;
isInstance(record?: UnknownRecord): record is R;
parseId(id: IdOf<R>): string;
readonly scope: RecordScope;
readonly typeName: R['typeName'];
validate(record: unknown, recordBefore?: R): R;
readonly validator: StoreValidator<R>;
withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>;
}
// @public
export function reverseRecordsDiff(diff: RecordsDiff<any>): RecordsDiff<any>;
// @public
export type RSIndex<R extends UnknownRecord> = Computed<RSIndexMap<R>, RSIndexDiff<R>>;
// @public
export type RSIndexDiff<R extends UnknownRecord> = Map<any, CollectionDiff<IdOf<R>>>;
// @public
export type RSIndexMap<R extends UnknownRecord> = Map<any, Set<IdOf<R>>>;
// @public
export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2;
// @public
export interface SerializedSchemaV1 {
recordVersions: Record<string, {
subTypeKey: string;
subTypeVersions: Record<string, number>;
version: number;
} | {
version: number;
}>;
schemaVersion: 1;
storeVersion: number;
}
// @public
export interface SerializedSchemaV2 {
// (undocumented)
schemaVersion: 2;
// (undocumented)
sequences: {
[sequenceId: string]: number;
};
}
// @public
export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
// @public
export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>[], options?: {
mutateFirstDiff?: boolean;
}): RecordsDiff<T>;
// @internal
export function squashRecordDiffsMutable<T extends UnknownRecord>(target: RecordsDiff<T>, diffs: RecordsDiff<T>[], fromIndex?: number): void;
// @public
export interface StandaloneDependsOn {
// (undocumented)
readonly dependsOn: readonly MigrationId[];
}
// @public
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
constructor(config: {
props: Props;
id?: string;
schema: StoreSchema<R, Props>;
initialData?: SerializedStore<R>;
});
// @internal (undocumented)
addHistoryInterceptor(fn: (entry: HistoryEntry<R>, source: ChangeSource) => void): () => void;
allRecords(): R[];
// (undocumented)
applyDiff(diff: RecordsDiff<R>, { runCallbacks, ignoreEphemeralKeys, }?: {
ignoreEphemeralKeys?: boolean;
runCallbacks?: boolean;
}): void;
// @internal (undocumented)
atomic<T>(fn: () => T, runCallbacks?: boolean, isMergingRemoteChanges?: boolean): T;
clear(): void;
createCache<Result, Record extends R = R>(create: (id: IdOf<Record>, recordSignal: Signal<R>) => Signal<Result>): {
get: (id: IdOf<Record>) => Result | undefined;
};
createComputedCache<Result, Record extends R = R>(name: string, derive: (record: Record) => Result | undefined, opts?: CreateComputedCacheOpts<Result, Record>): ComputedCache<Result, Record>;
// (undocumented)
dispose(): void;
// @internal (undocumented)
ensureStoreIsUsable(): void;
extractingChanges(fn: () => void): RecordsDiff<R>;
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
added: { [K in IdOf<R>]: R; };
removed: { [K in IdOf<R>]: R; };
updated: { [K in IdOf<R>]: [from: R, to: R]; };
} | null;
// (undocumented)
_flushHistory(): void;
get<K extends IdOf<R>>(id: K): RecordFromId<K> | undefined;
getStoreSnapshot(scope?: 'all' | RecordScope): StoreSnapshot<R>;
has<K extends IdOf<R>>(id: K): boolean;
readonly history: Atom<number, RecordsDiff<R>>;
readonly id: string;
// @internal (undocumented)
isPossiblyCorrupted(): boolean;
listen(onHistory: StoreListener<R>, filters?: Partial<StoreListenerFilters>): () => void;
loadStoreSnapshot(snapshot: StoreSnapshot<R>): void;
// @internal (undocumented)
markAsPossiblyCorrupted(): void;
mergeRemoteChanges(fn: () => void): void;
migrateSnapshot(snapshot: StoreSnapshot<R>): StoreSnapshot<R>;
readonly props: Props;
put(records: R[], phaseOverride?: 'initialize'): void;
readonly query: StoreQueries<R>;
remove(ids: IdOf<R>[]): void;
readonly schema: StoreSchema<R, Props>;
readonly scopedTypes: {
readonly [K in RecordScope]: ReadonlySet<R['typeName']>;
};
serialize(scope?: 'all' | RecordScope): SerializedStore<R>;
readonly sideEffects: StoreSideEffects<R>;
unsafeGetWithoutCapture<K extends IdOf<R>>(id: K): RecordFromId<K> | undefined;
update<K extends IdOf<R>>(id: K, updater: (record: RecordFromId<K>) => RecordFromId<K>): void;
// (undocumented)
validate(phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord'): void;
}
// @public
export type StoreAfterChangeHandler<R extends UnknownRecord> = (prev: R, next: R, source: 'remote' | 'user') => void;
// @public
export type StoreAfterCreateHandler<R extends UnknownRecord> = (record: R, source: 'remote' | 'user') => void;
// @public
export type StoreAfterDeleteHandler<R extends UnknownRecord> = (record: R, source: 'remote' | 'user') => void;
// @public
export type StoreBeforeChangeHandler<R extends UnknownRecord> = (prev: R, next: R, source: 'remote' | 'user') => R;
// @public
export type StoreBeforeCreateHandler<R extends UnknownRecord> = (record: R, source: 'remote' | 'user') => R;
// @public
export type StoreBeforeDeleteHandler<R extends UnknownRecord> = (record: R, source: 'remote' | 'user') => false | void;
// @public
export interface StoreError {
error: Error;
isExistingValidationIssue: boolean;
phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord';
recordAfter: unknown;
recordBefore?: unknown;
}
// @public
export type StoreListener<R extends UnknownRecord> = (entry: HistoryEntry<R>) => void;
// @public
export interface StoreListenerFilters {
scope: 'all' | RecordScope;
source: 'all' | ChangeSource;
}
// @public
export type StoreObject<R extends UnknownRecord> = {
store: Store<R>;
} | Store<R>;
// @public
export type StoreObjectRecordType<Context extends StoreObject<any>> = Context extends Store<infer R> ? R : Context extends {
store: Store<infer R>;
} ? R : never;
// @public
export type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void;
// @public
export class StoreQueries<R extends UnknownRecord> {
// @internal
constructor(recordMap: AtomMap<IdOf<R>, R>, history: Atom<number, RecordsDiff<R>>);
// @internal
__uncached_createIndex<TypeName extends R['typeName']>(typeName: TypeName, path: string): RSIndex<Extract<R, {
typeName: TypeName;
}>>;
exec<TypeName extends R['typeName']>(typeName: TypeName, query: QueryExpression<Extract<R, {
typeName: TypeName;
}>>): Array<Extract<R, {
typeName: TypeName;
}>>;
filterHistory<TypeName extends R['typeName']>(typeName: TypeName): Computed<number, RecordsDiff<Extract<R, {
typeName: TypeName;
}>>>;
// @internal (undocumented)
getAllIdsForType<TypeName extends R['typeName']>(typeName: TypeName): Set<IdOf<Extract<R, {
typeName: TypeName;
}>>>;
// @internal (undocumented)
getRecordById<TypeName extends R['typeName']>(typeName: TypeName, id: IdOf<Extract<R, {
typeName: TypeName;
}>>): Extract<R, {
typeName: TypeName;
}> | undefined;
ids<TypeName extends R['typeName']>(typeName: TypeName, queryCreator?: () => QueryExpression<Extract<R, {
typeName: TypeName;
}>>, name?: string): Computed<Set<IdOf<Extract<R, {
typeName: TypeName;
}>>>, CollectionDiff<IdOf<Extract<R, {
typeName: TypeName;
}>>>>;
index<TypeName extends R['typeName']>(typeName: TypeName, path: string): RSIndex<Extract<R, {
typeName: TypeName;
}>>;
record<TypeName extends R['typeName']>(typeName: TypeName, queryCreator?: () => QueryExpression<Extract<R, {
typeName: TypeName;
}>>, name?: string): Computed<Extract<R, {
typeName: TypeName;
}> | undefined>;
records<TypeName extends R['typeName']>(typeName: TypeName, queryCreator?: () => QueryExpression<Extract<R, {
typeName: TypeName;
}>>, name?: string): Computed<Array<Extract<R, {
typeName: TypeName;
}>>>;
}
// @internal
export type StoreRecord<S extends Store<any>> = S extends Store<infer R> ? R : never;
// @public
export class StoreSchema<R extends UnknownRecord, P = unknown> {
static create<R extends UnknownRecord, P = unknown>(types: {
[TypeName in R['typeName']]: {
createId: any;
};
}, options?: StoreSchemaOptions<R, P>): StoreSchema<R, P>;
// @internal
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
// @internal
getType(typeName: string): RecordType<R, any>;
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
// (undocumented)
migrateStorage(storage: SynchronousStorage<R>): void;
migrateStoreSnapshot(snapshot: StoreSnapshot<R>, opts?: {
mutateInputStore?: boolean;
}): MigrationResult<SerializedStore<R>>;
// (undocumented)
readonly migrations: Record<string, MigrationSequence>;
serialize(): SerializedSchemaV2;
// @internal @deprecated
serializeEarliestVersion(): SerializedSchema;
// (undocumented)
readonly sortedMigrations: readonly Migration[];
// (undocumented)
readonly types: {
[Record in R as Record['typeName']]: RecordType<R, any>;
};
validateRecord(store: Store<R>, record: R, phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord', recordBefore: null | R): R;
}
// @public
export interface StoreSchemaOptions<R extends UnknownRecord, P> {
// @internal (undocumented)
createIntegrityChecker?(store: Store<R, P>): void;
// (undocumented)
migrations?: MigrationSequence[];
// (undocumented)
onValidationFailure?(data: StoreValidationFailure<R>): R;
}
// @public
export class StoreSideEffects<R extends UnknownRecord> {
constructor(store: Store<R>);
// @internal
handleAfterChange(prev: R, next: R, source: 'remote' | 'user'): void;
// @internal
handleAfterCreate(record: R, source: 'remote' | 'user'): void;
// @internal
handleAfterDelete(record: R, source: 'remote' | 'user'): void;
// @internal
handleBeforeChange(prev: R, next: R, source: 'remote' | 'user'): R;
// @internal
handleBeforeCreate(record: R, source: 'remote' | 'user'): R;
// @internal
handleBeforeDelete(record: R, source: 'remote' | 'user'): boolean;
// @internal
handleOperationComplete(source: 'remote' | 'user'): void;
// @internal
isEnabled(): boolean;
// @internal
register(handlersByType: {
[T in R as T['typeName']]?: {
afterChange?: StoreAfterChangeHandler<T>;
afterCreate?: StoreAfterCreateHandler<T>;
afterDelete?: StoreAfterDeleteHandler<T>;
beforeChange?: StoreBeforeChangeHandler<T>;
beforeCreate?: StoreBeforeCreateHandler<T>;
beforeDelete?: StoreBeforeDeleteHandler<T>;
};
}): () => void;
registerAfterChangeHandler<T extends R['typeName']>(typeName: T, handler: StoreAfterChangeHandler<R & {
typeName: T;
}>): () => void;
registerAfterCreateHandler<T extends R['typeName']>(typeName: T, handler: StoreAfterCreateHandler<R & {
typeName: T;
}>): () => void;
registerAfterDeleteHandler<T extends R['typeName']>(typeName: T, handler: StoreAfterDeleteHandler<R & {
typeName: T;
}>): () => void;
registerBeforeChangeHandler<T extends R['typeName']>(typeName: T, handler: StoreBeforeChangeHandler<R & {
typeName: T;
}>): () => void;
registerBeforeCreateHandler<T extends R['typeName']>(typeName: T, handler: StoreBeforeCreateHandler<R & {
typeName: T;
}>): () => void;
registerBeforeDeleteHandler<T extends R['typeName']>(typeName: T, handler: StoreBeforeDeleteHandler<R & {
typeName: T;
}>): () => void;
registerOperationCompleteHandler(handler: StoreOperationCompleteHandler): () => void;
// @internal
setIsEnabled(enabled: boolean): void;
}
// @public
export interface StoreSnapshot<R extends UnknownRecord> {
schema: SerializedSchema;
store: SerializedStore<R>;
}
// @public
export interface StoreValidationFailure<R extends UnknownRecord> {
// (undocumented)
error: unknown;
// (undocumented)
phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord';
// (undocumented)
record: R;
// (undocumented)
recordBefore: null | R;
// (undocumented)
store: Store<R>;
}
// @public
export interface StoreValidator<R extends UnknownRecord> {
validate(record: unknown): R;
validateUsingKnownGoodVersion?(knownGoodVersion: R, record: unknown): R;
}
// @public
export type StoreValidators<R extends UnknownRecord> = {
[K in R['typeName']]: StoreValidator<Extract<R, {
typeName: K;
}>>;
};
// @public
export interface SynchronousRecordStorage<R extends UnknownRecord> {
// (undocumented)
delete(id: string): void;
// (undocumented)
entries(): Iterable<[string, R]>;
// (undocumented)
get(id: string): R | undefined;
// (undocumented)
keys(): Iterable<string>;
// (undocumented)
set(id: string, record: R): void;
// (undocumented)
values(): Iterable<R>;
}
// @public
export interface SynchronousStorage<R extends UnknownRecord> extends SynchronousRecordStorage<R> {
// (undocumented)
getSchema(): SerializedSchema;
// (undocumented)
setSchema(schema: SerializedSchema): void;
}
// @public
export type UnknownRecord = BaseRecord<string, RecordId<UnknownRecord>>;
// (No @packageDocumentation comment for this package)
```

View File

@@ -0,0 +1,67 @@
{
"name": "@tldraw/store",
"description": "tldraw infinite canvas SDK (store).",
"version": "5.4.2",
"author": {
"name": "tldraw Inc.",
"email": "hello@tldraw.com"
},
"homepage": "https://tldraw.dev",
"license": "MIT",
"tldraw_product": {
"stableId": "tldraw:sdk-core",
"name": "SDK",
"type": "product",
"premium": false
},
"repository": {
"type": "git",
"url": "https://github.com/tldraw/tldraw"
},
"bugs": {
"url": "https://github.com/tldraw/tldraw/issues"
},
"keywords": [
"tldraw",
"sdk",
"drawing",
"app",
"development",
"whiteboard",
"canvas",
"infinite"
],
"/* NOTE */": "These `main` and `types` fields are rewritten by the build script. They are not the actual values we publish",
"main": "./src/index.ts",
"types": "./.tsbuild/index.d.ts",
"type": "module",
"/* GOTCHA */": "files will include ./dist and index.d.ts by default, add any others you want to include in here",
"files": [],
"scripts": {
"test-ci": "yarn run -T vitest run --passWithNoTests",
"test": "yarn run -T vitest --passWithNoTests",
"test-coverage": "yarn run -T vitest run --coverage --passWithNoTests",
"build": "yarn run -T tsx ../../internal/scripts/build-package.ts",
"build-api": "yarn run -T tsx ../../internal/scripts/build-api.ts",
"prepack": "yarn run -T tsx ../../internal/scripts/prepack.ts",
"postpack": "../../internal/scripts/postpack.sh",
"pack-tarball": "yarn pack",
"lint": "yarn run -T tsx ../../internal/scripts/lint.ts"
},
"dependencies": {
"@tldraw/state": "workspace:*",
"@tldraw/utils": "workspace:*"
},
"peerDependencies": {
"react": "^18.2.0 || ^19.2.1"
},
"devDependencies": {
"@peculiar/webcrypto": "^1.5.0",
"lazyrepo": "0.0.0-alpha.27",
"raf": "^3.4.1",
"vitest": "^5.0.0"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@@ -0,0 +1 @@
global.crypto ??= new (require('@peculiar/webcrypto').Crypto)()

View File

@@ -0,0 +1,77 @@
import { registerTldrawLibraryVersion } from '@tldraw/utils'
export { AtomMap } from './lib/AtomMap'
export { AtomSet } from './lib/AtomSet'
export type { BaseRecord, IdOf, RecordId, UnknownRecord } from './lib/BaseRecord'
export { devFreeze } from './lib/devFreeze'
export { type QueryExpression, type QueryValueMatcher } from './lib/executeQuery'
export { IncrementalSetConstructor } from './lib/IncrementalSetConstructor'
export {
createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
MigrationFailureReason,
parseMigrationId,
type LegacyBaseMigrationsInfo,
type LegacyMigration,
type LegacyMigrations,
type Migration,
type MigrationId,
type MigrationResult,
type MigrationSequence,
type StandaloneDependsOn,
type SynchronousRecordStorage,
type SynchronousStorage,
} from './lib/migrate'
export {
createEmptyRecordsDiff,
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffs,
squashRecordDiffsMutable,
type RecordsDiff,
} from './lib/RecordsDiff'
export { assertIdType, createRecordType, RecordType, type RecordScope } from './lib/RecordType'
export {
createComputedCache,
Store,
type ChangeSource,
type CollectionDiff,
type ComputedCache,
type CreateComputedCacheOpts,
type HistoryEntry,
type RecordFromId,
type SerializedStore,
type StoreError,
type StoreListener,
type StoreListenerFilters,
type StoreObject,
type StoreObjectRecordType,
type StoreRecord,
type StoreSnapshot,
type StoreValidator,
type StoreValidators,
} from './lib/Store'
export { StoreQueries, type RSIndex, type RSIndexDiff, type RSIndexMap } from './lib/StoreQueries'
export { StoreSchema, type StoreValidationFailure } from './lib/StoreSchema'
export type {
SerializedSchema,
SerializedSchemaV1,
SerializedSchemaV2,
StoreSchemaOptions,
} from './lib/StoreSchema'
export {
StoreSideEffects,
type StoreAfterChangeHandler,
type StoreAfterCreateHandler,
type StoreAfterDeleteHandler,
type StoreBeforeChangeHandler,
type StoreBeforeCreateHandler,
type StoreBeforeDeleteHandler,
type StoreOperationCompleteHandler,
} from './lib/StoreSideEffects'
registerTldrawLibraryVersion(
(globalThis as any).TLDRAW_LIBRARY_NAME,
(globalThis as any).TLDRAW_LIBRARY_VERSION,
(globalThis as any).TLDRAW_LIBRARY_MODULES
)

View File

@@ -0,0 +1,663 @@
import { react, transaction } from '@tldraw/state'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AtomMap } from './AtomMap'
// Tests for SPEC.md §22 (AtomMap).
// Rule IDs like [AM1] in test names refer to that document.
describe('AtomMap', () => {
let cleanupFns: (() => void)[] = []
afterEach(() => {
for (const fn of cleanupFns) {
fn()
}
cleanupFns = []
})
function testReactor(name: string, fn: () => any) {
const cb = vi.fn(fn)
const cleanup = react(name, cb)
cleanupFns.push(() => cleanup())
return cb
}
describe('get', () => {
it('[AM3] returns the value or undefined', () => {
const map = new AtomMap('test')
expect(map.get('a')).toBe(undefined)
map.set('a', 1)
expect(map.get('a')).toBe(1)
map.delete('a')
expect(map.get('a')).toBe(undefined)
})
it('[AM1] [AM2] reacts to creating, updating, and deleting the read key', () => {
const map = new AtomMap('test', [['a', 1]])
const reactor = testReactor('test', () => map.get('b'))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith(undefined)
map.set('b', 2)
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith(2)
map.set('b', 3)
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith(3)
map.delete('b')
expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveLastReturnedWith(undefined)
})
it('[AM1] does not react to changes in other keys', () => {
const map = new AtomMap('test', [['a', 1]])
const aReactor = testReactor('a-reactor', () => map.get('a'))
expect(aReactor).toHaveBeenCalledTimes(1)
// setting, updating, and deleting other keys does not trigger a reaction
map.set('b', 1)
map.set('c', 2)
map.set('b', 3)
map.set('c', 4)
map.delete('b')
map.delete('c')
expect(aReactor).toHaveBeenCalledTimes(1)
// but setting the watched key does
map.set('a', 5)
expect(aReactor).toHaveBeenCalledTimes(2)
})
})
describe('has', () => {
it('[AM3] reports whether the key has a value', () => {
const map = new AtomMap('test')
expect(map.has('a')).toBe(false)
map.set('a', 1)
expect(map.has('a')).toBe(true)
map.delete('a')
expect(map.has('a')).toBe(false)
})
it('[AM1] [AM2] reacts to creating, updating, and deleting the checked key', () => {
const map = new AtomMap('test')
const reactor = testReactor('test', () => map.has('a'))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith(false)
map.set('a', 1)
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith(true)
map.set('a', 2)
expect(reactor).toHaveBeenCalledTimes(3) // setting a value triggers a reaction
expect(reactor).toHaveLastReturnedWith(true)
map.delete('a')
expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveLastReturnedWith(false)
map.set('a', 2)
expect(reactor).toHaveBeenCalledTimes(5) // reinstating a previously deleted key works
expect(reactor).toHaveLastReturnedWith(true)
})
it('[AM1] does not react to changes in other keys', () => {
const map = new AtomMap('test', [['a', 1]])
const aReactor = testReactor('a-reactor', () => map.has('a'))
expect(aReactor).toHaveBeenCalledTimes(1)
map.set('b', 1)
map.set('c', 2)
map.set('b', 3)
map.set('c', 4)
map.delete('b')
map.delete('c')
expect(aReactor).toHaveBeenCalledTimes(1)
map.set('a', 5)
expect(aReactor).toHaveBeenCalledTimes(2)
})
})
describe('set and update', () => {
it('[AM3] set adds or updates and returns the map', () => {
const map = new AtomMap('test')
expect(map.set('a', 1)).toBe(map)
expect(map.get('a')).toBe(1)
map.set('a', 2)
expect(map.get('a')).toBe(2)
})
it('[AM3] update replaces an existing value', () => {
const map = new AtomMap('test', [['count', 5]])
map.update('count', (n) => n + 1)
expect(map.get('count')).toBe(6)
})
it('[AM3] update throws for a missing key', () => {
const map = new AtomMap<string, number>('test')
expect(() => map.update('missing', (n) => n + 1)).toThrow('AtomMap: key missing not found')
})
})
describe('getOrInsert and getOrInsertComputed', () => {
it('[AM9] returns the existing value for a present key', () => {
const map = new AtomMap('test', [['a', 1]])
expect(map.getOrInsert('a', 2)).toBe(1)
expect(map.getOrInsertComputed('a', () => 3)).toBe(1)
expect(map.get('a')).toBe(1)
})
it('[AM9] inserts and returns the new value for an absent key', () => {
const map = new AtomMap<string, number>('test')
expect(map.getOrInsert('a', 1)).toBe(1)
expect(map.get('a')).toBe(1)
expect(map.getOrInsertComputed('b', (key) => key.length)).toBe(1)
expect(map.get('b')).toBe(1)
})
it('[AM9] only runs the callback when the key is absent', () => {
const map = new AtomMap<string, number>('test')
const callback = vi.fn(() => 1)
map.getOrInsertComputed('a', callback)
expect(callback).toHaveBeenCalledTimes(1)
map.getOrInsertComputed('a', callback)
expect(callback).toHaveBeenCalledTimes(1)
})
it('[AM9] reacts to a later insert of a key that was absent', () => {
const map = new AtomMap<string, number>('test')
const cb = testReactor('getOrInsert', () => map.get('a'))
expect(cb).toHaveBeenCalledTimes(1)
map.getOrInsert('a', 1)
expect(cb).toHaveBeenCalledTimes(2)
})
})
describe('delete', () => {
it('[AM4] deletes values and returns whether the key existed', () => {
const map = new AtomMap('test')
expect(map.delete('a')).toBe(false)
map.set('a', 1)
expect(map.delete('a')).toBe(true)
expect(map.has('a')).toBe(false)
expect(map.delete('a')).toBe(false)
})
})
describe('deleteMany', () => {
it('[AM4] deletes multiple values at once', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
['c', 3],
['d', 4],
])
expect(map.size).toBe(4)
const deleted = map.deleteMany(['a', 'c', 'nonexistent'])
expect(deleted).toEqual([
['a', 1],
['c', 3],
])
expect(map.size).toBe(2)
expect(map.has('a')).toBe(false)
expect(map.has('b')).toBe(true)
expect(map.has('c')).toBe(false)
expect(map.has('d')).toBe(true)
expect(Object.fromEntries(map.entries())).toEqual({
b: 2,
d: 4,
})
})
it('[AM4] triggers a single reaction for multiple deletions', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
['c', 3],
])
const sizeReactor = testReactor('size', () => map.size)
const entriesReactor = testReactor('entries', () => Array.from(map.entries()))
const aValueReactor = testReactor('a-value', () => map.get('a'))
const bValueReactor = testReactor('b-value', () => map.get('b'))
expect(sizeReactor).toHaveBeenCalledTimes(1)
expect(entriesReactor).toHaveBeenCalledTimes(1)
expect(aValueReactor).toHaveBeenCalledTimes(1)
expect(bValueReactor).toHaveBeenCalledTimes(1)
map.deleteMany(['a', 'b'])
expect(sizeReactor).toHaveBeenCalledTimes(2)
expect(sizeReactor).toHaveLastReturnedWith(1)
expect(entriesReactor).toHaveBeenCalledTimes(2)
expect(entriesReactor).toHaveLastReturnedWith([['c', 3]])
expect(aValueReactor).toHaveBeenCalledTimes(2)
expect(aValueReactor).toHaveLastReturnedWith(undefined)
expect(bValueReactor).toHaveBeenCalledTimes(2)
expect(bValueReactor).toHaveLastReturnedWith(undefined)
})
it('[AM4] does nothing if no keys are provided', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
const reactor = testReactor('test', () => map.size)
expect(reactor).toHaveBeenCalledTimes(1)
const deleted = map.deleteMany([])
expect(deleted).toEqual([])
expect(reactor).toHaveBeenCalledTimes(1)
expect(map.size).toBe(2)
})
it('[AM4] does nothing if none of the keys exist', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
const reactor = testReactor('test', () => map.size)
expect(reactor).toHaveBeenCalledTimes(1)
const deleted = map.deleteMany(['c', 'd'])
expect(deleted).toEqual([])
expect(reactor).toHaveBeenCalledTimes(1)
expect(map.size).toBe(2)
})
it('[AM4] returns only the entries that were actually deleted', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
['c', 3],
])
const deleted = map.deleteMany(['a', 'nonexistent1', 'b', 'nonexistent2'])
expect(deleted).toEqual([
['a', 1],
['b', 2],
])
expect(map.size).toBe(1)
expect(Array.from(map.entries())).toEqual([['c', 3]])
})
it('[AM4] does not include entries that were already deleted', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
map.delete('a')
const deleted = map.deleteMany(['a', 'b'])
expect(deleted).toEqual([['b', 2]])
})
it('[AM1] [AM4] does not trigger reactions for unaffected keys', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
['c', 3],
['d', 4],
])
const aReactor = testReactor('a-reactor', () => map.get('a'))
const bReactor = testReactor('b-reactor', () => map.get('b'))
const cReactor = testReactor('c-reactor', () => map.get('c'))
const dReactor = testReactor('d-reactor', () => map.get('d'))
expect(aReactor).toHaveBeenCalledTimes(1)
expect(bReactor).toHaveBeenCalledTimes(1)
expect(cReactor).toHaveBeenCalledTimes(1)
expect(dReactor).toHaveBeenCalledTimes(1)
map.deleteMany(['b', 'd'])
// a and c should not react
expect(aReactor).toHaveBeenCalledTimes(1)
expect(cReactor).toHaveBeenCalledTimes(1)
// b and d should react
expect(bReactor).toHaveBeenCalledTimes(2)
expect(bReactor).toHaveLastReturnedWith(undefined)
expect(dReactor).toHaveBeenCalledTimes(2)
expect(dReactor).toHaveLastReturnedWith(undefined)
})
})
describe('clear', () => {
it('[AM5] removes all values', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
expect(map.size).toBe(2)
map.clear()
expect(map.size).toBe(0)
expect(map.has('a')).toBe(false)
expect(map.has('b')).toBe(false)
})
it('[AM5] triggers reactions', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
const sizeReactor = testReactor('test', () => map.size)
expect(sizeReactor).toHaveBeenCalledTimes(1)
expect(sizeReactor).toHaveLastReturnedWith(2)
map.clear()
expect(sizeReactor).toHaveLastReturnedWith(0)
})
})
describe('entries', () => {
it('[AM6] iterates over key-value pairs, excluding deleted entries', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
expect(Array.from(map.entries())).toEqual([
['a', 1],
['b', 2],
])
map.delete('a')
expect(Array.from(map.entries())).toEqual([['b', 2]])
})
it('[AM6] reacts to changes', () => {
const map = new AtomMap('test', [['a', 1]])
const reactor = testReactor('test', () => Array.from(map.entries()))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith([['a', 1]])
map.set('b', 2)
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith([
['a', 1],
['b', 2],
])
map.delete('a')
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith([['b', 2]])
map.set('a', 3)
expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveLastReturnedWith([
['b', 2],
['a', 3],
])
})
it('[AM6] iterating the map itself is the same as entries', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
expect(Array.from(map)).toEqual(Array.from(map.entries()))
})
})
describe('keys', () => {
it('[AM6] iterates over keys, excluding deleted entries', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
expect(Array.from(map.keys())).toEqual(['a', 'b'])
map.delete('a')
expect(Array.from(map.keys())).toEqual(['b'])
})
it('[AM6] reacts to changes', () => {
const map = new AtomMap('test', [['a', 1]])
const reactor = testReactor('test', () => Array.from(map.keys()))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith(['a'])
map.set('b', 2)
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith(['a', 'b'])
map.delete('a')
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith(['b'])
map.set('a', 3)
expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveLastReturnedWith(['b', 'a'])
})
})
describe('values', () => {
it('[AM6] iterates over values, excluding deleted entries', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
expect(Array.from(map.values())).toEqual([1, 2])
map.delete('a')
expect(Array.from(map.values())).toEqual([2])
})
it('[AM6] reacts to changes', () => {
const map = new AtomMap('test', [['a', 1]])
const reactor = testReactor('test', () => Array.from(map.values()))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith([1])
map.set('b', 2)
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith([1, 2])
map.delete('a')
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith([2])
map.set('a', 3)
expect(reactor).toHaveBeenCalledTimes(4)
expect(reactor).toHaveLastReturnedWith([2, 3])
})
})
describe('size', () => {
it('[AM6] counts the live entries', () => {
const map = new AtomMap('test')
expect(map.size).toBe(0)
map.set('a', 1)
expect(map.size).toBe(1)
map.set('b', 2)
expect(map.size).toBe(2)
map.delete('a')
expect(map.size).toBe(1)
map.clear()
expect(map.size).toBe(0)
})
it('[AM6] reacts to changes', () => {
const map = new AtomMap('test')
const reactor = testReactor('test', () => map.size)
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith(0)
map.set('a', 1)
expect(reactor).toHaveLastReturnedWith(1)
map.set('a', 2) // updating keeps the same size
expect(reactor).toHaveLastReturnedWith(1)
map.delete('a')
expect(reactor).toHaveLastReturnedWith(0)
})
})
describe('forEach', () => {
it('[AM6] iterates over live entries', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
map.delete('a')
map.set('c', 3)
const results: Array<[string, number]> = []
map.forEach((value, key, theMap) => {
expect(theMap).toBe(map)
results.push([key, value])
})
expect(results).toEqual([
['b', 2],
['c', 3],
])
})
it('[AM6] reacts to changes', () => {
const map = new AtomMap('test', [['a', 1]])
const reactor = testReactor('test', () => {
const current: Array<[string, number]> = []
map.forEach((value, key) => {
current.push([key, value])
})
return current
})
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith([['a', 1]])
map.set('b', 2)
expect(reactor).toHaveBeenCalledTimes(2)
map.delete('a')
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith([['b', 2]])
})
it('[AM6] uses the provided thisArg', () => {
const map = new AtomMap('test', [['a', 1]])
const thisArg = { test: true }
map.forEach(function (this: any) {
expect(this).toBe(thisArg)
}, thisArg)
})
})
describe('transaction rollbacks', () => {
it('[AM7] rolls back additions', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
transaction((rollback) => {
map.set('c', 3)
rollback()
})
expect(map.size).toBe(2)
expect(map.has('c')).toBe(false)
transaction(() => {
map.set('c', 3)
})
expect(map.size).toBe(3)
expect(map.get('c')).toBe(3)
})
it('[AM7] rolls back updates', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
transaction((rollback) => {
map.set('a', 3)
map.set('b', 4)
rollback()
})
expect(map.get('a')).toBe(1)
expect(map.get('b')).toBe(2)
transaction(() => {
map.set('a', 3)
map.set('b', 4)
})
expect(map.get('a')).toBe(3)
expect(map.get('b')).toBe(4)
})
it('[AM7] rolls back deletions', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
])
transaction((rollback) => {
map.delete('a')
rollback()
})
expect(map.has('a')).toBe(true)
expect(map.size).toBe(2)
transaction(() => {
map.delete('a')
})
expect(map.has('a')).toBe(false)
expect(map.size).toBe(1)
})
})
it('[AM8] has the AtomMap string tag', () => {
const map = new AtomMap('test')
expect(Object.prototype.toString.call(map)).toBe('[object AtomMap]')
})
})
describe('AtomMap: Map parity (AM)', () => {
it('[AM6] an entry deleted before iteration reaches it is skipped, like Map', () => {
const map = new AtomMap('test', [
['a', 1],
['b', 2],
['c', 3],
])
const visited: string[] = []
for (const [key] of map) {
visited.push(key)
if (key === visited[0]) {
// delete the two keys we have not reached yet
for (const other of ['a', 'b', 'c']) if (other !== key) map.delete(other)
}
}
expect(visited).toHaveLength(1)
expect(Array.from(map.keys())).toEqual(visited)
const cleared = new AtomMap('test2', [
['a', 1],
['b', 2],
])
const seen: string[] = []
for (const key of cleared.keys()) {
seen.push(key)
cleared.clear()
}
expect(seen).toHaveLength(1)
})
})

View File

@@ -0,0 +1,465 @@
import { atom, Atom, transact, UNINITIALIZED } from '@tldraw/state'
import { assert } from '@tldraw/utils'
import { emptyMap, ImmutableMap } from './ImmutableMap'
/**
* A drop-in replacement for Map that stores values in atoms and can be used in reactive contexts.
* @public
*/
export class AtomMap<K, V> implements Map<K, V> {
private atoms: Atom<ImmutableMap<K, Atom<V | UNINITIALIZED>>>
/**
* Creates a new AtomMap instance.
*
* name - A unique name for this map, used for atom identification
* entries - Optional initial entries to populate the map with
* @example
* ```ts
* // Create an empty map
* const map = new AtomMap('userMap')
*
* // Create a map with initial data
* const initialData: [string, number][] = [['a', 1], ['b', 2]]
* const mapWithData = new AtomMap('numbersMap', initialData)
* ```
*/
constructor(
private readonly name: string,
entries?: Iterable<readonly [K, V]>
) {
let atoms = emptyMap<K, Atom<V>>()
if (entries) {
atoms = atoms.withMutations((atoms) => {
for (const [k, v] of entries) {
atoms.set(k, atom(`${name}:${String(k)}`, v))
}
})
}
this.atoms = atom(`${name}:atoms`, atoms)
}
/**
* Retrieves the underlying atom for a given key.
*
* @param key - The key to retrieve the atom for
* @returns The atom containing the value, or undefined if the key doesn't exist
* @internal
*/
getAtom(key: K): Atom<V | UNINITIALIZED> | undefined {
const valueAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (!valueAtom) {
// if the value is missing, we want to track whether it's in the present keys set
this.atoms.get()
return undefined
}
return valueAtom
}
/**
* Gets the value associated with a key. Returns undefined if the key doesn't exist.
* This method is reactive and will cause reactive contexts to update when the value changes.
*
* @param key - The key to retrieve the value for
* @returns The value associated with the key, or undefined if not found
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('name', 'Alice')
* console.log(map.get('name')) // 'Alice'
* console.log(map.get('missing')) // undefined
* ```
*/
get(key: K): V | undefined {
const value = this.getAtom(key)?.get()
assert(value !== UNINITIALIZED)
return value
}
/**
* Gets the value associated with a key without creating reactive dependencies.
* This method will not cause reactive contexts to update when the value changes.
*
* @param key - The key to retrieve the value for
* @returns The value associated with the key, or undefined if not found
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('count', 42)
* const value = map.__unsafe__getWithoutCapture('count') // No reactive subscription
* ```
*/
__unsafe__getWithoutCapture(key: K): V | undefined {
const valueAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (!valueAtom) return undefined
const value = valueAtom.__unsafe__getWithoutCapture()
assert(value !== UNINITIALIZED)
return value
}
/**
* Checks whether a key exists in the map.
* This method is reactive and will cause reactive contexts to update when keys are added or removed.
*
* @param key - The key to check for
* @returns True if the key exists in the map, false otherwise
* @example
* ```ts
* const map = new AtomMap('myMap')
* console.log(map.has('name')) // false
* map.set('name', 'Alice')
* console.log(map.has('name')) // true
* ```
*/
has(key: K): boolean {
const valueAtom = this.getAtom(key)
if (!valueAtom) {
return false
}
return valueAtom.get() !== UNINITIALIZED
}
/**
* Checks whether a key exists in the map without creating reactive dependencies.
* This method will not cause reactive contexts to update when keys are added or removed.
*
* @param key - The key to check for
* @returns True if the key exists in the map, false otherwise
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('active', true)
* const exists = map.__unsafe__hasWithoutCapture('active') // No reactive subscription
* ```
*/
__unsafe__hasWithoutCapture(key: K): boolean {
const valueAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (!valueAtom) return false
assert(valueAtom.__unsafe__getWithoutCapture() !== UNINITIALIZED)
return true
}
/**
* Sets a value for the given key. If the key already exists, its value is updated.
* If the key doesn't exist, a new entry is created.
*
* @param key - The key to set the value for
* @param value - The value to associate with the key
* @returns This AtomMap instance for method chaining
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('name', 'Alice').set('age', 30)
* ```
*/
set(key: K, value: V) {
const existingAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (existingAtom) {
existingAtom.set(value)
} else {
this.atoms.update((atoms) => {
return atoms.set(key, atom(`${this.name}:${String(key)}`, value))
})
}
return this
}
/**
* Gets the value associated with a key, inserting `defaultValue` first if the key doesn't exist.
* This method is reactive and will cause reactive contexts to update when the value changes.
*
* @param key - The key to retrieve the value for
* @param defaultValue - The value to insert if the key doesn't exist
* @returns The existing value, or `defaultValue` if the key was missing
* @example
* ```ts
* const map = new AtomMap('myMap')
* console.log(map.getOrInsert('count', 0)) // 0, and 'count' is now set
* console.log(map.getOrInsert('count', 10)) // 0
* ```
*/
getOrInsert(key: K, defaultValue: V): V {
return this.getOrInsertComputed(key, () => defaultValue)
}
/**
* Gets the value associated with a key, inserting the result of `callback` first if the key
* doesn't exist. The callback only runs when the key is missing.
* This method is reactive and will cause reactive contexts to update when the value changes.
*
* @param key - The key to retrieve the value for
* @param callback - Called with the key to produce the value to insert
* @returns The existing value, or the newly computed value if the key was missing
* @example
* ```ts
* const map = new AtomMap<string, string[]>('myMap')
* map.getOrInsertComputed('tags', () => []).push('new')
* ```
*/
getOrInsertComputed(key: K, callback: (key: K) => V): V {
const valueAtom = this.getAtom(key)
if (valueAtom) {
const value = valueAtom.get()
if (value !== UNINITIALIZED) return value
}
const value = callback(key)
this.set(key, value)
return value
}
/**
* Updates an existing value using an updater function.
*
* @param key - The key of the value to update
* @param updater - A function that receives the current value and returns the new value
* @throws Error if the key doesn't exist in the map
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('count', 5)
* map.update('count', count => count + 1) // count is now 6
* ```
*/
update(key: K, updater: (value: V) => V) {
const valueAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (!valueAtom) {
throw new Error(`AtomMap: key ${key} not found`)
}
const value = valueAtom.__unsafe__getWithoutCapture()
assert(value !== UNINITIALIZED)
valueAtom.set(updater(value))
}
/**
* Removes a key-value pair from the map.
*
* @param key - The key to remove
* @returns True if the key existed and was removed, false if it didn't exist
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('temp', 'value')
* console.log(map.delete('temp')) // true
* console.log(map.delete('missing')) // false
* ```
*/
delete(key: K) {
const valueAtom = this.atoms.__unsafe__getWithoutCapture().get(key)
if (!valueAtom) {
return false
}
transact(() => {
valueAtom.set(UNINITIALIZED)
this.atoms.update((atoms) => {
return atoms.delete(key)
})
})
return true
}
/**
* Removes multiple key-value pairs from the map in a single transaction.
*
* @param keys - An iterable of keys to remove
* @returns An array of [key, value] pairs that were actually deleted
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('a', 1).set('b', 2).set('c', 3)
* const deleted = map.deleteMany(['a', 'c', 'missing'])
* console.log(deleted) // [['a', 1], ['c', 3]]
* ```
*/
deleteMany(keys: Iterable<K>): [K, V][] {
return transact(() => {
const deleted: [K, V][] = []
const newAtoms = this.atoms.get().withMutations((atoms) => {
for (const key of keys) {
const valueAtom = atoms.get(key)
if (!valueAtom) continue
const oldValue = valueAtom.get()
assert(oldValue !== UNINITIALIZED)
deleted.push([key, oldValue])
atoms.delete(key)
valueAtom.set(UNINITIALIZED)
}
})
if (deleted.length) {
this.atoms.set(newAtoms)
}
return deleted
})
}
/**
* Removes all key-value pairs from the map.
*
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('a', 1).set('b', 2)
* map.clear()
* console.log(map.size) // 0
* ```
*/
clear() {
return transact(() => {
for (const valueAtom of this.atoms.__unsafe__getWithoutCapture().values()) {
valueAtom.set(UNINITIALIZED)
}
this.atoms.set(emptyMap())
})
}
/**
* Returns an iterator that yields [key, value] pairs for each entry in the map.
* This method is reactive and will cause reactive contexts to update when entries change.
*
* @returns A generator that yields [key, value] tuples
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('a', 1).set('b', 2)
* for (const [key, value] of map.entries()) {
* console.log(`${key}: ${value}`)
* }
* ```
*/
*entries(): Generator<[K, V], undefined, unknown> {
for (const [key, valueAtom] of this.atoms.get()) {
const value = valueAtom.get()
// The key set is a snapshot taken when iteration started, so a key deleted mid-iteration
// still appears in it; skip it, as `Map` skips entries deleted before they are visited.
if (value === UNINITIALIZED) continue
yield [key, value]
}
}
/**
* Returns an iterator that yields all keys in the map.
* This method is reactive and will cause reactive contexts to update when keys change.
*
* @returns A generator that yields keys
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('name', 'Alice').set('age', 30)
* for (const key of map.keys()) {
* console.log(key) // 'name', 'age'
* }
* ```
*/
*keys(): Generator<K, undefined, unknown> {
for (const [key, valueAtom] of this.atoms.get()) {
// see entries(): skip keys deleted mid-iteration
if (valueAtom.__unsafe__getWithoutCapture() === UNINITIALIZED) continue
yield key
}
}
/**
* Returns an iterator that yields all values in the map.
* This method is reactive and will cause reactive contexts to update when values change.
*
* @returns A generator that yields values
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('name', 'Alice').set('age', 30)
* for (const value of map.values()) {
* console.log(value) // 'Alice', 30
* }
* ```
*/
*values(): Generator<V, undefined, unknown> {
for (const valueAtom of this.atoms.get().values()) {
const value = valueAtom.get()
// see entries(): skip keys deleted mid-iteration
if (value === UNINITIALIZED) continue
yield value
}
}
/**
* The number of key-value pairs in the map.
* This property is reactive and will cause reactive contexts to update when the size changes.
*
* @returns The number of entries in the map
* @example
* ```ts
* const map = new AtomMap('myMap')
* console.log(map.size) // 0
* map.set('a', 1)
* console.log(map.size) // 1
* ```
*/
// eslint-disable-next-line tldraw/no-setter-getter
get size() {
return this.atoms.get().size
}
/**
* Executes a provided function once for each key-value pair in the map.
* This method is reactive and will cause reactive contexts to update when entries change.
*
* @param callbackfn - Function to execute for each entry
* - value - The value of the current entry
* - key - The key of the current entry
* - map - The AtomMap being traversed
* @param thisArg - Value to use as `this` when executing the callback
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('a', 1).set('b', 2)
* map.forEach((value, key) => {
* console.log(`${key} = ${value}`)
* })
* ```
*/
forEach(callbackfn: (value: V, key: K, map: AtomMap<K, V>) => void, thisArg?: any): void {
for (const [key, value] of this.entries()) {
callbackfn.call(thisArg, value, key, this)
}
}
/**
* Returns the default iterator for the map, which is the same as entries().
* This allows the map to be used in for...of loops and other iterable contexts.
*
* @returns The same iterator as entries()
* @example
* ```ts
* const map = new AtomMap('myMap')
* map.set('a', 1).set('b', 2)
*
* // These are equivalent:
* for (const [key, value] of map) {
* console.log(`${key}: ${value}`)
* }
*
* for (const [key, value] of map.entries()) {
* console.log(`${key}: ${value}`)
* }
* ```
*/
[Symbol.iterator]() {
return this.entries()
}
/**
* The string tag used by Object.prototype.toString for this class.
*
* @example
* ```ts
* const map = new AtomMap('myMap')
* console.log(Object.prototype.toString.call(map)) // '[object AtomMap]'
* ```
*/
[Symbol.toStringTag] = 'AtomMap'
}

View File

@@ -0,0 +1,116 @@
import { react } from '@tldraw/state'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AtomSet } from './AtomSet'
// Tests for SPEC.md §23 (AtomSet).
// Rule IDs like [AS1] in test names refer to that document.
describe('AtomSet', () => {
let cleanupFns: (() => void)[] = []
afterEach(() => {
for (const fn of cleanupFns) {
fn()
}
cleanupFns = []
})
function testReactor(name: string, fn: () => any) {
const cb = vi.fn(fn)
const cleanup = react(name, cb)
cleanupFns.push(() => cleanup())
return cb
}
it('[AS1] add returns the set; has reports membership', () => {
const set = new AtomSet<string>('test')
expect(set.has('a')).toBe(false)
expect(set.add('a')).toBe(set)
expect(set.has('a')).toBe(true)
// adding again is a no-op
set.add('a')
expect(set.size).toBe(1)
})
it('[AS1] delete returns whether the value was present', () => {
const set = new AtomSet('test', ['a', 'b'])
expect(set.delete('a')).toBe(true)
expect(set.delete('a')).toBe(false)
expect(set.delete('missing')).toBe(false)
expect(set.has('a')).toBe(false)
expect(set.has('b')).toBe(true)
})
it('[AS1] clear empties the set', () => {
const set = new AtomSet('test', ['a', 'b'])
set.clear()
expect(set.size).toBe(0)
expect(set.has('a')).toBe(false)
})
it('[AS1] size counts the elements', () => {
const set = new AtomSet('test', ['a', 'b'])
expect(set.size).toBe(2)
set.add('c')
expect(set.size).toBe(3)
set.delete('a')
expect(set.size).toBe(2)
})
it('[AS1] keys, values, and iteration yield the elements; entries yields [value, value] pairs', () => {
const set = new AtomSet('test', ['a', 'b'])
expect(Array.from(set.keys())).toEqual(['a', 'b'])
expect(Array.from(set.values())).toEqual(['a', 'b'])
expect(Array.from(set)).toEqual(['a', 'b'])
expect(Array.from(set.entries())).toEqual([
['a', 'a'],
['b', 'b'],
])
})
it('[AS1] forEach passes the value twice and the set, and honors thisArg', () => {
const set = new AtomSet('test', ['a', 'b'])
const seen: Array<[string, string]> = []
const thisArg = { marker: true }
set.forEach(function (this: any, value, value2, theSet) {
expect(this).toBe(thisArg)
expect(theSet).toBe(set)
seen.push([value, value2])
}, thisArg)
expect(seen).toEqual([
['a', 'a'],
['b', 'b'],
])
})
it('[AS2] membership reads are reactive', () => {
const set = new AtomSet<string>('test', ['a'])
const reactor = testReactor('has-a', () => set.has('a'))
expect(reactor).toHaveBeenCalledTimes(1)
expect(reactor).toHaveLastReturnedWith(true)
set.delete('a')
expect(reactor).toHaveBeenCalledTimes(2)
expect(reactor).toHaveLastReturnedWith(false)
set.add('a')
expect(reactor).toHaveBeenCalledTimes(3)
expect(reactor).toHaveLastReturnedWith(true)
})
it('[AS2] iteration is reactive', () => {
const set = new AtomSet<string>('test', ['a'])
const reactor = testReactor('all', () => Array.from(set))
expect(reactor).toHaveLastReturnedWith(['a'])
set.add('b')
expect(reactor).toHaveLastReturnedWith(['a', 'b'])
set.delete('a')
expect(reactor).toHaveLastReturnedWith(['b'])
})
})

View File

@@ -0,0 +1,52 @@
import { AtomMap } from './AtomMap'
/**
* A drop-in replacement for Set that stores values in atoms and can be used in reactive contexts.
* @public
*/
export class AtomSet<T> {
private readonly map: AtomMap<T, T>
constructor(
private readonly name: string,
keys?: Iterable<T>
) {
const entries = keys ? Array.from(keys, (k) => [k, k] as const) : undefined
this.map = new AtomMap(name, entries)
}
add(value: T): this {
this.map.set(value, value)
return this
}
clear(): void {
this.map.clear()
}
delete(value: T): boolean {
return this.map.delete(value)
}
forEach(callbackfn: (value: T, value2: T, set: AtomSet<T>) => void, thisArg?: any): void {
for (const value of this) {
callbackfn.call(thisArg, value, value, this)
}
}
has(value: T): boolean {
return this.map.has(value)
}
// eslint-disable-next-line tldraw/no-setter-getter
get size(): number {
return this.map.size
}
entries(): Generator<[T, T], undefined, unknown> {
return this.map.entries()
}
keys(): Generator<T, undefined, unknown> {
return this.map.keys()
}
values(): Generator<T, undefined, unknown> {
return this.map.keys()
}
[Symbol.iterator](): Generator<T, undefined, unknown> {
return this.map.keys()
}
[Symbol.toStringTag]: string = 'AtomSet'
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { isRecord } from './BaseRecord'
// Tests for SPEC.md §2 (records).
// Rule IDs like [R1] in test names refer to that document.
describe('records (R)', () => {
it('[R1] isRecord is true exactly for objects with an id and a typeName', () => {
expect(isRecord({ id: 'book:123', typeName: 'book', title: '1984' })).toBe(true)
expect(isRecord({ id: 'book:123', typeName: 'book' })).toBe(true)
expect(isRecord(null)).toBe(false)
expect(isRecord(undefined)).toBe(false)
expect(isRecord('string')).toBe(false)
expect(isRecord(42)).toBe(false)
expect(isRecord(true)).toBe(false)
expect(isRecord({})).toBe(false)
expect(isRecord({ id: 'test' })).toBe(false)
expect(isRecord({ typeName: 'test' })).toBe(false)
})
it('[R1] isRecord narrows the type of its argument', () => {
const unknownValue: unknown = { id: 'book:123', typeName: 'book', title: '1984' }
if (isRecord(unknownValue)) {
expect(unknownValue.id).toBe('book:123')
expect(unknownValue.typeName).toBe('book')
} else {
throw new Error('expected a record')
}
})
})

View File

@@ -0,0 +1,136 @@
/**
* A branded string type that represents a unique identifier for a record.
* The brand ensures type safety by preventing mixing of IDs between different record types.
*
* @example
* ```ts
* // Define a Book record
* interface Book extends BaseRecord<'book', RecordId<Book>> {
* title: string
* author: string
* }
*
* const bookId: RecordId<Book> = 'book:abc123' as RecordId<Book>
* const authorId: RecordId<Author> = 'author:xyz789' as RecordId<Author>
*
* // TypeScript prevents mixing different record ID types
* // bookId = authorId // Type error!
* ```
*
* @public
*/
export type RecordId<R extends UnknownRecord> = string & { __type__: R }
/**
* Utility type that extracts the ID type from a record type.
* This is useful when you need to work with record IDs without having the full record type.
*
* @example
* ```ts
* interface Book extends BaseRecord<'book', RecordId<Book>> {
* title: string
* author: string
* }
*
* // Extract the ID type from the Book record
* type BookId = IdOf<Book> // RecordId<Book>
*
* function findBook(id: IdOf<Book>): Book | undefined {
* return store.get(id)
* }
* ```
*
* @public
*/
export type IdOf<R extends UnknownRecord> = R['id']
/**
* The base record interface that all records in the store must extend.
* This interface provides the fundamental structure required for all records: a unique ID and a type name.
* The type parameters ensure type safety and prevent mixing of different record types.
*
* @example
* ```ts
* // Define a Book record that extends BaseRecord
* interface Book extends BaseRecord<'book', RecordId<Book>> {
* title: string
* author: string
* publishedYear: number
* }
*
* // Define an Author record
* interface Author extends BaseRecord<'author', RecordId<Author>> {
* name: string
* birthYear: number
* }
*
* // Usage with RecordType
* const Book = createRecordType<Book>('book', { scope: 'document' })
* const book = Book.create({
* title: '1984',
* author: 'George Orwell',
* publishedYear: 1949
* })
* // Results in: { id: 'book:abc123', typeName: 'book', title: '1984', ... }
* ```
*
* @public
*/
export interface BaseRecord<TypeName extends string, Id extends RecordId<UnknownRecord>> {
readonly id: Id
readonly typeName: TypeName
}
/**
* A generic type representing any record that extends BaseRecord.
* This is useful for type constraints when you need to work with records of unknown types,
* but still want to ensure they follow the BaseRecord structure.
*
* @example
* ```ts
* // Function that works with any type of record
* function logRecord(record: UnknownRecord): void {
* console.log(`Record ${record.id} of type ${record.typeName}`)
* }
*
* // Can be used with any record type
* const book: Book = { id: 'book:123' as RecordId<Book>, typeName: 'book', title: '1984' }
* const author: Author = { id: 'author:456' as RecordId<Author>, typeName: 'author', name: 'Orwell' }
*
* logRecord(book) // "Record book:123 of type book"
* logRecord(author) // "Record author:456 of type author"
* ```
*
* @public
*/
export type UnknownRecord = BaseRecord<string, RecordId<UnknownRecord>>
/**
* Type guard function that checks if an unknown value is a valid record.
* A valid record must be an object with both `id` and `typeName` properties.
*
* @param record - The unknown value to check
* @returns `true` if the value is a valid UnknownRecord, `false` otherwise
*
* @example
* ```ts
* const maybeRecord: unknown = { id: 'book:123', typeName: 'book', title: '1984' }
* const notARecord: unknown = { title: '1984', author: 'Orwell' }
* const nullValue: unknown = null
*
* if (isRecord(maybeRecord)) {
* // TypeScript now knows maybeRecord is UnknownRecord
* console.log(maybeRecord.id) // 'book:123'
* console.log(maybeRecord.typeName) // 'book'
* }
*
* console.log(isRecord(maybeRecord)) // true
* console.log(isRecord(notARecord)) // false (missing id and typeName)
* console.log(isRecord(nullValue)) // false
* ```
*
* @public
*/
export function isRecord(record: unknown): record is UnknownRecord {
return typeof record === 'object' && record !== null && 'id' in record && 'typeName' in record
}

View File

@@ -0,0 +1,179 @@
import { describe, expect, it } from 'vitest'
import { ImmutableMap, emptyMap } from './ImmutableMap'
// Tests for SPEC.md §27 (ImmutableMap, internal).
// Rule IDs like [IM1] in test names refer to that document.
describe('ImmutableMap (IM)', () => {
it('[IM1] set returns a new map and leaves the original unchanged', () => {
const map = new ImmutableMap([['key', 'value']])
const newMap = map.set('key', 'newValue')
expect(newMap.get('key')).toBe('newValue')
expect(map.get('key')).toBe('value')
const withExtra = map.set('other', 'x')
expect(withExtra.size).toBe(2)
expect(map.size).toBe(1)
})
it('[IM1] delete returns a new map without the key, leaving the original unchanged', () => {
const map = new ImmutableMap([
['a', 1],
['b', 2],
])
const newMap = map.delete('a')
expect(newMap.size).toBe(1)
expect(newMap.get('a')).toBeUndefined()
expect(newMap.get('b')).toBe(2)
expect(map.size).toBe(2)
expect(map.get('a')).toBe(1)
})
it('[IM2] get returns the value, undefined for missing keys, or the given notSetValue', () => {
const map = new ImmutableMap([['a', 1]])
expect(map.get('a')).toBe(1)
expect(map.get('missing')).toBeUndefined()
expect(map.get('missing', 42)).toBe(42)
expect(map.get('a', 42)).toBe(1)
})
it('[IM3] object keys are hashed by identity', () => {
const obj1 = { id: 1 }
const obj2 = { id: 2 }
let map = new ImmutableMap<{ id: number }, string>()
map = map.set(obj1, 'first')
map = map.set(obj2, 'second')
expect(map.size).toBe(2)
expect(map.get(obj1)).toBe('first')
expect(map.get(obj2)).toBe('second')
expect(map.get({ id: 1 })).toBeUndefined() // structurally equal, but a different key
})
it('[IM3] a constructor given duplicate keys keeps the last value', () => {
const map = new ImmutableMap([
['a', 1],
['a', 2],
['a', 3],
])
expect(map.size).toBe(1)
expect(map.get('a')).toBe(3)
})
it('[IM4] withMutations batches changes into one new map', () => {
const map = new ImmutableMap([['a', 1]])
const newMap = map.withMutations((mutable) => {
mutable.set('b', 2)
mutable.set('c', 3)
mutable.delete('a')
})
expect(newMap.size).toBe(2)
expect(newMap.get('a')).toBeUndefined()
expect(newMap.get('b')).toBe(2)
expect(newMap.get('c')).toBe(3)
expect(map.size).toBe(1)
expect(map.get('a')).toBe(1)
})
it('[IM4] withMutations that changes nothing returns the same instance', () => {
const map = new ImmutableMap([['a', 1]])
const same = map.withMutations(() => {})
expect(same).toBe(map)
})
it('[IM5] deleteAll removes all the given keys', () => {
const map = new ImmutableMap([
['a', 1],
['b', 2],
['c', 3],
])
const newMap = map.deleteAll(['a', 'c', 'missing'])
expect(newMap.size).toBe(1)
expect(newMap.get('a')).toBeUndefined()
expect(newMap.get('b')).toBe(2)
expect(newMap.get('c')).toBeUndefined()
})
it('[IM6] entries, keys, values, and iteration yield every entry exactly once', () => {
const map = new ImmutableMap([
['a', 1],
['b', 2],
['c', 3],
])
const entries = Array.from(map.entries())
expect(entries.length).toBe(map.size)
expect(new Map(entries)).toEqual(
new Map([
['a', 1],
['b', 2],
['c', 3],
])
)
expect(new Set(map.keys())).toEqual(new Set(['a', 'b', 'c']))
expect([...map.values()].sort()).toEqual([1, 2, 3])
expect(Array.from(map)).toEqual(entries)
})
it('[IM6] handles many entries consistently (trie depth)', () => {
let map = new ImmutableMap<number, number>()
for (let i = 0; i < 200; i++) {
map = map.set(i, i * 2)
}
expect(map.size).toBe(200)
for (let i = 0; i < 200; i++) {
expect(map.get(i)).toBe(i * 2)
}
expect(Array.from(map.entries()).length).toBe(200)
})
})
describe('emptyMap (IM)', () => {
it('[IM2] emptyMap has zero size and returns undefined for any key', () => {
const empty = emptyMap()
expect(empty.size).toBe(0)
expect(empty.get('anything')).toBeUndefined()
})
})
describe('ImmutableMap: key hashing and equality (IM)', () => {
it('[IM3] string keys named after Object.prototype members are ordinary keys', () => {
const keys = [
'a',
'b',
'c',
'd',
'e',
'f',
'g',
'h',
'constructor',
'toString',
'__proto__',
'hasOwnProperty',
'',
]
let map = new ImmutableMap<string, number>()
keys.forEach((key, i) => (map = map.set(key, i)))
expect(map.size).toBe(keys.length)
keys.forEach((key, i) => expect(map.get(key)).toBe(i))
expect(new Set(map.keys())).toEqual(new Set(keys))
})
it('[IM3] key equality is SameValueZero: 0 and -0 are one key, NaN equals NaN', () => {
let map = new ImmutableMap<number, string>()
map = map.set(0, 'zero').set(NaN, 'nan')
expect(map.get(-0)).toBe('zero')
expect(map.set(-0, 'neg').size).toBe(2)
expect(map.get(NaN)).toBe('nan')
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,116 @@
import { describe, expect, it } from 'vitest'
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
// Tests for SPEC.md §26 (IncrementalSetConstructor, internal).
// Rule IDs like [ISC1] in test names refer to that document.
describe('IncrementalSetConstructor (ISC)', () => {
it('[ISC1] get returns undefined when nothing was done', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
expect(constructor.get()).toBeUndefined()
})
it('[ISC1] adding already-present items is a no-op', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
constructor.add('a')
constructor.add('b')
expect(constructor.get()).toBeUndefined()
})
it('[ISC1] removing absent items is a no-op', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
constructor.remove('c')
constructor.remove('d')
expect(constructor.get()).toBeUndefined()
})
it('[ISC1] add/remove round trips cancel out', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
constructor.add('d')
constructor.remove('d')
expect(constructor.get()).toBeUndefined()
const constructor2 = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
constructor2.remove('a')
constructor2.add('a')
expect(constructor2.get()).toBeUndefined()
})
it('[ISC2] additions produce the new set and an added diff', () => {
const original = new Set(['a', 'b'])
const constructor = new IncrementalSetConstructor(original)
constructor.add('c')
constructor.add('d')
expect(constructor.get()).toEqual({
value: new Set(['a', 'b', 'c', 'd']),
diff: { added: new Set(['c', 'd']) },
})
expect(original).toEqual(new Set(['a', 'b']))
})
it('[ISC2] removals produce the new set and a removed diff', () => {
const original = new Set(['a', 'b', 'c', 'd'])
const constructor = new IncrementalSetConstructor(original)
constructor.remove('c')
constructor.remove('d')
expect(constructor.get()).toEqual({
value: new Set(['a', 'b']),
diff: { removed: new Set(['c', 'd']) },
})
expect(original).toEqual(new Set(['a', 'b', 'c', 'd']))
})
it('[ISC2] mixed adds and removes report both sides of the diff', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
constructor.remove('a')
constructor.add('d')
constructor.add('e')
expect(constructor.get()).toEqual({
value: new Set(['b', 'c', 'd', 'e']),
diff: { added: new Set(['d', 'e']), removed: new Set(['a']) },
})
})
it('[ISC3] re-adding a removed item restores it', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b', 'c']))
constructor.remove('a')
constructor.remove('b')
constructor.add('d')
constructor.add('a') // restore one removed item
expect(constructor.get()).toEqual({
value: new Set(['a', 'c', 'd']),
diff: { added: new Set(['d']), removed: new Set(['b']) },
})
})
it('[ISC3] removing an item added earlier cancels the add', () => {
const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
constructor.add('c')
constructor.add('d')
constructor.remove('c')
expect(constructor.get()).toEqual({
value: new Set(['a', 'b', 'd']),
diff: { added: new Set(['d']) },
})
})
})
describe('IncrementalSetConstructor: diff shape (ISC)', () => {
it('[ISC2] an add/remove round trip does not leave an empty set in the diff', () => {
const constructor = new IncrementalSetConstructor(new Set(['a']))
constructor.add('b')
constructor.remove('b')
constructor.remove('a')
expect(constructor.get()).toEqual({ value: new Set(), diff: { removed: new Set(['a']) } })
})
})

View File

@@ -0,0 +1,191 @@
import { CollectionDiff } from './Store'
/**
* A utility class for incrementally building a set while tracking changes. This class allows
* you to add and remove items from a set while maintaining a diff of what was added and
* removed from the original set. It's optimized for cases where you need to track changes
* to a set over time and get both the final result and the change delta.
*
* @example
* ```ts
* const originalSet = new Set(['a', 'b', 'c'])
* const constructor = new IncrementalSetConstructor(originalSet)
*
* constructor.add('d') // Add new item
* constructor.remove('b') // Remove existing item
* constructor.add('a') // Re-add removed item (no-op since already present)
*
* const result = constructor.get()
* // result.value contains Set(['a', 'c', 'd'])
* // result.diff contains { added: Set(['d']), removed: Set(['b']) }
* ```
*
* @internal
*/
export class IncrementalSetConstructor<T> {
/**
* The next value of the set.
*
* @internal
*/
private nextValue?: Set<T>
/**
* The diff of the set.
*
* @internal
*/
private diff?: CollectionDiff<T>
constructor(
/**
* The previous value of the set.
*
* @internal
* @readonly
*/
private readonly previousValue: Set<T>
) {}
/**
* Gets the result of the incremental set construction if any changes were made.
* Returns undefined if no additions or removals occurred.
*
* @returns An object containing the final set value and the diff of changes,
* or undefined if no changes were made
*
* @example
* ```ts
* const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
* constructor.add('c')
*
* const result = constructor.get()
* // result = {
* // value: Set(['a', 'b', 'c']),
* // diff: { added: Set(['c']) }
* // }
* ```
*
* @public
*/
public get() {
const numRemoved = this.diff?.removed?.size ?? 0
const numAdded = this.diff?.added?.size ?? 0
if (numRemoved === 0 && numAdded === 0) {
return undefined
}
// An add/remove round trip leaves an empty set behind; drop it so that consumers can treat
// the presence of `added`/`removed` as "there are additions/removals".
if (numAdded === 0) delete this.diff!.added
if (numRemoved === 0) delete this.diff!.removed
return { value: this.nextValue!, diff: this.diff! }
}
/**
* Add an item to the set.
*
* @param item - The item to add.
* @param wasAlreadyPresent - Whether the item was already present in the set.
* @internal
*/
private _add(item: T, wasAlreadyPresent: boolean) {
this.nextValue ??= new Set(this.previousValue)
this.nextValue.add(item)
this.diff ??= {}
if (wasAlreadyPresent) {
this.diff.removed?.delete(item)
} else {
this.diff.added ??= new Set()
this.diff.added.add(item)
}
}
/**
* Adds an item to the set. If the item was already present in the original set
* and was previously removed during this construction, it will be restored.
* If the item is already present and wasn't removed, this is a no-op.
*
* @param item - The item to add to the set
*
* @example
* ```ts
* const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
* constructor.add('c') // Adds new item
* constructor.add('a') // No-op, already present
* constructor.remove('b')
* constructor.add('b') // Restores previously removed item
* ```
*
* @public
*/
add(item: T) {
const wasAlreadyPresent = this.previousValue.has(item)
if (wasAlreadyPresent) {
const wasRemoved = this.diff?.removed?.has(item)
// if it wasn't removed during the lifetime of this set constructor, there's no need to add it again
if (!wasRemoved) return
return this._add(item, wasAlreadyPresent)
}
const isCurrentlyPresent = this.nextValue?.has(item)
// if it's already there, no need to add it again
if (isCurrentlyPresent) return
// otherwise add it
this._add(item, wasAlreadyPresent)
}
/**
* Remove an item from the set.
*
* @param item - The item to remove.
* @param wasAlreadyPresent - Whether the item was already present in the set.
* @internal
*/
private _remove(item: T, wasAlreadyPresent: boolean) {
this.nextValue ??= new Set(this.previousValue)
this.nextValue.delete(item)
this.diff ??= {}
if (wasAlreadyPresent) {
// it was in the original set, so we need to add it to the removed diff
this.diff.removed ??= new Set()
this.diff.removed.add(item)
} else {
// if it was added during the lifetime of this set constructor, we need to remove it from the added diff
this.diff.added?.delete(item)
}
}
/**
* Removes an item from the set. If the item wasn't present in the original set
* and was added during this construction, it will be removed from the added diff.
* If the item is not present at all, this is a no-op.
*
* @param item - The item to remove from the set
*
* @example
* ```ts
* const constructor = new IncrementalSetConstructor(new Set(['a', 'b']))
* constructor.remove('a') // Removes existing item
* constructor.remove('c') // No-op, not present
* constructor.add('d')
* constructor.remove('d') // Removes recently added item
* ```
*
* @public
*/
remove(item: T) {
const wasAlreadyPresent = this.previousValue.has(item)
if (!wasAlreadyPresent) {
const wasAdded = this.diff?.added?.has(item)
// if it wasn't added during the lifetime of this set constructor, there's no need to remove it
if (!wasAdded) return
return this._remove(item, wasAlreadyPresent)
}
const hasAlreadyBeenRemoved = this.diff?.removed?.has(item)
// if it's already removed, no need to remove it again
if (hasAlreadyBeenRemoved) return
// otherwise remove it
this._remove(item, wasAlreadyPresent)
}
}

View File

@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import { assertIdType, createRecordType, RecordType } from './RecordType'
// Tests for SPEC.md §2 (records) and §3 (RecordType).
// Rule IDs like [RT2] in test names refer to that document.
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
living: boolean
age: number
}
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
}
const Author = createRecordType<Author>('author', { scope: 'document' }).withDefaultProperties(
() => ({ living: true, age: 35 })
)
const Book = createRecordType<Book>('book', { scope: 'document' })
describe('creating record types (RT)', () => {
it('[RT1] createRecordType uses the given scope and defaults to pass-through validation', () => {
const Session = createRecordType('session', { scope: 'session' })
expect(Session.scope).toBe('session')
const anything = { id: 'session:1', typeName: 'session', whatever: NaN }
expect(Session.validator.validate(anything)).toBe(anything)
})
it('[RT1] the RecordType constructor defaults scope to document', () => {
const type = new RecordType<Book, never>('book', {
createDefaultProperties: () => ({}) as any,
})
expect(type.scope).toBe('document')
})
it('[RT1] createRecordType uses the given validator', () => {
const validator = {
validate: (r: unknown) => {
if ((r as Book).title === 'invalid') throw new Error('bad')
return r as Book
},
}
const Strict = createRecordType<Book>('book', { scope: 'document', validator })
expect(() => Strict.validate({ title: 'invalid' })).toThrow('bad')
const ok = { id: 'book:1', typeName: 'book', title: 'fine' }
expect(Strict.validate(ok)).toBe(ok)
})
it('[RT9] ephemeralKeySet contains exactly the keys mapped to true', () => {
const WithEphemeral = createRecordType<Author>('author', {
scope: 'document',
ephemeralKeys: { name: false, living: true, age: true },
})
expect(WithEphemeral.ephemeralKeySet).toEqual(new Set(['living', 'age']))
expect(Author.ephemeralKeySet).toEqual(new Set())
})
})
describe('creating records (RT)', () => {
it('[RT2] create merges defaults with the given props', () => {
const author = Author.create({ name: 'Le Guin' })
expect(author).toEqual({
id: author.id,
typeName: 'author',
name: 'Le Guin',
living: true,
age: 35,
})
const dead = Author.create({ name: 'Tolstoy', living: false })
expect(dead.living).toBe(false)
})
it('[RT2] undefined prop values do not override defaults', () => {
const author = Author.create({ name: 'Le Guin', living: undefined })
expect(author.living).toBe(true)
})
it('[RT3] create uses the given id, else generates one', () => {
const id = Author.createId('ursula')
expect(Author.create({ name: 'Le Guin', id }).id).toBe(id)
const generated = Author.create({ name: 'Le Guin' })
expect(generated.id).toMatch(/^author:/)
})
it('[RT3] an explicitly undefined id is treated as absent', () => {
const author = Author.create({ name: 'Le Guin', id: undefined })
expect(author.id).toMatch(/^author:/)
})
it('[R2] [RT3] createId returns typeName:uniquePart, honoring a custom part', () => {
expect(Author.createId('123')).toBe('author:123')
expect(Book.createId('xyz')).toBe('book:xyz')
const a = Author.createId()
const b = Author.createId()
expect(a).toMatch(/^author:/)
expect(a).not.toBe(b)
})
it('[RT4] clone deep-clones the record and assigns a fresh id', () => {
const Nested = createRecordType<Book & { props: { tags: string[] } }>('book', {
scope: 'document',
})
const original = Nested.create({ title: '1984', props: { tags: ['dystopia'] } })
const copy = Nested.clone(original)
expect(copy.id).not.toBe(original.id)
expect(copy.title).toBe(original.title)
expect(copy.props).toEqual(original.props)
expect(copy.props).not.toBe(original.props)
})
})
describe('ids and type guards (RT)', () => {
it('[RT5] parseId returns the part after the colon and throws for other types', () => {
expect(Author.parseId('author:123' as any)).toBe('123')
expect(Book.parseId('book:xyz' as any)).toBe('xyz')
expect(() => Author.parseId('book:123' as any)).toThrow()
expect(() => Book.parseId('author:xyz' as any)).toThrow()
})
it('[RT5] isId is true exactly for ids starting with typeName:', () => {
expect(Author.isId('author:123')).toBe(true)
expect(Author.isId('book:123')).toBe(false)
expect(Author.isId('author')).toBe(false)
expect(Author.isId('authors:123')).toBe(false)
expect(Author.isId('')).toBe(false)
expect(Author.isId(undefined)).toBe(false)
})
it('[RT5] isInstance checks the typeName', () => {
const author = Author.create({ name: 'Le Guin' })
const book = Book.create({ title: '1984' })
expect(Author.isInstance(author)).toBe(true)
expect(Author.isInstance(book)).toBe(false)
expect(Author.isInstance(undefined)).toBe(false)
})
it('[RT6] assertIdType throws for invalid ids and passes for valid ones', () => {
expect(() => assertIdType(Author.createId('ok'), Author)).not.toThrow()
expect(() => assertIdType(undefined, Author)).toThrow()
expect(() => assertIdType('', Author)).toThrow()
expect(() => assertIdType('book:123', Author)).toThrow()
})
})
describe('extending and validating (RT)', () => {
it('[RT7] withDefaultProperties keeps the type name, validator, scope, and ephemeral keys', () => {
const validator = { validate: (r: unknown) => r as Author }
const base = createRecordType<Author>('author', {
scope: 'presence',
validator,
ephemeralKeys: { name: false, living: false, age: true },
})
const extended = base.withDefaultProperties(() => ({ living: false }))
expect(extended.typeName).toBe('author')
expect(extended.scope).toBe('presence')
expect(extended.validator).toBe(validator)
expect(extended.ephemeralKeys).toBe(base.ephemeralKeys)
expect(extended.ephemeralKeySet).toEqual(new Set(['age']))
const author = extended.create({ name: 'Woolf', age: 59 })
expect(author.living).toBe(false)
})
it('[RT8] validate uses validateUsingKnownGoodVersion when a previous record is given', () => {
const calls: string[] = []
const Tracked = createRecordType<Book>('book', {
scope: 'document',
validator: {
validate: (r) => {
calls.push('validate')
return r as Book
},
validateUsingKnownGoodVersion: (_prev, r) => {
calls.push('validateUsingKnownGoodVersion')
return r as Book
},
},
})
const book = Tracked.create({ title: '1984' })
Tracked.validate({ ...book, title: 'Animal Farm' })
expect(calls).toEqual(['validate'])
Tracked.validate({ ...book, title: 'Animal Farm' }, book)
expect(calls).toEqual(['validate', 'validateUsingKnownGoodVersion'])
})
it('[RT8] validate falls back to validate when validateUsingKnownGoodVersion is absent', () => {
const calls: string[] = []
const Plain = createRecordType<Book>('book', {
scope: 'document',
validator: {
validate: (r) => {
calls.push('validate')
return r as Book
},
},
})
const book = Plain.create({ title: '1984' })
Plain.validate({ ...book }, book)
expect(calls).toEqual(['validate'])
})
})

View File

@@ -0,0 +1,379 @@
import { Expand, objectMapEntries, structuredClone, uniqueId } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
import { StoreValidator } from './Store'
/**
* Utility type that extracts the record type from a RecordType instance.
*
* @example
* ```ts
* const Book = createRecordType<BookRecord>('book', { scope: 'document' })
* type BookFromType = RecordTypeRecord<typeof Book> // BookRecord
* ```
*
* @public
*/
export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['create']>
/**
* Defines the scope of the record
*
* session: The record belongs to a single instance of the store. It should not be synced, and any persistence logic should 'de-instance-ize' the record before persisting it, and apply the reverse when rehydrating.
* document: The record is persisted and synced. It is available to all store instances.
* presence: The record belongs to a single instance of the store. It may be synced to other instances, but other instances should not make changes to it. It should not be persisted.
*
* @public
* */
export type RecordScope = 'session' | 'document' | 'presence'
/**
* A record type is a type that can be stored in a record store. It is created with
* `createRecordType`.
*
* @public
*/
export class RecordType<
R extends UnknownRecord,
RequiredProperties extends keyof Omit<R, 'id' | 'typeName'>,
> {
/**
* Factory function that creates default properties for new records.
* @public
*/
readonly createDefaultProperties: () => Exclude<Omit<R, 'id' | 'typeName'>, RequiredProperties>
/**
* Validator function used to validate records of this type.
* @public
*/
readonly validator: StoreValidator<R>
/**
* Optional configuration specifying which record properties are ephemeral.
* Ephemeral properties are not included in snapshots or synchronization.
* @public
*/
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
/**
* Set of property names that are marked as ephemeral for efficient lookup.
* @public
*/
readonly ephemeralKeySet: ReadonlySet<string>
/**
* The scope that determines how records of this type are persisted and synchronized.
* @public
*/
readonly scope: RecordScope
/**
* Creates a new RecordType instance.
*
* typeName - The unique type name for records created by this RecordType
* config - Configuration object for the RecordType
* - createDefaultProperties - Function that returns default properties for new records
* - validator - Optional validator function for record validation
* - scope - Optional scope determining persistence behavior (defaults to 'document')
* - ephemeralKeys - Optional mapping of property names to ephemeral status
* @public
*/
constructor(
/**
* The unique type associated with this record.
*
* @public
* @readonly
*/
public readonly typeName: R['typeName'],
config: {
// eslint-disable-next-line tldraw/method-signature-style
readonly createDefaultProperties: () => Exclude<
Omit<R, 'id' | 'typeName'>,
RequiredProperties
>
readonly validator?: StoreValidator<R>
readonly scope?: RecordScope
readonly ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
}
) {
this.createDefaultProperties = config.createDefaultProperties
this.validator = config.validator ?? { validate: (r: unknown) => r as R }
this.scope = config.scope ?? 'document'
this.ephemeralKeys = config.ephemeralKeys
const ephemeralKeySet = new Set<string>()
if (config.ephemeralKeys) {
for (const [key, isEphemeral] of objectMapEntries(config.ephemeralKeys)) {
if (isEphemeral) ephemeralKeySet.add(key as string)
}
}
this.ephemeralKeySet = ephemeralKeySet
}
/**
* Creates a new record of this type with the given properties.
*
* Properties are merged with default properties from the RecordType configuration.
* If no id is provided, a unique id will be generated automatically.
*
* @example
* ```ts
* const book = Book.create({
* title: 'The Great Gatsby',
* author: 'F. Scott Fitzgerald'
* })
* // Result: { id: 'book:abc123', typeName: 'book', title: 'The Great Gatsby', author: 'F. Scott Fitzgerald', inStock: true }
* ```
*
* @param properties - The properties for the new record, including both required and optional fields
* @returns The newly created record with generated id and typeName
* @public
*/
create(
properties: Expand<Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>>
): R {
const result = {
...this.createDefaultProperties(),
id: (properties as Partial<R>).id ?? this.createId(),
} as any
for (const [k, v] of objectMapEntries(properties)) {
if (v !== undefined) {
result[k] = v
}
}
result.typeName = this.typeName
return result as R
}
/**
* Creates a deep copy of an existing record with a new unique id.
*
* This method performs a deep clone of all properties while generating a fresh id,
* making it useful for duplicating records without id conflicts.
*
* @example
* ```ts
* const originalBook = Book.create({ title: '1984', author: 'George Orwell' })
* const duplicatedBook = Book.clone(originalBook)
* // duplicatedBook has same properties but different id
* ```
*
* @param record - The record to clone
* @returns A new record with the same properties but a different id
* @public
*/
clone(record: R): R {
return { ...structuredClone(record), id: this.createId() }
}
/**
* Create a new ID for this record type.
*
* @example
*
* ```ts
* const id = recordType.createId()
* ```
*
* @returns The new ID.
* @public
*/
createId(customUniquePart?: string): IdOf<R> {
return (this.typeName + ':' + (customUniquePart ?? uniqueId())) as IdOf<R>
}
/**
* Extracts the unique identifier part from a full record id.
*
* Record ids have the format `typeName:uniquePart`. This method returns just the unique part.
*
* @example
* ```ts
* const bookId = Book.createId() // 'book:abc123'
* const uniquePart = Book.parseId(bookId) // 'abc123'
* ```
*
* @param id - The full record id to parse
* @returns The unique identifier portion after the colon
* @throws Error if the id is not valid for this record type
* @public
*/
parseId(id: IdOf<R>): string {
if (!this.isId(id)) {
throw new Error(`ID "${id}" is not a valid ID for type "${this.typeName}"`)
}
return id.slice(this.typeName.length + 1)
}
/**
* Type guard that checks whether a record belongs to this RecordType.
*
* This method performs a runtime check by comparing the record's typeName
* against this RecordType's typeName.
*
* @example
* ```ts
* if (Book.isInstance(someRecord)) {
* // someRecord is now typed as a book record
* console.log(someRecord.title)
* }
* ```
*
* @param record - The record to check, may be undefined
* @returns True if the record is an instance of this record type
* @public
*/
isInstance(record?: UnknownRecord): record is R {
return record?.typeName === this.typeName
}
/**
* Type guard that checks whether an id string belongs to this RecordType.
*
* Validates that the id starts with this RecordType's typeName followed by a colon.
* This is more efficient than parsing the full id when you only need to verify the type.
*
* @example
* ```ts
* if (Book.isId(someId)) {
* // someId is now typed as IdOf<BookRecord>
* const book = store.get(someId)
* }
* ```
*
* @param id - The id string to check, may be undefined
* @returns True if the id belongs to this record type
* @public
*/
isId(id?: string): id is IdOf<R> {
if (!id) return false
for (let i = 0; i < this.typeName.length; i++) {
if (id[i] !== this.typeName[i]) return false
}
return id[this.typeName.length] === ':'
}
/**
* Create a new RecordType that has the same type name as this RecordType and includes the given
* default properties.
*
* @example
*
* ```ts
* const authorType = createRecordType('author', () => ({ living: true }))
* const deadAuthorType = authorType.withDefaultProperties({ living: false })
* ```
*
* @param createDefaultProperties - A function that returns the default properties of the new RecordType.
* @returns The new RecordType.
*/
withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'typeName' | 'id'>>(
createDefaultProperties: () => DefaultProps
): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>> {
return new RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>(this.typeName, {
createDefaultProperties: createDefaultProperties as any,
validator: this.validator,
scope: this.scope,
ephemeralKeys: this.ephemeralKeys,
})
}
/**
* Validates a record against this RecordType's validator and returns it with proper typing.
*
* This method runs the configured validator function and throws an error if validation fails.
* If a previous version of the record is provided, it may use optimized validation.
*
* @example
* ```ts
* try {
* const validBook = Book.validate(untrustedData)
* // validBook is now properly typed and validated
* } catch (error) {
* console.log('Validation failed:', error.message)
* }
* ```
*
* @param record - The unknown record data to validate
* @param recordBefore - Optional previous version for optimized validation
* @returns The validated and properly typed record
* @throws Error if validation fails
* @public
*/
validate(record: unknown, recordBefore?: R): R {
if (recordBefore && this.validator.validateUsingKnownGoodVersion) {
return this.validator.validateUsingKnownGoodVersion(recordBefore, record)
}
return this.validator.validate(record)
}
}
/**
* Creates a new RecordType with the specified configuration.
*
* This factory function creates a RecordType that can be used to create, validate, and manage
* records of a specific type within a store. The resulting RecordType can be extended with
* default properties using the withDefaultProperties method.
*
* @example
* ```ts
* interface BookRecord extends BaseRecord<'book', RecordId<BookRecord>> {
* title: string
* author: string
* inStock: boolean
* }
*
* const Book = createRecordType<BookRecord>('book', {
* scope: 'document',
* validator: bookValidator
* })
* ```
*
* @param typeName - The unique type name for this record type
* @param config - Configuration object containing validator, scope, and ephemeral keys
* @returns A new RecordType instance for creating and managing records
* @public
*/
export function createRecordType<R extends UnknownRecord>(
typeName: R['typeName'],
config: {
validator?: StoreValidator<R>
scope: RecordScope
ephemeralKeys?: { readonly [K in Exclude<keyof R, 'id' | 'typeName'>]: boolean }
}
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
createDefaultProperties: () => ({}) as any,
validator: config.validator,
scope: config.scope,
ephemeralKeys: config.ephemeralKeys,
})
}
/**
* Assert whether an id correspond to a record type.
*
* @example
*
* ```ts
* assertIdType(myId, "shape")
* ```
*
* @param id - The id to check.
* @param type - The type of the record.
* @public
*/
export function assertIdType<R extends UnknownRecord>(
id: string | undefined,
type: RecordType<R, any>
): asserts id is IdOf<R> {
if (!id || !type.isId(id)) {
throw new Error(`string ${JSON.stringify(id)} is not a valid ${type.typeName} id`)
}
}

View File

@@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import {
RecordsDiff,
createEmptyRecordsDiff,
isRecordsDiffEmpty,
reverseRecordsDiff,
squashRecordDiffs,
squashRecordDiffsMutable,
} from './RecordsDiff'
// Tests for SPEC.md §4 (RecordsDiff).
// Rule IDs like [D3] in test names refer to that document.
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
}
function book(id: string, title: string): Book {
return { id: id as RecordId<Book>, typeName: 'book', title }
}
function diff(
added: Record<string, Book> = {},
updated: Record<string, [Book, Book]> = {},
removed: Record<string, Book> = {}
): RecordsDiff<Book> {
return { added, updated, removed } as RecordsDiff<Book>
}
describe('empty diffs (D)', () => {
it('[D1] createEmptyRecordsDiff returns a diff with empty collections', () => {
expect(createEmptyRecordsDiff()).toEqual({ added: {}, updated: {}, removed: {} })
})
it('[D1] isRecordsDiffEmpty is true exactly when all three collections are empty', () => {
expect(isRecordsDiffEmpty(createEmptyRecordsDiff())).toBe(true)
expect(isRecordsDiffEmpty(diff({ 'book:1': book('book:1', 'a') }))).toBe(false)
expect(
isRecordsDiffEmpty(diff({}, { 'book:1': [book('book:1', 'a'), book('book:1', 'b')] }))
).toBe(false)
expect(isRecordsDiffEmpty(diff({}, {}, { 'book:1': book('book:1', 'a') }))).toBe(false)
})
})
describe('reversing diffs (D)', () => {
it('[D2] swaps added and removed and reverses updated pairs', () => {
const added = book('book:1', 'Added')
const from = book('book:2', 'Old')
const to = book('book:2', 'New')
const removed = book('book:3', 'Removed')
const reversed = reverseRecordsDiff(
diff({ 'book:1': added }, { 'book:2': [from, to] }, { 'book:3': removed })
)
expect(reversed).toEqual(
diff({ 'book:3': removed }, { 'book:2': [to, from] }, { 'book:1': added })
)
})
})
describe('squashing diffs (D)', () => {
it('[D3] add then update becomes an add with the final value', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
expect(squashRecordDiffs([diff({ 'book:1': v1 }), diff({}, { 'book:1': [v1, v2] })])).toEqual(
diff({ 'book:1': v2 })
)
})
it('[D3] add then remove cancels out', () => {
const b = book('book:1', 'a')
expect(squashRecordDiffs([diff({ 'book:1': b }), diff({}, {}, { 'book:1': b })])).toEqual(
createEmptyRecordsDiff()
)
})
it('[D3] update then update collapses from the original from to the final to', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
const v3 = book('book:1', 'v3')
expect(
squashRecordDiffs([diff({}, { 'book:1': [v1, v2] }), diff({}, { 'book:1': [v2, v3] })])
).toEqual(diff({}, { 'book:1': [v1, v3] }))
})
it('[D3] update then remove becomes a remove of the original from', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
expect(
squashRecordDiffs([diff({}, { 'book:1': [v1, v2] }), diff({}, {}, { 'book:1': v2 })])
).toEqual(diff({}, {}, { 'book:1': v1 }))
})
it('[D3] remove then add becomes an update', () => {
const original = book('book:1', 'Original')
const replacement = book('book:1', 'New')
expect(
squashRecordDiffs([diff({}, {}, { 'book:1': original }), diff({ 'book:1': replacement })])
).toEqual(diff({}, { 'book:1': [original, replacement] }))
})
it('[D3] remove then add of the identical object cancels out', () => {
const same = book('book:1', 'Same')
expect(squashRecordDiffs([diff({}, {}, { 'book:1': same }), diff({ 'book:1': same })])).toEqual(
createEmptyRecordsDiff()
)
})
it('[D4] does not mutate its inputs by default', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
const first = diff({ 'book:1': v1 })
const second = diff({}, { 'book:1': [v1, v2] })
squashRecordDiffs([first, second])
expect(first).toEqual(diff({ 'book:1': v1 }))
expect(second).toEqual(diff({}, { 'book:1': [v1, v2] }))
})
it('[D4] mutateFirstDiff applies the result onto the first diff in place', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
const first = diff({ 'book:1': v1 })
const result = squashRecordDiffs([first, diff({}, { 'book:1': [v1, v2] })], {
mutateFirstDiff: true,
})
expect(result).toBe(first)
expect(first).toEqual(diff({ 'book:1': v2 }))
})
it('[D4] squashRecordDiffsMutable agrees with squashRecordDiffs', () => {
const added = book('book:1', 'Book 1')
const from = book('book:2', 'Old')
const to = book('book:2', 'New')
const diffs = [diff({ 'book:1': added }), diff({}, { 'book:2': [from, to] })]
const immutableResult = squashRecordDiffs(diffs)
const mutableTarget = createEmptyRecordsDiff<Book>()
squashRecordDiffsMutable(mutableTarget, diffs)
expect(mutableTarget).toEqual(immutableResult)
})
})
describe('diff ownership (D)', () => {
it('[D2] reverseRecordsDiff does not share its collections with the input', () => {
const input = diff({ 'book:1': book('book:1', 'Added') })
const reversed = reverseRecordsDiff(input)
squashRecordDiffsMutable(reversed, [diff({ 'book:1': book('book:1', 'Older') })])
expect(input).toEqual(diff({ 'book:1': book('book:1', 'Added') }))
})
it('[D4] mutateFirstDiff with no diffs returns an empty diff', () => {
expect(squashRecordDiffs([], { mutateFirstDiff: true })).toEqual(createEmptyRecordsDiff())
})
it('[D4] mutateFirstDiff does not mutate the [from, to] tuples of the first diff in place', () => {
const v1 = book('book:1', 'v1')
const v2 = book('book:1', 'v2')
const v3 = book('book:1', 'v3')
const tuple: [Book, Book] = [v1, v2]
const first = diff({}, { 'book:1': tuple })
const result = squashRecordDiffs([first, diff({}, { 'book:1': [v2, v3] })], {
mutateFirstDiff: true,
})
expect(result).toBe(first)
expect(result.updated['book:1' as RecordId<Book>]).toEqual([v1, v3])
expect(tuple).toEqual([v1, v2])
})
})

View File

@@ -0,0 +1,347 @@
import { objectMapValues } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
/**
* A diff describing the changes to records, containing collections of records that were added,
* updated, or removed. This is the fundamental data structure used throughout the store system
* to track and communicate changes.
*
* @example
* ```ts
* const diff: RecordsDiff<Book> = {
* added: {
* 'book:1': { id: 'book:1', typeName: 'book', title: 'New Book' }
* },
* updated: {
* 'book:2': [
* { id: 'book:2', typeName: 'book', title: 'Old Title' }, // from
* { id: 'book:2', typeName: 'book', title: 'New Title' } // to
* ]
* },
* removed: {
* 'book:3': { id: 'book:3', typeName: 'book', title: 'Deleted Book' }
* }
* }
* ```
*
* @public
*/
export interface RecordsDiff<R extends UnknownRecord> {
/** Records that were created, keyed by their ID */
added: Record<IdOf<R>, R>
/** Records that were modified, keyed by their ID. Each entry contains [from, to] tuple */
updated: Record<IdOf<R>, [from: R, to: R]>
/** Records that were deleted, keyed by their ID */
removed: Record<IdOf<R>, R>
}
/**
* Creates an empty RecordsDiff with no added, updated, or removed records.
* This is useful as a starting point when building diffs programmatically.
*
* @returns An empty RecordsDiff with all collections initialized to empty objects
* @example
* ```ts
* const emptyDiff = createEmptyRecordsDiff<Book>()
* // Result: { added: {}, updated: {}, removed: {} }
* ```
*
* @internal
*/
export function createEmptyRecordsDiff<R extends UnknownRecord>(): RecordsDiff<R> {
return { added: {}, updated: {}, removed: {} } as RecordsDiff<R>
}
/**
* Creates the inverse of a RecordsDiff, effectively reversing all changes.
* Added records become removed, removed records become added, and updated records
* have their from/to values swapped. This is useful for implementing undo operations.
*
* @param diff - The diff to reverse
* @returns A new RecordsDiff that represents the inverse of the input diff
* @example
* ```ts
* const originalDiff: RecordsDiff<Book> = {
* added: { 'book:1': newBook },
* updated: { 'book:2': [oldBook, updatedBook] },
* removed: { 'book:3': deletedBook }
* }
*
* const reversedDiff = reverseRecordsDiff(originalDiff)
* // Result: {
* // added: { 'book:3': deletedBook },
* // updated: { 'book:2': [updatedBook, oldBook] },
* // removed: { 'book:1': newBook }
* // }
* ```
*
* @public
*/
export function reverseRecordsDiff(diff: RecordsDiff<any>) {
// Copy the collections rather than aliasing them: callers squash into the reversed diff
// (`squashRecordDiffsMutable`), which would otherwise write through to the input.
const result: RecordsDiff<any> = {
added: { ...diff.removed },
removed: { ...diff.added },
updated: {},
}
for (const [from, to] of objectMapValues(diff.updated)) {
result.updated[from.id] = [to, from]
}
return result
}
/**
* Checks whether a RecordsDiff contains any changes. A diff is considered empty
* if it has no added, updated, or removed records.
*
* @param diff - The diff to check
* @returns True if the diff contains no changes, false otherwise
* @example
* ```ts
* const emptyDiff = createEmptyRecordsDiff<Book>()
* console.log(isRecordsDiffEmpty(emptyDiff)) // true
*
* const nonEmptyDiff: RecordsDiff<Book> = {
* added: { 'book:1': someBook },
* updated: {},
* removed: {}
* }
* console.log(isRecordsDiffEmpty(nonEmptyDiff)) // false
* ```
*
* @public
*/
export function isRecordsDiffEmpty<T extends UnknownRecord>(diff: RecordsDiff<T>) {
return !hasAnyKey(diff.added) && !hasAnyKey(diff.updated) && !hasAnyKey(diff.removed)
}
// Cheaper than Object.keys().length, but note that for-in still pays an O(N)
// key-collection prologue on dictionary-mode objects (which diffs become once keys
// are deleted from them) — avoid calling this on large diffs in per-frame hot paths.
/** @internal */
export function hasAnyKey(obj: object) {
for (const _ in obj) return true
return false
}
/**
* Combines multiple RecordsDiff objects into a single consolidated diff.
* This function intelligently merges changes, handling cases where the same record
* is modified multiple times across different diffs. For example, if a record is
* added in one diff and then updated in another, the result will show it as added
* with the final state.
*
* @param diffs - An array of diffs to combine into a single diff
* @param options - Configuration options for the squashing operation
* - mutateFirstDiff - If true, modifies the first diff in place instead of creating a new one
* @returns A single diff that represents the cumulative effect of all input diffs
* @example
* ```ts
* const diff1: RecordsDiff<Book> = {
* added: { 'book:1': { id: 'book:1', title: 'New Book' } },
* updated: {},
* removed: {}
* }
*
* const diff2: RecordsDiff<Book> = {
* added: {},
* updated: { 'book:1': [{ id: 'book:1', title: 'New Book' }, { id: 'book:1', title: 'Updated Title' }] },
* removed: {}
* }
*
* const squashed = squashRecordDiffs([diff1, diff2])
* // Result: {
* // added: { 'book:1': { id: 'book:1', title: 'Updated Title' } },
* // updated: {},
* // removed: {}
* // }
* ```
*
* @public
*/
export function squashRecordDiffs<T extends UnknownRecord>(
diffs: RecordsDiff<T>[],
options?: {
mutateFirstDiff?: boolean
}
): RecordsDiff<T> {
if (options?.mutateFirstDiff) {
const result = diffs[0]
if (!result) return createEmptyRecordsDiff()
// The squash mutates the target's `[from, to]` tuples in place, which is only safe when the
// target owns them. The first diff's tuples may be shared with other holders, so copy them.
for (const _id in result.updated) {
const id = _id as IdOf<T>
const [from, to] = result.updated[id]
result.updated[id] = [from, to]
}
squashRecordDiffsMutable(result, diffs, 1)
return result
}
const result = { added: {}, removed: {}, updated: {} } as RecordsDiff<T>
squashRecordDiffsMutable(result, diffs)
return result
}
/**
* Applies an array of diffs to a target diff by mutating the target in-place.
* This is the core implementation used by squashRecordDiffs. It handles complex
* scenarios where records move between added/updated/removed states across multiple diffs.
*
* The function processes each diff sequentially, applying the following logic:
* - Added records: If the record was previously removed, convert to an update; otherwise add it
* - Updated records: Chain updates together, preserving the original 'from' state
* - Removed records: If the record was added in this sequence, cancel both operations
*
* The target's `updated` tuples are mutated in place, so the target must own them: start from
* `createEmptyRecordsDiff()` (or a diff built by earlier squashes into it) rather than a diff whose
* tuples are shared with another holder.
*
* @param target - The diff to modify in-place (will be mutated)
* @param diffs - Array of diffs to apply to the target
* @example
* ```ts
* const targetDiff: RecordsDiff<Book> = {
* added: {},
* updated: {},
* removed: { 'book:1': oldBook }
* }
*
* const newDiffs = [{
* added: { 'book:1': newBook },
* updated: {},
* removed: {}
* }]
*
* squashRecordDiffsMutable(targetDiff, newDiffs)
* // targetDiff is now: {
* // added: {},
* // updated: { 'book:1': [oldBook, newBook] },
* // removed: {}
* // }
* ```
*
* @internal
*/
export function squashRecordDiffsMutable<T extends UnknownRecord>(
target: RecordsDiff<T>,
diffs: RecordsDiff<T>[],
fromIndex = 0
): void {
squashRecordDiffsMutableImpl(target, diffs, fromIndex)
}
/**
* Applies only records of a given type from an array of diffs to a target diff. Returns the net
* change in the number of entries in the target.
*
* @internal
*/
export function squashRecordDiffsMutableByType<
T extends UnknownRecord,
TypeName extends T['typeName'],
>(
target: RecordsDiff<Extract<T, { typeName: TypeName }>>,
diffs: RecordsDiff<T>[],
typeName: TypeName
): number {
return squashRecordDiffsMutableImpl(target, diffs, 0, typeName)
}
function squashRecordDiffsMutableImpl<T extends UnknownRecord>(
target: RecordsDiff<T>,
diffs: RecordsDiff<T>[],
fromIndex: number,
typeName?: T['typeName']
): number {
let sizeChange = 0
const trackSize = typeName !== undefined
// This runs on every history interceptor call — e.g. once per input tick while
// resizing N shapes, with N entries in diff.updated — so the updated loop must not
// allocate per entry. We use for-in instead of Object.entries, mutate the target's
// existing [from, to] tuples in place, and skip delete calls against collections we
// know are empty. In-place tuple mutation is safe because the target exclusively
// owns its updated tuples: they are always created here (never shared with a source
// diff), and sources are never mutated.
for (let i = fromIndex; i < diffs.length; i++) {
const diff = diffs[i]
// target.removed can only lose entries before the removed loop below, so a
// stale `true` is harmless (extra no-op deletes).
const targetHasRemoved = hasAnyKey(target.removed)
for (const _id in diff.added) {
const id = _id as IdOf<T>
const value = diff.added[id]
if (typeName !== undefined && value.typeName !== typeName) continue
if (targetHasRemoved && target.removed[id]) {
const original = target.removed[id]
delete target.removed[id]
if (trackSize) sizeChange--
if (original !== value) {
target.updated[id] = [original, value]
if (trackSize) sizeChange++
}
} else {
if (trackSize && !target.added[id]) sizeChange++
target.added[id] = value
}
}
// the added loop above may have inserted into target.added
const targetHasAdded = hasAnyKey(target.added)
for (const _id in diff.updated) {
const id = _id as IdOf<T>
const to = diff.updated[id][1]
if (typeName !== undefined && to.typeName !== typeName) continue
if (targetHasAdded && target.added[id]) {
target.added[id] = to
if (trackSize && target.updated[id]) sizeChange--
delete target.updated[id]
if (targetHasRemoved) {
if (trackSize && target.removed[id]) sizeChange--
delete target.removed[id]
}
continue
}
const existing = target.updated[id]
if (existing) {
existing[1] = to
if (targetHasRemoved) {
if (trackSize && target.removed[id]) sizeChange--
delete target.removed[id]
}
continue
}
// copy the tuple so the target owns it and can mutate it in place later
target.updated[id] = [diff.updated[id][0], to]
if (trackSize) sizeChange++
if (targetHasRemoved) {
if (trackSize && target.removed[id]) sizeChange--
delete target.removed[id]
}
}
for (const _id in diff.removed) {
const id = _id as IdOf<T>
if (typeName !== undefined && diff.removed[id].typeName !== typeName) continue
// the same record was added in this diff sequence, just drop it
if (target.added[id]) {
delete target.added[id]
if (trackSize) sizeChange--
} else if (target.updated[id]) {
target.removed[id] = target.updated[id][0]
delete target.updated[id]
} else {
if (trackSize && !target.removed[id]) sizeChange++
target.removed[id] = diff.removed[id]
}
}
}
return sizeChange
}

View File

@@ -0,0 +1,767 @@
import { react } from '@tldraw/state'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import { createMigrationSequence } from './migrate'
import { createRecordType } from './RecordType'
import { createComputedCache, Store } from './Store'
import { StoreSchema } from './StoreSchema'
// Tests for SPEC.md §5 (reading and writing), §9 (validation), §10 (computed caches),
// and §21 (integrity). Rule IDs like [S3] in test names refer to that document.
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
author: RecordId<Author>
numPages: number
}
const Book = createRecordType<Book>('book', {
validator: {
validate(value) {
const book = value as Book
if (!book.id.startsWith('book:')) throw Error('invalid book id')
if (book.typeName !== 'book') throw Error('invalid book typeName')
if (typeof book.title !== 'string') throw Error('invalid book title')
if (!Number.isFinite(book.numPages) || book.numPages < 0) {
throw Error('invalid book numPages')
}
return book
},
},
scope: 'document',
}).withDefaultProperties(() => ({ numPages: 100 }))
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
isPseudonym: boolean
}
const Author = createRecordType<Author>('author', {
validator: {
validate(value) {
const author = value as Author
if (!author.id.startsWith('author:')) throw Error('invalid author id')
if (author.typeName !== 'author') throw Error('invalid author typeName')
if (typeof author.name !== 'string') throw Error('invalid author name')
if (typeof author.isPseudonym !== 'boolean') throw Error('invalid author isPseudonym')
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({ isPseudonym: false }))
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
visitorName: string
}
const Visit = createRecordType<Visit>('visit', {
scope: 'session',
}).withDefaultProperties(() => ({ visitorName: 'Anonymous' }))
interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
x: number
y: number
}
const Cursor = createRecordType<Cursor>('cursor', {
scope: 'presence',
})
type LibraryType = Book | Author | Visit | Cursor
const schema = () =>
StoreSchema.create<LibraryType>({
book: Book,
author: Author,
visit: Visit,
cursor: Cursor,
})
describe('Store: reading and writing (S)', () => {
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({ props: {}, schema: schema() })
})
afterEach(() => {
store.dispose()
})
it('[S1] a new store is empty', () => {
expect(store.allRecords()).toEqual([])
})
it('[S1] initialData populates the store', () => {
const author = Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
const populated = new Store({
props: {},
schema: schema(),
initialData: { [author.id]: author } as any,
})
expect(populated.allRecords()).toEqual([author])
populated.dispose()
})
it('[S1] records read from the store are frozen in dev/test builds', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
expect(Object.isFrozen(store.get(author.id))).toBe(true)
})
it('[S2] put creates records and get/has read them', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
const book = Book.create({ title: 'The Hobbit', author: author.id, numPages: 310 })
store.put([author, book])
expect(store.get(author.id)).toEqual(author)
expect(store.get(book.id)).toEqual(book)
expect(store.has(author.id)).toBe(true)
expect(store.has(Book.createId('missing'))).toBe(false)
expect(store.get(Book.createId('missing'))).toBeUndefined()
})
it('[S2] put updates records whose ids are already present', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
store.put([{ ...author, name: 'John Ronald Reuel Tolkien' }])
expect(store.get(author.id)?.name).toBe('John Ronald Reuel Tolkien')
})
it('[S3] re-putting the stored object is a complete no-op', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
const listener = vi.fn()
store.listen(listener)
store.update(author.id, (a) => a)
store.put([store.get(author.id)!])
expect(listener).not.toHaveBeenCalled()
})
it('[S3] a no-op put produces no history entry even among real changes', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
const other = Author.create({ name: 'Ursula K. Le Guin' })
const diff = store.extractingChanges(() => {
store.put([store.get(author.id)!, other])
})
expect(Object.keys(diff.updated)).toEqual([])
expect(Object.keys(diff.added)).toEqual([other.id])
})
it('[S4] update applies an updater function to the current record', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
store.update(author.id, (current) => ({ ...current, name: 'Jimmy Tolks' }))
expect(store.get(author.id)?.name).toBe('Jimmy Tolks')
})
it('[S4] update of a missing id logs an error and does not throw', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
expect(() => {
store.update(Author.createId('missing'), (a) => a)
}).not.toThrow()
expect(consoleSpy).toHaveBeenCalled()
consoleSpy.mockRestore()
})
it('[S5] remove deletes records and ignores missing ids', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
const book = Book.create({ title: 'The Hobbit', author: author.id })
store.put([author, book])
store.remove([author.id, Book.createId('missing')])
expect(store.has(author.id)).toBe(false)
expect(store.has(book.id)).toBe(true)
})
it('[S5] removing nothing produces no history entry', () => {
const diff = store.extractingChanges(() => {
store.remove([Author.createId('missing')])
})
expect(diff).toEqual({ added: {}, updated: {}, removed: {} })
})
it('[S5] clear removes all records', () => {
store.put([Author.create({ name: 'J.R.R Tolkein' }), Visit.create({ visitorName: 'John Doe' })])
expect(store.allRecords()).toHaveLength(2)
store.clear()
expect(store.allRecords()).toHaveLength(0)
})
it('[S6] get is reactive; unsafeGetWithoutCapture is not', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
store.put([author])
const reactiveReads: (string | undefined)[] = []
const unsafeReads: (string | undefined)[] = []
const stop = react('reader', () => {
reactiveReads.push(store.get(author.id)?.name)
unsafeReads.push(store.unsafeGetWithoutCapture(author.id)?.name)
})
store.update(author.id, (a) => ({ ...a, name: 'Jimmy Tolks' }))
expect(reactiveReads).toEqual(['J.R.R Tolkein', 'Jimmy Tolks'])
stop()
const unsafeOnly: (string | undefined)[] = []
const stop2 = react('unsafe-reader', () => {
unsafeOnly.push(store.unsafeGetWithoutCapture(author.id)?.name)
})
store.update(author.id, (a) => ({ ...a, name: 'John Ronald' }))
// the effect did not re-run because nothing was captured
expect(unsafeOnly).toEqual(['Jimmy Tolks'])
stop2()
})
})
describe('Store: serialization and snapshots (S)', () => {
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({ props: {}, schema: schema() })
store.put([
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') }),
Book.create({
title: 'The Hobbit',
id: Book.createId('hobbit'),
author: Author.createId('tolkein'),
numPages: 300,
}),
Visit.create({ visitorName: 'John Doe', id: Visit.createId('doe') }),
Cursor.create({ x: 1, y: 2, id: Cursor.createId('c') }),
])
})
afterEach(() => {
store.dispose()
})
it('[S7] serialize defaults to document scope', () => {
const typeNames = Object.values(store.serialize()).map((r) => r.typeName)
expect(typeNames.sort()).toEqual(['author', 'book'])
})
it('[S7] serialize filters by the given scope, and "all" includes everything', () => {
expect(Object.values(store.serialize('session')).map((r) => r.typeName)).toEqual(['visit'])
expect(Object.values(store.serialize('presence')).map((r) => r.typeName)).toEqual(['cursor'])
expect(
Object.values(store.serialize('all'))
.map((r) => r.typeName)
.sort()
).toEqual(['author', 'book', 'cursor', 'visit'])
})
it('[S7] scopedTypes maps each scope to its type names', () => {
expect(store.scopedTypes).toEqual({
document: new Set(['book', 'author']),
session: new Set(['visit']),
presence: new Set(['cursor']),
})
})
it('[S8] getStoreSnapshot bundles the serialized records and schema', () => {
const snapshot = store.getStoreSnapshot('all')
expect(snapshot.store).toEqual(store.serialize('all'))
expect(snapshot.schema).toEqual(store.schema.serialize())
})
it('[S9] loadStoreSnapshot replaces all current data', () => {
const snapshot = store.getStoreSnapshot()
const store2 = new Store({ props: {}, schema: schema() })
store2.put([Author.create({ name: 'Someone Else' })])
store2.loadStoreSnapshot(snapshot)
expect(store2.serialize('all')).toEqual(store.serialize('document'))
expect(store2.getStoreSnapshot()).toEqual(snapshot)
store2.dispose()
})
it('[S9] loadStoreSnapshot does not run side effects', () => {
const snapshot = store.getStoreSnapshot()
const store2 = new Store({ props: {}, schema: schema() })
const afterCreate = vi.fn()
const beforeCreate = vi.fn((r: Book) => r)
store2.sideEffects.registerAfterCreateHandler('book', afterCreate)
store2.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
store2.loadStoreSnapshot(snapshot)
expect(beforeCreate).not.toHaveBeenCalled()
expect(afterCreate).not.toHaveBeenCalled()
// side effects are re-enabled afterwards
store2.put([Book.create({ title: 'New', author: Author.createId('x') })])
expect(afterCreate).toHaveBeenCalledTimes(1)
store2.dispose()
})
it('[S9] loadStoreSnapshot migrates the snapshot first', () => {
const snapshot = store.getStoreSnapshot()
const up = vi.fn((s: any) => {
s['book:hobbit'].numPages = 42
})
const store2 = new Store<LibraryType>({
props: {},
schema: StoreSchema.create<LibraryType>(
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
{
migrations: [
createMigrationSequence({
sequenceId: 'com.tldraw',
retroactive: true,
sequence: [{ id: 'com.tldraw/1', scope: 'store', up }],
}),
],
}
),
})
store2.loadStoreSnapshot(snapshot)
expect(up).toHaveBeenCalledTimes(1)
expect((store2.get(Book.createId('hobbit')) as Book).numPages).toBe(42)
store2.dispose()
})
it('[S9] storage-scope migrations run during loadStoreSnapshot', () => {
const snapshot = store.getStoreSnapshot()
const up = vi.fn((storage: any) => {
const book = storage.get('book:hobbit')
storage.set('book:hobbit', { ...book, numPages: 42 })
storage.delete('author:tolkein')
})
const store2 = new Store<LibraryType>({
props: {},
schema: StoreSchema.create<LibraryType>(
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
{
migrations: [
createMigrationSequence({
sequenceId: 'com.tldraw',
retroactive: true,
sequence: [{ id: 'com.tldraw/1', scope: 'storage', up }],
}),
],
}
),
})
store2.loadStoreSnapshot(snapshot)
expect(up).toHaveBeenCalledTimes(1)
expect((store2.get(Book.createId('hobbit')) as Book).numPages).toBe(42)
expect(store2.get(Author.createId('tolkein'))).toBeUndefined()
store2.dispose()
})
it('[S9] loadStoreSnapshot throws on migration failure and leaves the store unchanged', () => {
const snapshot = store.getStoreSnapshot()
const store2 = new Store({
props: {},
schema: StoreSchema.create<Book>({ book: Book }), // no author type
})
const existing = Book.create({ title: 'Keep me', author: Author.createId('x') })
store2.put([existing])
expect(() => {
store2.loadStoreSnapshot(snapshot as any)
}).toThrow('Missing definition for record type author')
expect(store2.get(existing.id)).toEqual(existing)
store2.dispose()
})
it('[S10] migrateSnapshot returns the snapshot stamped with the current schema', () => {
const snapshot = store.getStoreSnapshot()
const migrated = store.migrateSnapshot(snapshot)
expect(migrated).toEqual(snapshot) // no migrations needed
// a snapshot with an older schema is migrated and stamped with the current one
const store2 = new Store<LibraryType>({
props: {},
schema: StoreSchema.create<LibraryType>(
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
{
migrations: [
createMigrationSequence({
sequenceId: 'com.tldraw',
retroactive: true,
sequence: [{ id: 'com.tldraw/1', scope: 'record', up: (r) => r }],
}),
],
}
),
})
const migrated2 = store2.migrateSnapshot(snapshot)
expect(snapshot.schema).toEqual({ schemaVersion: 2, sequences: {} })
expect(migrated2.schema).toEqual({ schemaVersion: 2, sequences: { 'com.tldraw': 1 } })
expect(migrated2.schema).toEqual(store2.schema.serialize())
store2.dispose()
expect(() =>
store.migrateSnapshot({ store: {}, schema: { schemaVersion: -1 } } as any)
).toThrow('Failed to migrate snapshot')
})
})
describe('Store: validation (V)', () => {
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({ props: {}, schema: schema() })
})
afterEach(() => {
store.dispose()
})
it('[V1] put validates created records', () => {
expect(() => {
store.put([
Book.create({
// @ts-expect-error - deliberately invalid data
title: 4,
author: Author.createId('tolkein'),
}),
])
}).toThrow('invalid book title')
expect(store.allRecords()).toEqual([])
})
it('[V1] put validates updated records', () => {
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkein') })
store.put([book])
expect(() => {
store.put([{ ...book, numPages: -1 }])
}).toThrow('invalid book numPages')
expect(store.get(book.id)).toEqual(book)
})
it('[V1] the store constructor validates initial data', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
expect(() => {
new Store({
props: {},
schema: schema(),
initialData: { [author.id]: { ...author, name: 4 } } as any,
})
}).toThrow('invalid author name')
})
it('[V2] updates pass the previous record to validateUsingKnownGoodVersion', () => {
const validateUsingKnownGoodVersion = vi.fn((_prev: Author, next: unknown) => next as Author)
const TrackedAuthor = createRecordType<Author>('author', {
validator: {
validate: (r) => r as Author,
validateUsingKnownGoodVersion,
},
scope: 'document',
})
const trackedStore = new Store({
props: {},
schema: StoreSchema.create<Author>({ author: TrackedAuthor }),
})
const author = Author.create({ name: 'J.R.R Tolkein' })
trackedStore.put([author])
expect(validateUsingKnownGoodVersion).not.toHaveBeenCalled()
const updated = { ...author, name: 'Jimmy Tolks' }
trackedStore.put([updated])
expect(validateUsingKnownGoodVersion).toHaveBeenCalledWith(author, updated)
trackedStore.dispose()
})
it('[V3] a validation throw rolls back the whole operation', () => {
const goodAuthor = Author.create({ name: 'Fine' })
expect(() => {
store.put([
goodAuthor,
Book.create({
// @ts-expect-error - deliberately invalid data
title: 4,
author: goodAuthor.id,
}),
])
}).toThrow()
// the valid record earlier in the same put was rolled back too
expect(store.get(goodAuthor.id)).toBeUndefined()
expect(store.allRecords()).toEqual([])
})
it('[V4] the validation failure handler result is stored, on create and update alike', () => {
const FixableAuthor = createRecordType<Author>('author', {
validator: {
validate(value) {
const author = value as Author
if (typeof author.name !== 'string') throw Error('invalid author name')
return author
},
},
scope: 'document',
})
const recoveringStore = new Store<Author>({
props: {},
schema: StoreSchema.create<Author>(
{ author: FixableAuthor },
{
onValidationFailure: ({ record }) => ({ ...record, name: 'Recovered' }) as Author,
}
),
})
const id = FixableAuthor.createId('a')
// create
recoveringStore.put([{ id, typeName: 'author', name: 4, isPseudonym: false } as any])
expect(recoveringStore.get(id)!.name).toBe('Recovered')
// update
recoveringStore.put([{ id, typeName: 'author', name: 'Valid', isPseudonym: false } as any])
recoveringStore.put([{ id, typeName: 'author', name: 5, isPseudonym: false } as any])
expect(recoveringStore.get(id)!.name).toBe('Recovered')
recoveringStore.dispose()
})
it('[V4] a validator that substitutes a transformed record has it stored on update', () => {
const NormalizingAuthor = createRecordType<Author>('author', {
validator: {
validate(value) {
const author = value as Author
return { ...author, name: author.name.trim() }
},
},
scope: 'document',
})
const normalizingStore = new Store<Author>({
props: {},
schema: StoreSchema.create<Author>({ author: NormalizingAuthor }),
})
const id = NormalizingAuthor.createId('a')
normalizingStore.put([
{ id, typeName: 'author', name: ' padded ', isPseudonym: false } as any,
])
expect(normalizingStore.get(id)!.name).toBe('padded')
normalizingStore.put([
{ id, typeName: 'author', name: ' padded again ', isPseudonym: false } as any,
])
expect(normalizingStore.get(id)!.name).toBe('padded again')
normalizingStore.dispose()
})
it('[V5] store.validate re-validates every record', () => {
const author = Author.create({ name: 'J.R.R Tolkein' })
const book = Book.create({ title: 'The Hobbit', author: author.id })
store.put([author, book])
const validateRecord = vi.spyOn(store.schema, 'validateRecord')
store.validate('tests')
expect(validateRecord).toHaveBeenCalledTimes(2)
expect(validateRecord).toHaveBeenCalledWith(store, author, 'tests', null)
expect(validateRecord).toHaveBeenCalledWith(store, book, 'tests', null)
validateRecord.mockRestore()
})
})
describe('computed caches (CC)', () => {
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({ props: {}, schema: schema() })
})
afterEach(() => {
store.dispose()
})
it('[CC1] get derives for existing records and returns undefined for missing ones', () => {
const derive = vi.fn((book: Book) => `derived-${book.title}`)
const cache = store.createComputedCache('cache', derive)
expect(cache.get(Book.createId('missing'))).toBeUndefined()
expect(derive).not.toHaveBeenCalled()
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get(book.id)).toBe('derived-The Hobbit')
})
it('[CC2] values are cached until the record changes', () => {
const derive = vi.fn((book: Book) => `derived-${book.title}`)
const cache = store.createComputedCache('cache', derive)
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get(book.id)).toBe('derived-The Hobbit')
expect(cache.get(book.id)).toBe('derived-The Hobbit')
expect(derive).toHaveBeenCalledTimes(1)
store.update(book.id, (b) => ({ ...b, title: 'The Hobbit: Updated' }) as Book)
expect(cache.get(book.id)).toBe('derived-The Hobbit: Updated')
expect(derive).toHaveBeenCalledTimes(2)
})
it('[CC1] a deleted record makes get return undefined again', () => {
const cache = store.createComputedCache('cache', (book: Book) => book.title)
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get(book.id)).toBe('The Hobbit')
store.remove([book.id])
expect(cache.get(book.id)).toBeUndefined()
})
it('[CC3] areRecordsEqual controls which record changes invalidate the cache', () => {
const derive = vi.fn((book: Book) => book.title)
const cache = store.createComputedCache('cache', derive, {
areRecordsEqual: (a, b) => a.title === b.title,
})
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get(book.id)).toBe('The Hobbit')
expect(derive).toHaveBeenCalledTimes(1)
// a change the equality function ignores does not re-derive
store.update(book.id, (b) => ({ ...b, numPages: 999 }) as Book)
expect(cache.get(book.id)).toBe('The Hobbit')
expect(derive).toHaveBeenCalledTimes(1)
// a change it observes does
store.update(book.id, (b) => ({ ...b, title: 'New Title' }) as Book)
expect(cache.get(book.id)).toBe('New Title')
expect(derive).toHaveBeenCalledTimes(2)
})
it('[CC3] areResultsEqual keeps the previous result object for equal results', () => {
const cache = store.createComputedCache(
'cache',
(book: Book) => ({ words: book.title.split(' ') }),
{ areResultsEqual: (a, b) => a.words.join() === b.words.join() }
)
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
const first = cache.get(book.id)
// the record changed, but the derived result is "equal" -> same object back
store.update(book.id, (b) => ({ ...b, numPages: 999 }) as Book)
expect(cache.get(book.id)).toBe(first)
})
it('[CC4] the standalone createComputedCache keys caches per context object', () => {
const derive = vi.fn((context: Store<LibraryType>, book: Book) => `${book.title}-${context.id}`)
const cache = createComputedCache('standalone', derive)
const store2 = new Store({ props: {}, schema: schema() })
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
store2.put([book])
expect(cache.get(store, book.id)).toBe(`The Hobbit-${store.id}`)
expect(cache.get(store2, book.id)).toBe(`The Hobbit-${store2.id}`)
expect(derive).toHaveBeenCalledTimes(2)
store2.dispose()
})
it('[CC4] the standalone cache accepts objects containing a store', () => {
const cache = createComputedCache(
'standalone',
(context: { store: Store<LibraryType> }, book: Book) => `${book.title}-${context.store.id}`
)
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get({ store }, book.id)).toBe(`The Hobbit-${store.id}`)
})
it('[CC5] createCache calls create once per record and not for missing ids', () => {
const create = vi.fn((id: any, recordSignal: any) => {
// return the record signal itself: the cached value is the record
return recordSignal
})
const cache = store.createCache(create)
expect(cache.get(Book.createId('missing'))).toBeUndefined()
expect(create).not.toHaveBeenCalled()
const book = Book.create({ title: 'The Hobbit', author: Author.createId('tolkien') })
store.put([book])
expect(cache.get(book.id)).toEqual(book)
cache.get(book.id)
expect(create).toHaveBeenCalledTimes(1)
})
})
describe('integrity (IC)', () => {
it('[IC1] ensureStoreIsUsable creates the integrity checker once and runs it', () => {
const integrityChecker = vi.fn()
const createIntegrityChecker = vi.fn(() => integrityChecker)
const integritySchema = StoreSchema.create<LibraryType>(
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
{ createIntegrityChecker: createIntegrityChecker as any }
)
const store = new Store({ props: {}, schema: integritySchema })
store.ensureStoreIsUsable()
store.ensureStoreIsUsable()
expect(createIntegrityChecker).toHaveBeenCalledTimes(1)
expect(createIntegrityChecker).toHaveBeenCalledWith(store)
expect(integrityChecker).toHaveBeenCalledTimes(2)
store.dispose()
})
it('[IC1] the integrity checker runs after mergeRemoteChanges', () => {
const integrityChecker = vi.fn()
const integritySchema = StoreSchema.create<LibraryType>(
{ book: Book, author: Author, visit: Visit, cursor: Cursor },
{ createIntegrityChecker: (() => integrityChecker) as any }
)
const store = new Store({ props: {}, schema: integritySchema })
store.mergeRemoteChanges(() => {
store.put([Author.create({ name: 'J.R.R Tolkein' })])
})
expect(integrityChecker).toHaveBeenCalled()
store.dispose()
})
it('[IC2] markAsPossiblyCorrupted sets a readable flag', () => {
const store = new Store({ props: {}, schema: schema() })
expect(store.isPossiblyCorrupted()).toBe(false)
store.markAsPossiblyCorrupted()
expect(store.isPossiblyCorrupted()).toBe(true)
store.dispose()
})
})

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,516 @@
import { react, RESET_VALUE, transact } from '@tldraw/state'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import { RecordsDiff, reverseRecordsDiff } from './RecordsDiff'
import { createRecordType } from './RecordType'
import { Store } from './Store'
import { StoreSchema } from './StoreSchema'
// Tests for SPEC.md §8 (history and listeners).
// Rule IDs like [H4] in test names refer to that document.
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
author: RecordId<Author>
numPages: number
}
const Book = createRecordType<Book>('book', {
validator: { validate: (book) => book as Book },
scope: 'document',
}).withDefaultProperties(() => ({ numPages: 100 }))
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
isPseudonym: boolean
}
const Author = createRecordType<Author>('author', {
validator: { validate: (author) => author as Author },
scope: 'document',
}).withDefaultProperties(() => ({ isPseudonym: false }))
interface Visit extends BaseRecord<'visit', RecordId<Visit>> {
visitorName: string
lastActive: number
}
const Visit = createRecordType<Visit>('visit', {
validator: { validate: (visit) => visit as Visit },
scope: 'session',
ephemeralKeys: { visitorName: false, lastActive: true },
}).withDefaultProperties(() => ({ visitorName: 'Anonymous', lastActive: 0 }))
interface Cursor extends BaseRecord<'cursor', RecordId<Cursor>> {
x: number
}
const Cursor = createRecordType<Cursor>('cursor', {
scope: 'presence',
}).withDefaultProperties(() => ({ x: 0 }))
type LibraryType = Book | Author | Visit | Cursor
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<LibraryType>({
book: Book,
author: Author,
visit: Visit,
cursor: Cursor,
}),
})
})
const tolkein = () => Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
const hobbit = () =>
Book.create({
title: 'The Hobbit',
id: Book.createId('hobbit'),
author: Author.createId('tolkein'),
numPages: 300,
})
describe('the history atom (H)', () => {
it('[H1] increments by exactly one per committed change-set, carrying the diff', () => {
let lastDiff: RecordsDiff<LibraryType>[] | typeof RESET_VALUE | undefined
const stop = react('history', (lastReactedEpoch) => {
store.history.get()
lastDiff = store.history.getDiffSince(lastReactedEpoch)
})
const before = store.history.get()
const author = tolkein()
store.put([author])
expect(store.history.get()).toBe(before + 1)
expect(lastDiff).toEqual([{ added: { [author.id]: author }, updated: {}, removed: {} }])
// a transaction with several mutations commits as one change per put/remove call
transact(() => {
store.put([hobbit()])
store.remove([author.id])
})
expect(store.history.get()).toBe(before + 3)
stop()
})
})
describe('listeners (H)', () => {
it('[H2] listen returns a remover and notification is deferred to the next frame', async () => {
try {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = true
const listener = vi.fn()
const removeListener = store.listen(listener)
store.put([tolkein()])
// not called synchronously
expect(listener).toHaveBeenCalledTimes(0)
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(listener).toHaveBeenCalledTimes(1)
removeListener()
store.put([hobbit()])
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(listener).toHaveBeenCalledTimes(1)
} finally {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = false
}
})
it('[H3] a flush squashes adjacent same-source entries, preserving source boundaries', async () => {
try {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = true
const entries: Array<{ source: string; addedIds: string[] }> = []
store.listen((entry) => {
entries.push({ source: entry.source, addedIds: Object.keys(entry.changes.added) })
})
const author = tolkein()
const book = hobbit()
const remoteAuthor = Author.create({ name: 'Remote', id: Author.createId('remote') })
const lastAuthor = Author.create({ name: 'Last', id: Author.createId('last') })
store.put([author])
store.put([book])
store.mergeRemoteChanges(() => {
store.put([remoteAuthor])
})
store.put([lastAuthor])
await new Promise((resolve) => requestAnimationFrame(resolve))
// [user, user, remote, user] -> [user, remote, user]
expect(entries).toEqual([
{ source: 'user', addedIds: [author.id, book.id] },
{ source: 'remote', addedIds: [remoteAuthor.id] },
{ source: 'user', addedIds: [lastAuthor.id] },
])
} finally {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = false
}
})
it('[H3] changes within one transaction arrive as one squashed entry', async () => {
const listener = vi.fn()
store.listen(listener)
const author = tolkein()
transact(() => {
store.put([author])
store.update(author.id, (r) => ({ ...r, name: 'Jimmy Tolks' }))
})
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(listener).toHaveBeenCalledTimes(1)
expect(listener.mock.calls[0][0]).toEqual({
source: 'user',
changes: {
added: { [author.id]: { ...author, name: 'Jimmy Tolks' } },
updated: {},
removed: {},
},
})
})
it('[H4] source filters drop entries from other sources', () => {
const userListener = vi.fn()
const remoteListener = vi.fn()
store.listen(userListener, { source: 'user' })
store.listen(remoteListener, { source: 'remote' })
store.put([tolkein()])
expect(userListener).toHaveBeenCalledTimes(1)
expect(remoteListener).toHaveBeenCalledTimes(0)
store.mergeRemoteChanges(() => {
store.put([hobbit()])
})
expect(userListener).toHaveBeenCalledTimes(1)
expect(remoteListener).toHaveBeenCalledTimes(1)
expect(remoteListener.mock.calls[0][0].source).toBe('remote')
})
it('[H4] scope filters reduce each entry to records of that scope', () => {
const documentListener = vi.fn()
const sessionListener = vi.fn()
const presenceListener = vi.fn()
store.listen(documentListener, { scope: 'document' })
store.listen(sessionListener, { scope: 'session' })
store.listen(presenceListener, { scope: 'presence' })
const author = tolkein()
const visit = Visit.create({ visitorName: 'John Doe' })
const cursor = Cursor.create({ x: 1 })
store.put([author, visit, cursor])
expect(documentListener).toHaveBeenCalledTimes(1)
expect(Object.keys(documentListener.mock.calls[0][0].changes.added)).toEqual([author.id])
expect(sessionListener).toHaveBeenCalledTimes(1)
expect(Object.keys(sessionListener.mock.calls[0][0].changes.added)).toEqual([visit.id])
expect(presenceListener).toHaveBeenCalledTimes(1)
expect(Object.keys(presenceListener.mock.calls[0][0].changes.added)).toEqual([cursor.id])
})
it('[H4] a listener is not called when its scope filter leaves nothing', () => {
const sessionListener = vi.fn()
store.listen(sessionListener, { scope: 'session' })
// document-only change
store.put([tolkein(), hobbit()])
expect(sessionListener).toHaveBeenCalledTimes(0)
// mixed change still arrives, filtered
store.put([Visit.create({ visitorName: 'Jimmy Beans' }), Author.create({ name: 'Other' })])
expect(sessionListener).toHaveBeenCalledTimes(1)
})
it('[H5] a new listener never sees changes made before it subscribed', async () => {
try {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = true
store.put([tolkein()])
const firstListener = vi.fn()
store.listen(firstListener)
expect(firstListener).toHaveBeenCalledTimes(0)
store.put([Author.create({ name: 'Chips McCoy', id: Author.createId('chips') })])
expect(firstListener).toHaveBeenCalledTimes(0)
// attaching a second listener flushes pending history to the first
const secondListener = vi.fn()
store.listen(secondListener)
expect(firstListener).toHaveBeenCalledTimes(1)
expect(secondListener).toHaveBeenCalledTimes(0)
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(firstListener).toHaveBeenCalledTimes(1)
expect(secondListener).toHaveBeenCalledTimes(0)
} finally {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = false
}
})
it('[H6] accumulated history is discarded while no listeners are attached', () => {
store.put([tolkein()])
// H6 has no public observation point, so this reaches into private state
expect((store as any).historyAccumulator._history).toHaveLength(0)
})
})
describe('extracting and intercepting changes (H)', () => {
const authorId = Author.createId('tolkein')
const bookId = Book.createId('hobbit')
it('[H7] extractingChanges returns the squashed diff of exactly the changes in fn', () => {
const author = tolkein()
const book = hobbit()
expect(
store.extractingChanges(() => {
store.put([author])
store.put([book])
})
).toEqual({
added: { [author.id]: author, [book.id]: book },
updated: {},
removed: {},
})
const renamed = { ...book, title: 'The Hobbit: There and Back Again' }
expect(
store.extractingChanges(() => {
store.remove([authorId])
store.update(bookId, () => renamed)
})
).toEqual({
added: {},
updated: { [book.id]: [book, renamed] },
removed: { [author.id]: author },
})
})
it('[H7] listeners still see changes made inside extractingChanges', () => {
const listener = vi.fn()
store.listen(listener)
store.extractingChanges(() => {
store.put([tolkein()])
})
expect(listener).toHaveBeenCalledTimes(1)
})
it('[H8] addHistoryInterceptor sees every change-set synchronously, with its source', () => {
const entries: any[] = []
const interceptor = vi.fn((entry, source) => entries.push({ entry, source }))
const remove = store.addHistoryInterceptor(interceptor)
const author = tolkein()
store.put([author])
expect(interceptor).toHaveBeenCalledTimes(1)
expect(entries[0].source).toBe('user')
expect(entries[0].entry.changes.added).toEqual({ [author.id]: author })
store.mergeRemoteChanges(() => {
store.put([hobbit()])
})
expect(interceptor).toHaveBeenCalledTimes(2)
expect(entries[1].source).toBe('remote')
remove()
store.remove([author.id])
expect(interceptor).toHaveBeenCalledTimes(2)
})
})
describe('applying diffs (H)', () => {
const authorId = Author.createId('tolkein')
const bookId = Book.createId('hobbit')
it('[H9] applyDiff puts added and updated records and removes removed ones', () => {
const author = tolkein()
store.applyDiff({
added: { [author.id]: author },
updated: {},
removed: {},
} as RecordsDiff<LibraryType>)
expect(store.get(authorId)).toEqual(author)
const renamed = { ...author, name: 'Jimmy Tolks' }
store.applyDiff({
added: {},
updated: { [author.id]: [author, renamed] },
removed: {},
} as RecordsDiff<LibraryType>)
expect(store.get(authorId)).toEqual(renamed)
store.applyDiff({
added: {},
updated: {},
removed: { [author.id]: renamed },
} as RecordsDiff<LibraryType>)
expect(store.has(authorId)).toBe(false)
})
it('[H9] applying a diff and then its reverse restores the prior state', () => {
store.put([tolkein(), hobbit()])
const checkpoint1 = store.getStoreSnapshot()
const forwardsDiff = store.extractingChanges(() => {
store.remove([authorId])
store.update(bookId, (book) => ({ ...book, title: 'The Hobbit: There and Back Again' }))
})
const checkpoint2 = store.getStoreSnapshot()
store.applyDiff(reverseRecordsDiff(forwardsDiff))
expect(store.getStoreSnapshot()).toEqual(checkpoint1)
store.applyDiff(forwardsDiff)
expect(store.getStoreSnapshot()).toEqual(checkpoint2)
})
it('[H9] runCallbacks: false applies the diff without side effects', () => {
const afterCreate = vi.fn()
store.sideEffects.registerAfterCreateHandler('author', afterCreate)
const author = tolkein()
store.applyDiff(
{ added: { [author.id]: author }, updated: {}, removed: {} } as RecordsDiff<LibraryType>,
{ runCallbacks: false }
)
expect(store.get(authorId)).toEqual(author)
expect(afterCreate).not.toHaveBeenCalled()
})
describe('[H10] ignoreEphemeralKeys', () => {
const visitId = Visit.createId('jane')
const visit = () => Visit.create({ id: visitId, visitorName: 'Jane', lastActive: 100 })
it('drops updates that touch only ephemeral keys', () => {
store.put([visit()])
const listener = vi.fn()
store.listen(listener)
store.applyDiff(
{
added: {},
updated: { [visitId]: [visit(), { ...visit(), lastActive: 999 }] },
removed: {},
} as RecordsDiff<LibraryType>,
{ ignoreEphemeralKeys: true }
)
expect((store.get(visitId) as Visit).lastActive).toBe(100)
expect(listener).not.toHaveBeenCalled()
})
it('merges non-ephemeral changes onto the stored record, keeping ephemeral values', () => {
store.put([visit()])
store.applyDiff(
{
added: {},
updated: {
[visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
},
removed: {},
} as RecordsDiff<LibraryType>,
{ ignoreEphemeralKeys: true }
)
const result = store.get(visitId) as Visit
expect(result.visitorName).toBe('Janet')
expect(result.lastActive).toBe(100) // the stored ephemeral value is kept
})
it('applies updates for missing records, and added records, in full', () => {
store.applyDiff(
{
added: {},
updated: {
[visitId]: [visit(), { ...visit(), visitorName: 'Janet', lastActive: 999 }],
},
removed: {},
} as RecordsDiff<LibraryType>,
{ ignoreEphemeralKeys: true }
)
const result = store.get(visitId) as Visit
expect(result.visitorName).toBe('Janet')
expect(result.lastActive).toBe(999)
})
})
})
describe('applying diffs with ignoreEphemeralKeys (H)', () => {
it('[H10] ignoreEphemeralKeys also applies the removal of a non-ephemeral key', () => {
const visitId = Visit.createId('jane')
const visit = Visit.create({ id: visitId, visitorName: 'Jane', lastActive: 100 })
store.put([visit])
const { visitorName: _dropped, ...withoutName } = visit
store.applyDiff(
{
added: {},
updated: { [visitId]: [visit, withoutName as Visit] },
removed: {},
} as RecordsDiff<LibraryType>,
{ ignoreEphemeralKeys: true }
)
const result = store.get(visitId) as Visit
expect('visitorName' in result).toBe(false)
expect(result.lastActive).toBe(100)
})
})
describe('listeners: a throwing listener (H)', () => {
it('[H13] a listener that throws does not stop the others from receiving the flush', () => {
const received = vi.fn()
store.listen(() => {
throw new Error('listener failed')
})
store.listen(received)
expect(() => store.put([tolkein()])).toThrow('listener failed')
expect(received).toHaveBeenCalledTimes(1)
})
})
describe('listeners: dispose (H)', () => {
it('[H12] dispose delivers pending change-sets to listeners before cancelling the flush', () => {
try {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = true
const listener = vi.fn()
store.listen(listener)
store.put([tolkein()])
expect(listener).not.toHaveBeenCalled()
store.dispose()
expect(listener).toHaveBeenCalledTimes(1)
} finally {
// @ts-expect-error - test-only escape hatch
globalThis.__FORCE_RAF_IN_TESTS__ = false
}
})
})

View File

@@ -0,0 +1,892 @@
import { atom, computed, EMPTY_ARRAY, RESET_VALUE } from '@tldraw/state'
import { beforeEach, describe, expect, it } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import { createRecordType } from './RecordType'
import { Store } from './Store'
import { StoreSchema } from './StoreSchema'
// Tests for SPEC.md §11 (filtered history), §12 (indexes), and §13 (id, record, and exec
// queries). Rule IDs like [QI3] in test names refer to that document.
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
age: number
}
const Author = createRecordType<Author>('author', {
validator: {
validate(value) {
const author = value as Author
if (author.typeName !== 'author') throw Error()
if (!author.id.startsWith('author:')) throw Error()
if (!Number.isFinite(author.age)) throw Error()
if (author.age < 0) throw Error()
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({ age: 23 }))
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
authorId: RecordId<Author>
}
const Book = createRecordType<Book>('book', {
validator: {
validate(value) {
const book = value as Book
if (!book.id.startsWith('book:')) throw Error()
if (book.typeName !== 'book') throw Error()
if (typeof book.title !== 'string') throw Error()
if (!book.authorId.startsWith('author')) throw Error()
return book
},
},
scope: 'document',
})
const authors = {
tolkein: Author.create({ name: 'J.R.R. Tolkein' }),
bradbury: Author.create({ name: 'Ray Bradbury' }),
davidMitchellSerious: Author.create({ name: 'David Mitchell' }),
davidMitchellFunny: Author.create({ name: 'David Mitchell' }),
}
const books = {
cloudAtlas: Book.create({ title: 'Cloud Atlas', authorId: authors.davidMitchellSerious.id }),
myLifeInComedy: Book.create({
title: 'My Life in Comedy',
authorId: authors.davidMitchellFunny.id,
}),
lotr: Book.create({ title: 'Lord of the Rings', authorId: authors.tolkein.id }),
farenheit: Book.create({ title: 'Farenheit 451', authorId: authors.bradbury.id }),
}
let store: Store<Author | Book>
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<Author | Book>({
author: Author,
book: Book,
}),
})
store.put([
authors.tolkein,
authors.bradbury,
books.lotr,
books.farenheit,
authors.davidMitchellFunny,
authors.davidMitchellSerious,
books.cloudAtlas,
books.myLifeInComedy,
])
})
describe('filtered history (QH)', () => {
it('[QH1] is cached per type name', () => {
expect(store.query.filterHistory('author')).toBe(store.query.filterHistory('author'))
})
it('[QH1] contains only changes to records of the given type', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
let lastChangedEpoch = authorHistory.lastChangedEpoch
expect(authorHistory.getDiffSince(lastChangedEpoch - 1)).toBe(RESET_VALUE)
// updating an author changes the history
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
authorHistory.get()
expect(lastChangedEpoch).toBeLessThan(authorHistory.lastChangedEpoch)
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
{ updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 30 }] } },
])
lastChangedEpoch = authorHistory.lastChangedEpoch
// adding and removing authors shows up too
const newAuthor = Author.create({ name: 'Stanley Briggs' })
store.put([newAuthor])
store.remove([authors.bradbury.id])
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
{
added: { [newAuthor.id]: { name: 'Stanley Briggs' } },
removed: { [authors.bradbury.id]: { name: 'Ray Bradbury' } },
},
])
})
it('[QH3] changes to other types produce no observable change', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
const lastChangedEpoch = authorHistory.lastChangedEpoch
store.put([
{ ...books.lotr, title: 'The Lord of the Rings Part I: The Fellowship of the Ring' },
])
authorHistory.get()
expect(authorHistory.lastChangedEpoch).toEqual(lastChangedEpoch)
expect(authorHistory.getDiffSince(lastChangedEpoch)).toEqual([])
})
it('[QH2] changes that cancel out in a window produce no change', () => {
const authorHistory = store.query.filterHistory('author')
const epoch = authorHistory.get()
const newAuthor = Author.create({ name: 'Stanley Briggs' })
store.put([newAuthor])
store.put([{ ...newAuthor, age: 38 }])
store.remove([newAuthor.id])
expect(authorHistory.get()).toEqual(epoch)
store.remove([authors.tolkein.id])
store.put([authors.tolkein])
expect(authorHistory.get()).toEqual(epoch)
})
it('[QH2] update entries are removed when the record is deleted', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
const lastChangedEpoch = authorHistory.lastChangedEpoch
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
store.put([{ ...authors.davidMitchellFunny, age: 343 }])
store.remove([authors.davidMitchellFunny.id])
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
{
// shows the original, not the updated version
removed: { [authors.davidMitchellFunny.id]: { age: 23 } },
},
])
})
it('[QH2] multiple update entries collapse into one', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
const lastChangedEpoch = authorHistory.lastChangedEpoch
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
store.put([{ ...authors.davidMitchellFunny, age: 343 }])
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
{
updated: { [authors.davidMitchellFunny.id]: [{ age: 23 }, { age: 343 }] },
},
])
})
it('[QH2] an add followed by an update folds into the add', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
const lastChangedEpoch = authorHistory.lastChangedEpoch
const newAuthor = Author.create({ name: 'Stanley Briggs' })
store.put([newAuthor])
store.put([{ ...newAuthor, age: 38 }])
expect(authorHistory.getDiffSince(lastChangedEpoch)).toMatchObject([
{
added: { [newAuthor.id]: { age: 38 } },
},
])
})
it('[QH2] squashes matching records while ignoring interleaved record types', () => {
const authorHistory = store.query.filterHistory('author')
authorHistory.get()
const lastChangedEpoch = authorHistory.lastChangedEpoch
const newAuthor = Author.create({ name: 'Stanley Briggs' })
const updatedNewAuthor = { ...newAuthor, age: 38 }
store.put([newAuthor])
store.put([updatedNewAuthor, { ...books.lotr, title: 'The Fellowship of the Ring' }])
store.put([{ ...authors.davidMitchellFunny, age: 38 }])
store.remove([authors.davidMitchellFunny.id])
store.remove([authors.tolkein.id])
store.put([authors.tolkein])
expect(authorHistory.getDiffSince(lastChangedEpoch)).toEqual([
{
added: { [newAuthor.id]: updatedNewAuthor },
updated: {},
removed: { [authors.davidMitchellFunny.id]: authors.davidMitchellFunny },
},
])
})
})
describe('indexes (QI)', () => {
it('[QI2] are cached per type and property', () => {
expect(store.query.index('author', 'name')).toBe(store.query.index('author', 'name'))
expect(store.query.index('author', 'name')).not.toBe(store.query.index('author', 'age'))
})
it('[QI1] map each property value to the ids of records with that value', () => {
const authorNameIndex = store.query.index('author', 'name')
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
expect(authorNameIndex.get().get('David Mitchell')).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
expect(authorNameIndex.get().get('Nonexistent')).toBeUndefined()
const bookTitleIndex = store.query.index('book', 'title')
expect(bookTitleIndex.get().get('Cloud Atlas')).toEqual(new Set([books.cloudAtlas.id]))
expect(bookTitleIndex.get().get('Lord of the Rings')).toEqual(new Set([books.lotr.id]))
})
it('[QI1] records whose value is undefined are not indexed', () => {
const newAuthor = { ...Author.create({ name: 'No Name' }), name: undefined } as any
const index = store.query.index('author', 'name')
store.put([newAuthor])
expect(index.get().get(undefined)).toBeUndefined()
})
it('[QI3] adding records updates the index, with diffs', () => {
const authorNameIndex = store.query.index('author', 'name')
// deref to make it compute once
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
const newAuthor = Author.create({ name: 'New Author' })
store.put([newAuthor])
expect(authorNameIndex.get().get('New Author')).toEqual(new Set([newAuthor.id]))
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0].get('New Author')).toEqual({
added: new Set([newAuthor.id]),
})
expect(diff[0].size).toBe(1)
const moreNewAuthors = [
Author.create({ name: 'New Author' }),
Author.create({ name: 'New Author' }),
Author.create({ name: 'New Author' }),
]
lastChangedEpoch = authorNameIndex.lastChangedEpoch
store.put(moreNewAuthors)
expect(authorNameIndex.get().get('New Author')).toEqual(
new Set([newAuthor.id, ...moreNewAuthors.map((a) => a.id)])
)
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
expect(diff2).toHaveLength(1)
expect(diff2[0].get('New Author')).toEqual({ added: new Set(moreNewAuthors.map((a) => a.id)) })
})
it('[QI3] updating a record moves its id between value sets', () => {
const authorNameIndex = store.query.index('author', 'name')
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
const lastChangedEpoch = authorNameIndex.lastChangedEpoch
store.put([{ ...authors.bradbury, name: 'J.R.R. Tolkein' }])
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(
new Set([authors.tolkein.id, authors.bradbury.id])
)
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0].size).toBe(2)
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
added: new Set([authors.bradbury.id]),
})
expect(diff[0].get('Ray Bradbury')).toEqual({
removed: new Set([authors.bradbury.id]),
})
})
it('[QI3] removing records updates the index; empty value sets are dropped', () => {
const authorNameIndex = store.query.index('author', 'name')
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
store.remove([authors.tolkein.id])
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(undefined)
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0].size).toBe(1)
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
removed: new Set([authors.tolkein.id]),
})
lastChangedEpoch = authorNameIndex.lastChangedEpoch
store.remove([
authors.bradbury.id,
authors.davidMitchellFunny.id,
authors.davidMitchellSerious.id,
])
expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(undefined)
expect(authorNameIndex.get().get('David Mitchell')).toEqual(undefined)
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
expect(diff2).toHaveLength(1)
expect(diff2[0].size).toBe(2)
expect(diff2[0].get('Ray Bradbury')).toEqual({
removed: new Set([authors.bradbury.id]),
})
expect(diff2[0].get('David Mitchell')).toEqual({
removed: new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id]),
})
})
it('[QI3] removals and additions for the same value combine in one diff', () => {
const authorNameIndex = store.query.index('author', 'name')
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([authors.tolkein.id]))
let lastChangedEpoch = authorNameIndex.lastChangedEpoch
// do remove first
store.remove([authors.tolkein.id])
const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
store.put([newAuthor])
expect(authorNameIndex.get().get('J.R.R. Tolkein')).toEqual(new Set([newAuthor.id]))
const diff = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0].size).toBe(1)
expect(diff[0].get('J.R.R. Tolkein')).toEqual({
removed: new Set([authors.tolkein.id]),
added: new Set([newAuthor.id]),
})
lastChangedEpoch = authorNameIndex.lastChangedEpoch
// do updates
store.put([{ ...authors.davidMitchellFunny, name: 'Ray Bradbury' }])
store.put([{ ...authors.bradbury, name: 'David Mitchell' }])
expect(authorNameIndex.get().get('Ray Bradbury')).toEqual(
new Set([authors.davidMitchellFunny.id])
)
expect(authorNameIndex.get().get('David Mitchell')).toEqual(
new Set([authors.bradbury.id, authors.davidMitchellSerious.id])
)
const diff2 = authorNameIndex.getDiffSince(lastChangedEpoch)
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
expect(diff2).toHaveLength(1)
expect(diff2[0].size).toBe(2)
expect(diff2[0].get('Ray Bradbury')).toEqual({
added: new Set([authors.davidMitchellFunny.id]),
removed: new Set([authors.bradbury.id]),
})
expect(diff2[0].get('David Mitchell')).toEqual({
added: new Set([authors.bradbury.id]),
removed: new Set([authors.davidMitchellFunny.id]),
})
})
it('[QI4] keeps the same map object if the indexed values did not change', () => {
const authorNameIndex = store.query.index('author', 'name')
const value = authorNameIndex.get()
const lastChangedEpoch = authorNameIndex.lastChangedEpoch
// change a property the index does not cover
store.put([{ ...authors.tolkein, age: 23 }])
store.put([{ ...authors.tolkein, age: 99 }])
expect(authorNameIndex.get()).toBe(value)
expect(authorNameIndex.lastChangedEpoch).toBe(lastChangedEpoch)
})
it('[QI5] a backslash-delimited property indexes a nested path', () => {
interface Doc extends BaseRecord<'doc', RecordId<Doc>> {
metadata: { sessionId?: string }
}
const Doc = createRecordType<Doc>('doc', { scope: 'document' })
const docStore = new Store({
props: {},
schema: StoreSchema.create<Doc>({ doc: Doc }),
})
const docA = Doc.create({ metadata: { sessionId: 'alpha' } })
const docB = Doc.create({ metadata: { sessionId: 'beta' } })
const docNone = Doc.create({ metadata: {} })
docStore.put([docA, docB, docNone])
const index = docStore.query.index('doc', 'metadata\\sessionId')
expect(index.get().get('alpha')).toEqual(new Set([docA.id]))
expect(index.get().get('beta')).toEqual(new Set([docB.id]))
// missing nested values are not indexed
expect(index.get().get(undefined)).toBeUndefined()
// incremental updates work through the nested path
docStore.update(docA.id, (d) => ({ ...d, metadata: { sessionId: 'beta' } }))
expect(index.get().get('alpha')).toBeUndefined()
expect(index.get().get('beta')).toEqual(new Set([docB.id, docA.id]))
})
})
describe('id queries (QQ)', () => {
it('[QQ1] with no query, contains all ids of the type', () => {
const bookQuery = store.query.ids('book')
expect(bookQuery.get()).toEqual(
new Set([books.cloudAtlas.id, books.farenheit.id, books.lotr.id, books.myLifeInComedy.id])
)
const authorQuery = store.query.ids('author')
expect(authorQuery.get()).toEqual(
new Set([
authors.bradbury.id,
authors.davidMitchellFunny.id,
authors.davidMitchellSerious.id,
authors.tolkein.id,
])
)
const newAuthor = Author.create({ name: 'J.R.R. Tolkein' })
const newBook = Book.create({ title: 'The Hobbit', authorId: newAuthor.id })
store.put([newAuthor, newBook])
expect(bookQuery.get()).toContain(newBook.id)
expect(authorQuery.get()).toContain(newAuthor.id)
})
it('[QQ1] filters ids by the query expression', () => {
const jrr = store.query.ids('author', () => ({
name: { eq: 'J.R.R. Tolkein' },
}))
expect(jrr.get()).toEqual(new Set([authors.tolkein.id]))
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
}))
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
const mitchell30 = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
age: { eq: 30 },
}))
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
})
it('[QQ1] emits collection diffs as records change', () => {
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
}))
mitchell.get()
const lastEpoch = mitchell.lastChangedEpoch
const newAuthor = Author.create({ name: 'David Mitchell' })
store.put([newAuthor])
store.remove([authors.davidMitchellFunny.id])
const diff = mitchell.getDiffSince(lastEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0]).toEqual({
added: new Set([newAuthor.id]),
removed: new Set([authors.davidMitchellFunny.id]),
})
})
it('[QQ2] a reactive query expression rebuilds with a correct diff when it changes', () => {
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
const currentAuthor = atom('currentAuthor', 'David Mitchell')
const currentAge = atom('currentAge', 30)
const mitchell30 = store.query.ids('author', () => ({
name: { eq: currentAuthor.get() },
age: { eq: currentAge.get() },
}))
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
let lastChangedEpoch = mitchell30.lastChangedEpoch
currentAge.set(23)
expect(mitchell30.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
const diff = mitchell30.getDiffSince(lastChangedEpoch)
if (diff === RESET_VALUE) throw new Error('should not be reset')
expect(diff).toHaveLength(1)
expect(diff[0]).toEqual({
added: new Set([authors.davidMitchellSerious.id]),
removed: new Set([authors.davidMitchellFunny.id]),
})
currentAuthor.set('J.R.R. Tolkein')
lastChangedEpoch = mitchell30.lastChangedEpoch
expect(mitchell30.get()).toEqual(new Set([authors.tolkein.id]))
const diff2 = mitchell30.getDiffSince(lastChangedEpoch)
if (diff2 === RESET_VALUE) throw new Error('should not be reset')
expect(diff2).toHaveLength(1)
expect(diff2[0]).toEqual({
added: new Set([authors.tolkein.id]),
removed: new Set([authors.davidMitchellSerious.id]),
})
})
it('[QQ1] supports not-equals matches', () => {
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
const mitchell = store.query.ids('author', () => ({
name: { neq: 'David Mitchell' },
}))
expect(mitchell.get()).toEqual(new Set([authors.tolkein.id, authors.bradbury.id]))
const ageNot23 = store.query.ids('author', () => ({
age: { neq: 23 },
}))
expect(ageNot23.get()).toEqual(new Set([authors.davidMitchellFunny.id]))
})
it('[QQ1] updates as records are added, removed, and updated', () => {
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
age: { neq: 30 },
}))
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
store.put([{ ...authors.davidMitchellFunny, age: 23 }])
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellSerious.id, authors.davidMitchellFunny.id])
)
store.remove([authors.davidMitchellFunny.id])
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id]))
const newAuthor = Author.create({ name: 'David Mitchell' })
store.put([newAuthor])
expect(mitchell.get()).toEqual(new Set([authors.davidMitchellSerious.id, newAuthor.id]))
})
it('[QQ3] does not change if unrelated records are added, updated, or removed', () => {
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
}))
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
const lastChangedEpoch = mitchell.lastChangedEpoch
const newAuthor = Author.create({ name: 'William Shakespeare' })
store.put([newAuthor])
mitchell.get()
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
store.remove([authors.tolkein.id])
mitchell.get()
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
})
it('[QQ3] does not change if matching records change in irrelevant ways', () => {
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
}))
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
const lastChangedEpoch = mitchell.lastChangedEpoch
store.put([{ ...authors.davidMitchellFunny, age: 30 }])
mitchell.get()
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
// a change that does affect the query, just to check
store.put([{ ...authors.davidMitchellFunny, name: 'steve' }])
mitchell.get()
expect(mitchell.lastChangedEpoch).toBeGreaterThan(lastChangedEpoch)
})
it('[QQ3] round trips (remove/add-back, add/remove) leave the set unchanged', () => {
const mitchell = store.query.ids('author', () => ({
name: { eq: 'David Mitchell' },
}))
expect(mitchell.get()).toEqual(
new Set([authors.davidMitchellFunny.id, authors.davidMitchellSerious.id])
)
const lastChangedEpoch = mitchell.lastChangedEpoch
store.remove([authors.davidMitchellFunny.id])
store.put([authors.davidMitchellFunny])
mitchell.get()
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
const newMitchell = Author.create({ name: 'David Mitchell' })
store.put([newMitchell])
store.remove([newMitchell.id])
mitchell.get()
expect(mitchell.lastChangedEpoch).toEqual(lastChangedEpoch)
})
})
const bookComparator = (a: Book, b: Book) => a.title.localeCompare(b.title)
describe('record queries (QQ)', () => {
it('[QQ4] records() returns all matching records', () => {
const allBooks = store.query.records('book')
expect(allBooks.get().sort(bookComparator)).toEqual(
[books.cloudAtlas, books.farenheit, books.lotr, books.myLifeInComedy].sort(bookComparator)
)
const farenheit = store.query.records('book', () => ({
title: { eq: 'Farenheit 451' },
}))
expect(farenheit.get()).toEqual([books.farenheit])
const funnyGuide = Book.create({
title: 'How to be a funny man',
authorId: authors.davidMitchellFunny.id,
})
store.put([funnyGuide])
const mitchell = store.query.records('book', () => ({
authorId: { eq: authors.davidMitchellFunny.id },
}))
expect(mitchell.get().sort(bookComparator)).toEqual(
[books.myLifeInComedy, funnyGuide].sort(bookComparator)
)
})
it('[QQ4] records() keeps the same array when the members did not change', () => {
const allBooks = store.query.records('book')
const value = allBooks.get()
// updating an author does not change the book array members
store.put([{ ...authors.tolkein, age: 50 }])
expect(allBooks.get()).toBe(value)
})
it('[QQ4] records() supports reactive query expressions', () => {
const currentAuthor = atom('currentAuthor', authors.davidMitchellFunny.id)
const booksQuery = store.query.records('book', () => ({
authorId: { eq: currentAuthor.get() },
}))
expect(booksQuery.get()).toEqual([books.myLifeInComedy])
currentAuthor.set(authors.tolkein.id)
expect(booksQuery.get()).toEqual([books.lotr])
})
it('[QQ4] record() returns one matching record or undefined', () => {
const farenheit = store.query.record('book', () => ({
title: { eq: 'Farenheit 451' },
}))
expect(farenheit.get()).toEqual(books.farenheit)
const nonexistent = store.query.record('book', () => ({
title: { eq: 'No Such Book' },
}))
expect(nonexistent.get()).toBeUndefined()
})
it('[QQ4] record() updates reactively', () => {
const tolkein = store.query.record('author', () => ({
name: { eq: 'J.R.R. Tolkein' },
}))
expect(tolkein.get()?.age).toBe(23)
store.put([{ ...authors.tolkein, age: 81 }])
expect(tolkein.get()?.age).toBe(81)
store.remove([authors.tolkein.id])
expect(tolkein.get()).toBeUndefined()
})
it('[QQ5] exec runs a one-shot, non-reactive query', () => {
const mitchells = store.query.exec('author', { name: { eq: 'David Mitchell' } })
expect(new Set(mitchells)).toEqual(
new Set([authors.davidMitchellFunny, authors.davidMitchellSerious])
)
// not reactive: subsequent store changes don't affect the returned array
store.put([Author.create({ name: 'David Mitchell' })])
expect(mitchells).toHaveLength(2)
})
it('[QQ5] exec returns the shared empty array when nothing matches', () => {
expect(store.query.exec('author', { name: { eq: 'No Such Author' } })).toBe(EMPTY_ARRAY)
})
})
describe('integration with reactive state (QI, QQ)', () => {
it('[QI3] computed values see index updates', () => {
const tolkeinBookCount = computed('tolkein-books', () => {
const byAuthor = store.query.index('book', 'authorId')
return byAuthor.get().get(authors.tolkein.id)?.size ?? 0
})
expect(tolkeinBookCount.get()).toBe(1)
store.put([Book.create({ title: 'The Hobbit', authorId: authors.tolkein.id })])
expect(tolkeinBookCount.get()).toBe(2)
})
})
describe('queries across rolled-back transactions (QH, QQ)', () => {
it('[QQ2] the query creator is called without arguments', () => {
const args: unknown[][] = []
const ids = store.query.ids('book', (...rest: unknown[]) => {
args.push(rest)
return {}
})
ids.get()
store.put([Book.create({ title: 'Another', authorId: authors.bradbury.id })])
ids.get()
for (const call of args) expect(call).toEqual([])
})
// `read` stands in for a side effect that queries the store mid-operation.
function readDuringRolledBackPut(read: () => void) {
const phantom = Book.create({ title: 'Phantom', authorId: authors.tolkein.id })
expect(() =>
store.atomic(() => {
store.put([phantom])
read()
throw new Error('abort')
})
).toThrow('abort')
expect(store.get(phantom.id)).toBeUndefined()
}
const initialBookIds = Object.values(books).map((book) => book.id)
it('[QH4] the filtered history value strictly increases across a rolled-back transaction', () => {
const bookHistory = store.query.filterHistory('book')
const values = [bookHistory.get()]
readDuringRolledBackPut(() => values.push(bookHistory.get()))
store.put([Book.create({ title: 'First', authorId: authors.bradbury.id })])
values.push(bookHistory.get())
store.put([Book.create({ title: 'Second', authorId: authors.bradbury.id })])
values.push(bookHistory.get())
expect(values.slice(1).map((value, i) => value > values[i])).toEqual([true, true, true])
})
it('[QH4] indexes and queries read during a rolled-back transaction are rebuilt afterwards', () => {
const bookIds = store.query.ids('book')
const booksByAuthor = store.query.index('book', 'authorId')
let counterSeenByQueries = -1
readDuringRolledBackPut(() => {
expect(bookIds.get().size).toBe(5)
booksByAuthor.get()
counterSeenByQueries = store.history.get()
})
const real = Book.create({ title: 'Real', authorId: authors.bradbury.id })
store.put([real])
// a stale cache only goes unnoticed when the counter lands on the value the queries last saw
expect(store.history.get()).toBe(counterSeenByQueries)
expect(bookIds.get()).toEqual(new Set([...initialBookIds, real.id]))
expect(booksByAuthor.get().get(authors.tolkein.id)).toEqual(new Set([books.lotr.id]))
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
new Set([books.farenheit.id, real.id])
)
})
it('[QH4] indexes and queries keep picking up changes after being rebuilt', () => {
const bookIds = store.query.ids('book')
const booksByAuthor = store.query.index('book', 'authorId')
readDuringRolledBackPut(() => {
bookIds.get()
booksByAuthor.get()
})
const first = Book.create({ title: 'First', authorId: authors.bradbury.id })
store.put([first])
bookIds.get()
booksByAuthor.get()
// the rebuild left the filtered history ahead of the store's counter, so this change lands
// on the value it last reported
const second = Book.create({ title: 'Second', authorId: authors.bradbury.id })
store.put([second])
expect(bookIds.get()).toEqual(new Set([...initialBookIds, first.id, second.id]))
expect(booksByAuthor.get().get(authors.bradbury.id)).toEqual(
new Set([books.farenheit.id, first.id, second.id])
)
})
})

View File

@@ -0,0 +1,635 @@
import {
Atom,
computed,
Computed,
EMPTY_ARRAY,
isUninitialized,
RESET_VALUE,
withDiff,
} from '@tldraw/state'
import { areArraysShallowEqual, isEqual, objectMapValues } from '@tldraw/utils'
import { AtomMap } from './AtomMap'
import { IdOf, UnknownRecord } from './BaseRecord'
import { executeQuery, objectMatchesQuery, QueryExpression } from './executeQuery'
import { IncrementalSetConstructor } from './IncrementalSetConstructor'
import { hasAnyKey, RecordsDiff, squashRecordDiffsMutableByType } from './RecordsDiff'
import { diffSets } from './setUtils'
import { CollectionDiff } from './Store'
/**
* A type representing the diff of changes to a reactive store index.
* Maps property values to the collection differences for record IDs that have that property value.
*
* @example
* ```ts
* // For an index on book titles, the diff might look like:
* const titleIndexDiff: RSIndexDiff<Book, 'title'> = new Map([
* ['The Lathe of Heaven', { added: new Set(['book:1']), removed: new Set() }],
* ['Animal Farm', { added: new Set(), removed: new Set(['book:2']) }]
* ])
* ```
*
* @public
*/
export type RSIndexDiff<R extends UnknownRecord> = Map<any, CollectionDiff<IdOf<R>>>
/**
* A type representing a reactive store index as a map from property values to sets of record IDs.
* This is used to efficiently look up records by a specific property value.
*
* @example
* ```ts
* // Index mapping book titles to the IDs of books with that title
* const titleIndex: RSIndexMap<Book, 'title'> = new Map([
* ['The Lathe of Heaven', new Set(['book:1'])],
* ['Animal Farm', new Set(['book:2', 'book:3'])]
* ])
* ```
*
* @public
*/
export type RSIndexMap<R extends UnknownRecord> = Map<any, Set<IdOf<R>>>
/**
* A reactive computed index that provides efficient lookups of records by property values.
* Returns a computed value containing an RSIndexMap with diffs for change tracking.
*
* @example
* ```ts
* // Create an index on book authors
* const authorIndex: RSIndex<Book, 'authorId'> = store.query.index('book', 'authorId')
*
* // Get all books by a specific author
* const leguinBooks = authorIndex.get().get('author:leguin')
* ```
*
* @public
*/
export type RSIndex<R extends UnknownRecord> = Computed<RSIndexMap<R>, RSIndexDiff<R>>
/**
* A class that provides reactive querying capabilities for a record store.
* Offers methods to create indexes, filter records, and perform efficient lookups with automatic cache management.
* All queries are reactive and will automatically update when the underlying store data changes.
*
* @example
* ```ts
* // Create a store with books
* const store = new Store({ schema: StoreSchema.create({ book: Book, author: Author }) })
*
* // Get reactive queries for books
* const booksByAuthor = store.query.index('book', 'authorId')
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
* ```
*
* @public
*/
export class StoreQueries<R extends UnknownRecord> {
/**
* Creates a new StoreQueries instance.
*
* recordMap - The atom map containing all records in the store
* history - The atom tracking the store's change history with diffs
*
* @internal
*/
constructor(
private readonly recordMap: AtomMap<IdOf<R>, R>,
private readonly history: Atom<number, RecordsDiff<R>>
) {}
/**
* A cache of derivations (indexes).
*
* @internal
*/
private indexCache = new Map<string, RSIndex<R>>()
/**
* A cache of derivations (filtered histories).
*
* @internal
*/
private historyCache = new Map<string, Computed<number, RecordsDiff<R>>>()
/**
* @internal
*/
public getAllIdsForType<TypeName extends R['typeName']>(
typeName: TypeName
): Set<IdOf<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
const ids = new Set<IdOf<S>>()
for (const record of this.recordMap.values()) {
if (record.typeName === typeName) {
ids.add(record.id as IdOf<S>)
}
}
return ids
}
/**
* @internal
*/
public getRecordById<TypeName extends R['typeName']>(
typeName: TypeName,
id: IdOf<Extract<R, { typeName: TypeName }>>
): Extract<R, { typeName: TypeName }> | undefined {
const record = this.recordMap.get(id as IdOf<R>)
if (record && record.typeName === typeName) {
return record as Extract<R, { typeName: TypeName }>
}
return undefined
}
/**
* Helper to extract nested property value using pre-split path parts.
* @internal
*/
private getNestedValue(obj: any, pathParts: string[]): any {
let current = obj
for (const part of pathParts) {
if (current == null || typeof current !== 'object') return undefined
current = current[part]
}
return current
}
/**
* Creates a reactive computed that tracks the change history for records of a specific type.
* The returned computed provides incremental diffs showing what records of the given type
* have been added, updated, or removed.
*
* @param typeName - The type name to filter the history by
* @returns A computed value containing a change counter and diffs of changes for the specified type
*
* @example
* ```ts
* // Track changes to book records only
* const bookHistory = store.query.filterHistory('book')
*
* // Log the books added since the last run
* react('book-changes', (lastReactedEpoch) => {
* const diffs = bookHistory.getDiffSince(lastReactedEpoch)
* // no diffs to give, e.g. on the first run: rebuild anything derived from scratch
* if (diffs === RESET_VALUE) return
* for (const diff of diffs) console.log('Added:', Object.keys(diff.added))
* })
* ```
*
* @public
*/
public filterHistory<TypeName extends R['typeName']>(
typeName: TypeName
): Computed<number, RecordsDiff<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
if (this.historyCache.has(typeName)) {
return this.historyCache.get(typeName) as any
}
const filtered = computed<number, RecordsDiff<S>>(
'filterHistory:' + typeName,
(lastValue, lastComputedEpoch) => {
if (isUninitialized(lastValue)) {
return this.history.get()
}
// The value tracks the store's history counter, but it must strictly increase on every
// reset or relevant change: after a rolled-back transaction the counter can come back
// round to `lastValue`, and an unchanged value would hide the reset from downstream
// indexes and queries, leaving them stale for good.
const next = Math.max(this.history.get(), lastValue + 1)
const diff = this.history.getDiffSince(lastComputedEpoch)
if (diff === RESET_VALUE) return next
const res = { added: {}, removed: {}, updated: {} } as RecordsDiff<S>
const size = squashRecordDiffsMutableByType(res, diff, typeName)
if (size) {
return withDiff(next, res)
} else {
return lastValue
}
},
{ historyLength: 100 }
)
this.historyCache.set(typeName, filtered)
return filtered
}
/**
* Creates a reactive index that maps property values to sets of record IDs for efficient lookups.
* The index automatically updates when records are added, updated, or removed, and results are cached
* for performance.
*
* Supports nested property paths using backslash separator (e.g., 'metadata\\sessionId').
*
* @param typeName - The type name of records to index
* @param path - The property name or backslash-delimited path to index by
* @returns A reactive computed containing the index map with change diffs
*
* @example
* ```ts
* // Create an index of books by author ID
* const booksByAuthor = store.query.index('book', 'authorId')
*
* // Get all books by a specific author
* const authorBooks = booksByAuthor.get().get('author:leguin')
* console.log(authorBooks) // Set<RecordId<Book>>
*
* // Index by nested property using backslash separator
* const booksBySession = store.query.index('book', 'metadata\\sessionId')
* const sessionBooks = booksBySession.get().get('session:alpha')
* ```
*
* @public
*/
public index<TypeName extends R['typeName']>(
typeName: TypeName,
path: string
): RSIndex<Extract<R, { typeName: TypeName }>> {
const cacheKey = typeName + ':' + path
if (this.indexCache.has(cacheKey)) {
return this.indexCache.get(cacheKey) as any
}
const index = this.__uncached_createIndex(typeName, path)
this.indexCache.set(cacheKey, index as any)
return index
}
/**
* Creates a new index without checking the cache. This method performs the actual work
* of building the reactive index computation that tracks property values to record ID sets.
*
* Supports nested property paths using backslash separator.
*
* @param typeName - The type name of records to index
* @param path - The property name or backslash-delimited path to index by
* @returns A reactive computed containing the index map with change diffs
*
* @internal
*/
__uncached_createIndex<TypeName extends R['typeName']>(
typeName: TypeName,
path: string
): RSIndex<Extract<R, { typeName: TypeName }>> {
type S = Extract<R, { typeName: TypeName }>
const typeHistory = this.filterHistory(typeName)
// Create closure for efficient property value extraction
const pathParts = path.split('\\')
const getPropertyValue =
pathParts.length > 1
? (obj: S) => this.getNestedValue(obj, pathParts)
: (obj: S) => obj[path as keyof S]
const fromScratch = () => {
// deref typeHistory early so that the first time the incremental version runs
// it gets a diff to work with instead of having to bail to this from-scratch version
typeHistory.get()
const res = new Map<any, Set<IdOf<S>>>()
for (const record of this.recordMap.values()) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
if (!res.has(value)) {
res.set(value, new Set())
}
res.get(value)!.add(record.id)
}
}
}
return res
}
return computed<RSIndexMap<S>, RSIndexDiff<S>>(
'index:' + typeName + ':' + path,
(prevValue, lastComputedEpoch) => {
if (isUninitialized(prevValue)) return fromScratch()
const history = typeHistory.getDiffSince(lastComputedEpoch)
if (history === RESET_VALUE) {
return fromScratch()
}
const setConstructors = new Map<any, IncrementalSetConstructor<IdOf<S>>>()
const add = (value: any, id: IdOf<S>) => {
let setConstructor = setConstructors.get(value)
if (!setConstructor)
setConstructor = new IncrementalSetConstructor<IdOf<S>>(
prevValue.get(value) ?? new Set()
)
setConstructor.add(id)
setConstructors.set(value, setConstructor)
}
const remove = (value: any, id: IdOf<S>) => {
let set = setConstructors.get(value)
if (!set) set = new IncrementalSetConstructor<IdOf<S>>(prevValue.get(value) ?? new Set())
set.remove(id)
setConstructors.set(value, set)
}
for (const changes of history) {
for (const record of objectMapValues(changes.added)) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
add(value, record.id)
}
}
}
for (const [from, to] of objectMapValues(changes.updated)) {
if (to.typeName === typeName) {
const prev = getPropertyValue(from as S)
const next = getPropertyValue(to as S)
if (prev !== next) {
if (prev !== undefined) {
remove(prev, to.id)
}
if (next !== undefined) {
add(next, to.id)
}
}
}
}
for (const record of objectMapValues(changes.removed)) {
if (record.typeName === typeName) {
const value = getPropertyValue(record as S)
if (value !== undefined) {
remove(value, record.id)
}
}
}
}
let nextValue: undefined | RSIndexMap<S> = undefined
let nextDiff: undefined | RSIndexDiff<S> = undefined
for (const [value, setConstructor] of setConstructors) {
const result = setConstructor.get()
if (!result) continue
if (!nextValue) nextValue = new Map(prevValue)
if (!nextDiff) nextDiff = new Map()
if (result.value.size === 0) {
nextValue.delete(value)
} else {
nextValue.set(value, result.value)
}
nextDiff.set(value, result.diff)
}
if (nextValue && nextDiff) {
return withDiff(nextValue, nextDiff)
}
return prevValue
},
{ historyLength: 100 }
)
}
/**
* Creates a reactive query that returns the first record matching the given query criteria.
* Returns undefined if no matching record is found. The query automatically updates
* when records change.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing the first matching record or undefined
*
* @example
* ```ts
* // Find the first book with a specific title
* const bookLatheOfHeaven = store.query.record('book', () => ({ title: { eq: 'The Lathe of Heaven' } }))
* console.log(bookLatheOfHeaven.get()?.title) // 'The Lathe of Heaven' or undefined
*
* // Find any book in stock
* const anyInStockBook = store.query.record('book', () => ({ inStock: { eq: true } }))
* ```
*
* @public
*/
record<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'record:' + typeName + ':' + queryCreator.toString()
): Computed<Extract<R, { typeName: TypeName }> | undefined> {
type S = Extract<R, { typeName: TypeName }>
const ids = this.ids(typeName, queryCreator, name)
return computed<S | undefined>(name, () => {
for (const id of ids.get()) {
return this.recordMap.get(id) as S | undefined
}
return undefined
})
}
/**
* Creates a reactive query that returns an array of all records matching the given query criteria.
* The array automatically updates when records are added, updated, or removed.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing an array of all matching records
*
* @example
* ```ts
* // Get all books in stock
* const inStockBooks = store.query.records('book', () => ({ inStock: { eq: true } }))
* console.log(inStockBooks.get()) // Book[]
*
* // Get all books by a specific author
* const leguinBooks = store.query.records('book', () => ({ authorId: { eq: 'author:leguin' } }))
*
* // Get all books (no filter)
* const allBooks = store.query.records('book')
* ```
*
* @public
*/
records<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'records:' + typeName + ':' + queryCreator.toString()
): Computed<Array<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
const ids = this.ids(typeName, queryCreator, 'ids:' + name)
return computed<S[]>(
name,
() => {
return Array.from(ids.get(), (id) => this.recordMap.get(id) as S)
},
{
isEqual: areArraysShallowEqual,
}
)
}
/**
* Creates a reactive query that returns a set of record IDs matching the given query criteria.
* This is more efficient than `records()` when you only need the IDs and not the full record objects.
* The set automatically updates with collection diffs when records change.
*
* @param typeName - The type name of records to query
* @param queryCreator - Function that returns the query expression object to match against
* @param name - Optional name for the query computation (used for debugging)
* @returns A computed value containing a set of matching record IDs with collection diffs
*
* @example
* ```ts
* // Get IDs of all books in stock
* const inStockBookIds = store.query.ids('book', () => ({ inStock: { eq: true } }))
* console.log(inStockBookIds.get()) // Set<RecordId<Book>>
*
* // Get all book IDs (no filter)
* const allBookIds = store.query.ids('book')
*
* // Use with other queries for efficient lookups
* const authorBookIds = store.query.ids('book', () => ({ authorId: { eq: 'author:leguin' } }))
* ```
*
* @public
*/
ids<TypeName extends R['typeName']>(
typeName: TypeName,
queryCreator: () => QueryExpression<Extract<R, { typeName: TypeName }>> = () => ({}),
name = 'ids:' + typeName + ':' + queryCreator.toString()
): Computed<
Set<IdOf<Extract<R, { typeName: TypeName }>>>,
CollectionDiff<IdOf<Extract<R, { typeName: TypeName }>>>
> {
type S = Extract<R, { typeName: TypeName }>
const typeHistory = this.filterHistory(typeName)
const fromScratch = () => {
// deref type history early to allow first incremental update to use diffs
typeHistory.get()
const query: QueryExpression<S> = queryCreator()
if (!hasAnyKey(query)) {
return this.getAllIdsForType(typeName)
}
return executeQuery(this, typeName, query)
}
const fromScratchWithDiff = (prevValue: Set<IdOf<S>>) => {
const nextValue = fromScratch()
const diff = diffSets(prevValue, nextValue)
if (diff) {
return withDiff(nextValue, diff)
} else {
return prevValue
}
}
// Wrapped rather than passed straight through: a computed's derive function receives
// `(previousValue, lastComputedEpoch)`, which a queryCreator with optional parameters
// would otherwise misread.
const cachedQuery = computed('ids_query:' + name, () => queryCreator(), {
isEqual,
})
return computed(
'query:' + name,
(prevValue, lastComputedEpoch) => {
const query = cachedQuery.get()
if (isUninitialized(prevValue)) {
return fromScratch()
}
// if the query changed since last time this ran then we need to start again
if (lastComputedEpoch < cachedQuery.lastChangedEpoch) {
return fromScratchWithDiff(prevValue)
}
// otherwise iterate over the changes from the store and apply them to the previous value if needed
const history = typeHistory.getDiffSince(lastComputedEpoch)
if (history === RESET_VALUE) {
return fromScratchWithDiff(prevValue)
}
const setConstructor = new IncrementalSetConstructor<IdOf<S>>(
prevValue
) as IncrementalSetConstructor<IdOf<S>>
for (const changes of history) {
for (const added of objectMapValues(changes.added)) {
if (added.typeName === typeName && objectMatchesQuery(query, added)) {
setConstructor.add(added.id)
}
}
for (const [_, updated] of objectMapValues(changes.updated)) {
if (updated.typeName === typeName) {
if (objectMatchesQuery(query, updated)) {
setConstructor.add(updated.id)
} else {
setConstructor.remove(updated.id)
}
}
}
for (const removed of objectMapValues(changes.removed)) {
if (removed.typeName === typeName) {
setConstructor.remove(removed.id)
}
}
}
const result = setConstructor.get()
if (!result) {
return prevValue
}
return withDiff(result.value, result.diff)
},
{ historyLength: 50 }
)
}
/**
* Executes a one-time query against the current store state and returns matching records.
* This is a non-reactive query that returns results immediately without creating a computed value.
* Use this when you need a snapshot of data at a specific point in time.
*
* @param typeName - The type name of records to query
* @param query - The query expression object to match against
* @returns An array of records that match the query at the current moment
*
* @example
* ```ts
* // Get current in-stock books (non-reactive)
* const currentInStockBooks = store.query.exec('book', { inStock: { eq: true } })
* console.log(currentInStockBooks) // Book[]
*
* // Unlike records(), this won't update when the data changes
* const staticBookList = store.query.exec('book', { authorId: { eq: 'author:leguin' } })
* ```
*
* @public
*/
exec<TypeName extends R['typeName']>(
typeName: TypeName,
query: QueryExpression<Extract<R, { typeName: TypeName }>>
): Array<Extract<R, { typeName: TypeName }>> {
const ids = executeQuery(this, typeName, query)
if (ids.size === 0) {
return EMPTY_ARRAY
}
return Array.from(ids, (id) => this.recordMap.get(id) as Extract<R, { typeName: TypeName }>)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,813 @@
import {
assert,
exhaustiveSwitchError,
getOwnProperty,
isEqual,
objectMapEntries,
Result,
structuredClone,
} from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { devFreeze } from './devFreeze'
import {
Migration,
MigrationFailureReason,
MigrationId,
MigrationResult,
MigrationSequence,
parseMigrationId,
sortMigrations,
SynchronousStorage,
validateMigrations,
} from './migrate'
import { RecordType } from './RecordType'
import { SerializedStore, Store, StoreSnapshot } from './Store'
/**
* Version 1 format for serialized store schema information.
*
* This is the legacy format used before schema version 2. Version 1 schemas
* separate store-level versioning from record-level versioning, and support
* subtypes for complex record types like shapes.
*
* @example
* ```ts
* const schemaV1: SerializedSchemaV1 = {
* schemaVersion: 1,
* storeVersion: 2,
* recordVersions: {
* book: { version: 3 },
* shape: {
* version: 2,
* subTypeVersions: { rectangle: 1, circle: 2 },
* subTypeKey: 'type'
* }
* }
* }
* ```
*
* @public
*/
export interface SerializedSchemaV1 {
/** Schema version is the version for this type you're looking at right now */
schemaVersion: 1
/**
* Store version is the version for the structure of the store. e.g. higher level structure like
* removing or renaming a record type.
*/
storeVersion: number
/** Record versions are the versions for each record type. e.g. adding a new field to a record */
recordVersions: Record<
string,
| {
version: number
}
| {
// subtypes are used for migrating shape and asset props
version: number
subTypeVersions: Record<string, number>
subTypeKey: string
}
>
}
/**
* Version 2 format for serialized store schema information.
*
* This is the current format that uses a unified sequence-based approach
* for tracking versions across all migration sequences. Each sequence ID
* maps to the latest version number for that sequence.
*
* @example
* ```ts
* const schemaV2: SerializedSchemaV2 = {
* schemaVersion: 2,
* sequences: {
* 'com.tldraw.store': 3,
* 'com.tldraw.book': 2,
* 'com.tldraw.shape': 4,
* 'com.tldraw.shape.rectangle': 1
* }
* }
* ```
*
* @public
*/
export interface SerializedSchemaV2 {
schemaVersion: 2
sequences: {
[sequenceId: string]: number
}
}
/**
* Union type representing all supported serialized schema formats.
*
* This type allows the store to handle both legacy (V1) and current (V2)
* schema formats during deserialization and migration.
*
* @example
* ```ts
* function handleSchema(schema: SerializedSchema) {
* if (schema.schemaVersion === 1) {
* // Handle V1 format
* console.log('Store version:', schema.storeVersion)
* } else {
* // Handle V2 format
* console.log('Sequences:', schema.sequences)
* }
* }
* ```
*
* @public
*/
export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2
/**
* Upgrades a serialized schema from version 1 to version 2 format.
*
* Version 1 schemas use separate `storeVersion` and `recordVersions` fields,
* while version 2 schemas use a unified `sequences` object with sequence IDs.
*
* @param schema - The serialized schema to upgrade
* @returns A Result containing the upgraded schema or an error message
*
* @example
* ```ts
* const v1Schema = {
* schemaVersion: 1,
* storeVersion: 1,
* recordVersions: {
* book: { version: 2 },
* author: { version: 1, subTypeVersions: { fiction: 1 }, subTypeKey: 'genre' }
* }
* }
*
* const result = upgradeSchema(v1Schema)
* if (result.ok) {
* console.log(result.value.sequences)
* // { 'com.tldraw.store': 1, 'com.tldraw.book': 2, 'com.tldraw.author': 1, 'com.tldraw.author.fiction': 1 }
* }
* ```
*
* @public
*/
export function upgradeSchema(schema: SerializedSchema): Result<SerializedSchemaV2, string> {
if (schema.schemaVersion > 2 || schema.schemaVersion < 1) return Result.err('Bad schema version')
if (schema.schemaVersion === 2) return Result.ok(schema as SerializedSchemaV2)
const result: SerializedSchemaV2 = {
schemaVersion: 2,
sequences: {
'com.tldraw.store': schema.storeVersion,
},
}
for (const [typeName, recordVersion] of Object.entries(schema.recordVersions)) {
result.sequences[`com.tldraw.${typeName}`] = recordVersion.version
if ('subTypeKey' in recordVersion) {
for (const [subType, version] of Object.entries(recordVersion.subTypeVersions)) {
result.sequences[`com.tldraw.${typeName}.${subType}`] = version
}
}
}
return Result.ok(result)
}
/**
* Information about a record validation failure that occurred in the store.
*
* This interface provides context about validation errors, including the failed
* record, the store state, and the operation phase where the failure occurred.
* It's used by validation failure handlers to implement recovery strategies.
*
* @example
* ```ts
* const schema = StoreSchema.create(
* { book: Book },
* {
* onValidationFailure: (failure: StoreValidationFailure<Book>) => {
* console.error(`Validation failed during ${failure.phase}:`, failure.error)
* console.log('Failed record:', failure.record)
* console.log('Previous record:', failure.recordBefore)
*
* // Return a corrected version of the record
* return { ...failure.record, title: failure.record.title || 'Untitled' }
* }
* }
* )
* ```
*
* @public
*/
export interface StoreValidationFailure<R extends UnknownRecord> {
error: unknown
store: Store<R>
record: R
phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests'
recordBefore: R | null
}
/**
* Configuration options for creating a StoreSchema.
*
* These options control migration behavior, validation error handling,
* and integrity checking for the store schema.
*
* @example
* ```ts
* const options: StoreSchemaOptions<MyRecord, MyProps> = {
* migrations: [bookMigrations, authorMigrations],
* onValidationFailure: (failure) => {
* // Log the error and return a corrected record
* console.error('Validation failed:', failure.error)
* return sanitizeRecord(failure.record)
* },
* createIntegrityChecker: (store) => {
* // Set up integrity checking logic
* return setupIntegrityChecks(store)
* }
* }
* ```
*
* @public
*/
export interface StoreSchemaOptions<R extends UnknownRecord, P> {
migrations?: MigrationSequence[]
/** @public */
onValidationFailure?(data: StoreValidationFailure<R>): R
/** @internal */
createIntegrityChecker?(store: Store<R, P>): void
}
/**
* Manages the schema definition, validation, and migration system for a Store.
*
* StoreSchema coordinates record types, handles data migrations between schema
* versions, validates records, and provides the foundational structure for
* reactive stores. It acts as the central authority for data consistency
* and evolution within the store system.
*
* @example
* ```ts
* // Define record types
* const Book = createRecordType<Book>('book', { scope: 'document' })
* const Author = createRecordType<Author>('author', { scope: 'document' })
*
* // Create schema with migrations
* const schema = StoreSchema.create(
* { book: Book, author: Author },
* {
* migrations: [bookMigrations, authorMigrations],
* onValidationFailure: (failure) => {
* console.warn('Validation failed, using default:', failure.error)
* return failure.record // or return a corrected version
* }
* }
* )
*
* // Use with store
* const store = new Store({ schema })
* ```
*
* @public
*/
export class StoreSchema<R extends UnknownRecord, P = unknown> {
/**
* Creates a new StoreSchema with the given record types and options.
*
* This static factory method is the recommended way to create a StoreSchema.
* It ensures type safety while providing a clean API for schema definition.
*
* @param types - Object mapping type names to their RecordType definitions
* @param options - Optional configuration for migrations, validation, and integrity checking
* @returns A new StoreSchema instance
*
* @example
* ```ts
* const Book = createRecordType<Book>('book', { scope: 'document' })
* const Author = createRecordType<Author>('author', { scope: 'document' })
*
* const schema = StoreSchema.create(
* {
* book: Book,
* author: Author
* },
* {
* migrations: [bookMigrations],
* onValidationFailure: (failure) => failure.record
* }
* )
* ```
*
* @public
*/
static create<R extends UnknownRecord, P = unknown>(
// HACK: making this param work with RecordType is an enormous pain
// let's just settle for making sure each typeName has a corresponding RecordType
// and accept that this function won't be able to infer the record type from it's arguments
types: { [TypeName in R['typeName']]: { createId: any } },
options?: StoreSchemaOptions<R, P>
): StoreSchema<R, P> {
return new StoreSchema<R, P>(types as any, options ?? {})
}
readonly migrations: Record<string, MigrationSequence> = {}
readonly sortedMigrations: readonly Migration[]
private readonly migrationCache = new WeakMap<SerializedSchema, Result<Migration[], string>>()
private constructor(
public readonly types: {
[Record in R as Record['typeName']]: RecordType<R, any>
},
private readonly options: StoreSchemaOptions<R, P>
) {
for (const m of options.migrations ?? []) {
assert(!this.migrations[m.sequenceId], `Duplicate migration sequenceId ${m.sequenceId}`)
validateMigrations(m)
this.migrations[m.sequenceId] = m
}
const allMigrations = Object.values(this.migrations).flatMap((m) => m.sequence)
this.sortedMigrations = sortMigrations(allMigrations)
for (const migration of this.sortedMigrations) {
if (!migration.dependsOn?.length) continue
for (const dep of migration.dependsOn) {
const depMigration = allMigrations.find((m) => m.id === dep)
assert(depMigration, `Migration '${migration.id}' depends on missing migration '${dep}'`)
}
}
}
/**
* Validates a record using its corresponding RecordType validator.
*
* This method ensures that records conform to their type definitions before
* being stored. If validation fails and an onValidationFailure handler is
* provided, it will be called to potentially recover from the error.
*
* @param store - The store instance where validation is occurring
* @param record - The record to validate
* @param phase - The lifecycle phase where validation is happening
* @param recordBefore - The previous version of the record (for updates)
* @returns The validated record, potentially modified by validation failure handler
*
* @example
* ```ts
* try {
* const validatedBook = schema.validateRecord(
* store,
* { id: 'book:1', typeName: 'book', title: '', author: 'Jane Doe' },
* 'createRecord',
* null
* )
* } catch (error) {
* console.error('Record validation failed:', error)
* }
* ```
*
* @public
*/
validateRecord(
store: Store<R>,
record: R,
phase: 'initialize' | 'createRecord' | 'updateRecord' | 'tests',
recordBefore: R | null
): R {
try {
const recordType = getOwnProperty(this.types, record.typeName)
if (!recordType) {
throw new Error(`Missing definition for record type ${record.typeName}`)
}
return recordType.validate(record, recordBefore ?? undefined)
} catch (error: unknown) {
if (this.options.onValidationFailure) {
return this.options.onValidationFailure({
store,
record,
phase,
recordBefore,
error,
})
} else {
throw error
}
}
}
/**
* Gets all migrations that need to be applied to upgrade from a persisted schema
* to the current schema version.
*
* This method compares the persisted schema with the current schema and determines
* which migrations need to be applied to bring the data up to date. It handles
* both regular migrations and retroactive migrations, and caches results for
* performance.
*
* @param persistedSchema - The schema version that was previously persisted
* @returns A Result containing the list of migrations to apply, or an error message
*
* @example
* ```ts
* const persistedSchema = {
* schemaVersion: 2,
* sequences: { 'com.tldraw.book': 1, 'com.tldraw.author': 0 }
* }
*
* const migrationsResult = schema.getMigrationsSince(persistedSchema)
* if (migrationsResult.ok) {
* console.log('Migrations to apply:', migrationsResult.value.length)
* // Apply each migration to bring data up to date
* }
* ```
*
* @public
*/
public getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string> {
// Every result — success, empty, or error — is cached, so cache once around the whole
// computation rather than at each of its exit points.
const cached = this.migrationCache.get(persistedSchema)
if (cached) {
return cached
}
const result = this.computeMigrationsSince(persistedSchema)
this.migrationCache.set(persistedSchema, result)
return result
}
private computeMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string> {
const upgradeResult = upgradeSchema(persistedSchema)
if (!upgradeResult.ok) {
return upgradeResult
}
const schema = upgradeResult.value
const sequenceIdsToInclude = new Set(
// start with any shared sequences
Object.keys(schema.sequences).filter((sequenceId) => this.migrations[sequenceId])
)
// also include any sequences that are not in the persisted schema but are marked as postHoc
for (const sequenceId in this.migrations) {
if (schema.sequences[sequenceId] === undefined && this.migrations[sequenceId].retroactive) {
sequenceIdsToInclude.add(sequenceId)
}
}
if (sequenceIdsToInclude.size === 0) {
return Result.ok([])
}
const allMigrationsToInclude = new Set<MigrationId>()
for (const sequenceId of sequenceIdsToInclude) {
const theirVersion = schema.sequences[sequenceId]
if (
(typeof theirVersion !== 'number' && this.migrations[sequenceId].retroactive) ||
theirVersion === 0
) {
for (const migration of this.migrations[sequenceId].sequence) {
allMigrationsToInclude.add(migration.id)
}
continue
}
const theirVersionId = `${sequenceId}/${theirVersion}`
const idx = this.migrations[sequenceId].sequence.findIndex((m) => m.id === theirVersionId)
// todo: better error handling
if (idx === -1) {
return Result.err('Incompatible schema?')
}
for (const migration of this.migrations[sequenceId].sequence.slice(idx + 1)) {
allMigrationsToInclude.add(migration.id)
}
}
// collect any migrations
return Result.ok(this.sortedMigrations.filter(({ id }) => allMigrationsToInclude.has(id)))
}
/**
* Migrates a single persisted record to match the current schema version.
*
* This method applies the necessary migrations to transform a record from an
* older (or newer) schema version to the current version. It supports both
* forward ('up') and backward ('down') migrations.
*
* @param record - The record to migrate
* @param persistedSchema - The schema version the record was persisted with
* @param direction - Direction to migrate ('up' for newer, 'down' for older)
* @returns A MigrationResult containing the migrated record or an error
*
* @example
* ```ts
* const oldRecord = { id: 'book:1', typeName: 'book', title: 'Old Title', publishDate: '2020-01-01' }
* const oldSchema = { schemaVersion: 2, sequences: { 'com.tldraw.book': 1 } }
*
* const result = schema.migratePersistedRecord(oldRecord, oldSchema, 'up')
* if (result.type === 'success') {
* console.log('Migrated record:', result.value)
* // Record now has publishedYear instead of publishDate
* } else {
* console.error('Migration failed:', result.reason)
* }
* ```
*
* @public
*/
migratePersistedRecord(
record: R,
persistedSchema: SerializedSchema,
direction: 'up' | 'down' = 'up'
): MigrationResult<R> {
const migrations = this.getMigrationsSince(persistedSchema)
if (!migrations.ok) {
// TODO: better error
console.error('Error migrating record', migrations.error)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
let migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return { type: 'success', value: record }
}
if (!migrationsToApply.every((m) => m.scope === 'record')) {
return {
type: 'error',
reason:
direction === 'down'
? MigrationFailureReason.TargetVersionTooOld
: MigrationFailureReason.TargetVersionTooNew,
}
}
if (direction === 'down') {
if (!migrationsToApply.every((m) => m.scope === 'record' && m.down)) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
}
}
migrationsToApply = migrationsToApply.slice().reverse()
}
record = structuredClone(record)
try {
for (const migration of migrationsToApply) {
if (migration.scope === 'store') throw new Error(/* won't happen, just for TS */)
if (migration.scope === 'storage') throw new Error(/* won't happen, just for TS */)
const shouldApply = migration.filter ? migration.filter(record) : true
if (!shouldApply) continue
const result = migration[direction]!(record)
if (result) {
record = structuredClone(result) as any
}
}
} catch (e) {
console.error('Error migrating record', e)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
return { type: 'success', value: record }
}
migrateStorage(storage: SynchronousStorage<R>) {
const schema = storage.getSchema()
assert(schema, 'Schema is missing.')
const migrations = this.getMigrationsSince(schema)
if (!migrations.ok) {
console.error('Error migrating store', migrations.error)
throw new Error(migrations.error)
}
const migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return
}
storage.setSchema(this.serialize())
for (const migration of migrationsToApply) {
if (migration.scope === 'record') {
// Collect updates during iteration, then apply them after.
// This avoids issues with live iterators (e.g., SQLite) where updating
// records during iteration can cause them to be visited multiple times.
const updates: [string, R][] = []
for (const [id, state] of storage.entries()) {
const shouldApply = migration.filter ? migration.filter(state) : true
if (!shouldApply) continue
const record = structuredClone(state)
const result = migration.up!(record as any) ?? record
if (!isEqual(result, state)) {
updates.push([id, result as R])
}
}
for (const [id, record] of updates) {
storage.set(id, record)
}
} else if (migration.scope === 'store') {
// legacy
const prevStore = Object.fromEntries(storage.entries())
let nextStore = structuredClone(prevStore)
nextStore = (migration.up!(nextStore) as any) ?? nextStore
for (const [id, state] of Object.entries(nextStore)) {
if (!state) continue // these will be deleted in the next loop
if (!isEqual(state, prevStore[id])) {
storage.set(id, state)
}
}
for (const id of Object.keys(prevStore)) {
if (!nextStore[id]) {
storage.delete(id)
}
}
} else if (migration.scope === 'storage') {
migration.up!(storage)
} else {
exhaustiveSwitchError(migration)
}
}
// Clean up by filtering out any non-document records.
// This is mainly legacy support for extremely early days tldraw.
// Collect first: as above, deleting during a live iterator can throw on SQLite backends.
const idsToDelete: string[] = []
for (const [id, state] of storage.entries()) {
if (this.getType(state.typeName).scope !== 'document') {
idsToDelete.push(id)
}
}
for (const id of idsToDelete) {
storage.delete(id)
}
}
/**
* Migrates an entire store snapshot to match the current schema version.
*
* This method applies all necessary migrations to bring a persisted store
* snapshot up to the current schema version. It handles both record-level
* and store-level migrations, and can optionally mutate the input store
* for performance.
*
* @param snapshot - The store snapshot containing data and schema information
* @param opts - Options controlling migration behavior
* - mutateInputStore - Whether to modify the input store directly (default: false)
* @returns A MigrationResult containing the migrated store or an error
*
* @example
* ```ts
* const snapshot = {
* schema: { schemaVersion: 2, sequences: { 'com.tldraw.book': 1 } },
* store: {
* 'book:1': { id: 'book:1', typeName: 'book', title: 'Old Book', publishDate: '2020-01-01' }
* }
* }
*
* const result = schema.migrateStoreSnapshot(snapshot)
* if (result.type === 'success') {
* console.log('Migrated store:', result.value)
* // All records are now at current schema version
* }
* ```
*
* @public
*/
migrateStoreSnapshot(
snapshot: StoreSnapshot<R>,
opts?: { mutateInputStore?: boolean }
): MigrationResult<SerializedStore<R>> {
const migrations = this.getMigrationsSince(snapshot.schema)
if (!migrations.ok) {
// TODO: better error
console.error('Error migrating store', migrations.error)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
const migrationsToApply = migrations.value
if (migrationsToApply.length === 0) {
return { type: 'success', value: snapshot.store }
}
const store = Object.assign(
new Map<string, R>(objectMapEntries(snapshot.store).map(devFreeze)),
{
getSchema: () => snapshot.schema,
setSchema: (_: SerializedSchema) => {},
}
)
try {
this.migrateStorage(store)
if (opts?.mutateInputStore) {
for (const [id, record] of store.entries()) {
snapshot.store[id as keyof typeof snapshot.store] = record
}
for (const id of Object.keys(snapshot.store)) {
if (!store.has(id)) {
delete snapshot.store[id as keyof typeof snapshot.store]
}
}
return { type: 'success', value: snapshot.store }
} else {
return {
type: 'success',
value: Object.fromEntries(store.entries()) as SerializedStore<R>,
}
}
} catch (e) {
console.error('Error migrating store', e)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
}
}
/**
* Creates an integrity checker function for the given store.
*
* This method calls the createIntegrityChecker option if provided, allowing
* custom integrity checking logic to be set up for the store. The integrity
* checker is used to validate store consistency and catch data corruption.
*
* @param store - The store instance to create an integrity checker for
* @returns An integrity checker function, or undefined if none is configured
*
* @internal
*/
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined {
return this.options.createIntegrityChecker?.(store) ?? undefined
}
/**
* Serializes the current schema to a SerializedSchemaV2 format.
*
* This method creates a serialized representation of the current schema,
* capturing the latest version number for each migration sequence.
* The result can be persisted and later used to determine what migrations
* need to be applied when loading data.
*
* @returns A SerializedSchemaV2 object representing the current schema state
*
* @example
* ```ts
* const serialized = schema.serialize()
* console.log(serialized)
* // {
* // schemaVersion: 2,
* // sequences: {
* // 'com.tldraw.book': 3,
* // 'com.tldraw.author': 2
* // }
* // }
*
* // Store this with your data for future migrations
* localStorage.setItem('schema', JSON.stringify(serialized))
* ```
*
* @public
*/
serialize(): SerializedSchemaV2 {
return {
schemaVersion: 2,
sequences: Object.fromEntries(
Object.values(this.migrations).map(({ sequenceId, sequence }) => [
sequenceId,
sequence.length ? parseMigrationId(sequence.at(-1)!.id).version : 0,
])
),
}
}
/**
* Serializes a schema representing the earliest possible version.
*
* This method creates a serialized schema where all migration sequences
* are set to version 0, representing the state before any migrations
* have been applied. This is used in specific legacy scenarios.
*
* @returns A SerializedSchema with all sequences set to version 0
*
* @deprecated This is only here for legacy reasons, don't use it unless you have david's blessing!
* @internal
*/
serializeEarliestVersion(): SerializedSchema {
return {
schemaVersion: 2,
sequences: Object.fromEntries(
Object.values(this.migrations).map(({ sequenceId }) => [sequenceId, 0])
),
}
}
/**
* Gets the RecordType definition for a given type name.
*
* This method retrieves the RecordType associated with the specified
* type name, which contains the record's validation, creation, and
* other behavioral logic.
*
* @param typeName - The name of the record type to retrieve
* @returns The RecordType definition for the specified type
*
* @throws Will throw an error if the record type does not exist
*
* @internal
*/
getType(typeName: string) {
const type = getOwnProperty(this.types, typeName)
assert(type, 'record type does not exist')
return type
}
}

View File

@@ -0,0 +1,725 @@
import { react } from '@tldraw/state'
import { beforeEach, describe, expect, it, test, vi } from 'vitest'
import { BaseRecord, RecordId } from './BaseRecord'
import { createRecordType } from './RecordType'
import { HistoryEntry, Store } from './Store'
import { StoreSchema } from './StoreSchema'
// Tests for SPEC.md §6 (atomic operations and the side-effect flush) and §7 (side effect
// handlers). Rule IDs like [AO4] in test names refer to that document.
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: string
numPages: number
}
const Book = createRecordType<Book>('book', {
validator: { validate: (book) => book as Book },
scope: 'document',
}).withDefaultProperties(() => ({ numPages: 1 }))
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: string
}
const Author = createRecordType<Author>('author', {
validator: { validate: (author) => author as Author },
scope: 'document',
})
type LibraryType = Book | Author
const book1Id = Book.createId('darkness')
const book1 = Book.create({ title: 'the left hand of darkness', id: book1Id })
const book2Id = Book.createId('dispossessed')
const book2 = Book.create({ title: 'the dispossessed', id: book2Id })
let store: Store<LibraryType>
beforeEach(() => {
store = new Store({
props: {},
schema: StoreSchema.create<LibraryType>({ book: Book, author: Author }),
})
})
describe('side effect handlers (SE)', () => {
it('[SE1] handlers chain in registration order, each receiving the previous output', () => {
store.sideEffects.registerBeforeCreateHandler('book', (record) => ({
...record,
numPages: record.numPages + 10,
}))
store.sideEffects.registerBeforeCreateHandler('book', (record) => ({
...record,
numPages: record.numPages * 2,
}))
store.put([Book.create({ title: 'test', numPages: 5, id: book1Id })])
// first handler: 5 + 10 = 15, second handler: 15 * 2 = 30
expect(store.get(book1Id)!.numPages).toBe(30)
})
it('[SE1] each register call returns a remover', () => {
const handler = vi.fn((record: Book) => ({ ...record, title: 'transformed' }))
const cleanup = store.sideEffects.registerBeforeCreateHandler('book', handler)
cleanup()
store.put([book1])
expect(handler).not.toHaveBeenCalled()
expect(store.get(book1Id)!.title).toBe(book1.title)
})
it('[SE1] register() registers many handlers and returns a single cleanup', () => {
const beforeCreate = vi.fn((record: Book) => record)
const afterCreate = vi.fn()
const beforeChange = vi.fn((_prev: Book, next: Book) => next)
const afterChange = vi.fn()
const beforeDelete = vi.fn()
const afterDelete = vi.fn()
const cleanup = store.sideEffects.register({
book: { beforeCreate, afterCreate, beforeChange, afterChange, beforeDelete, afterDelete },
})
store.put([book1])
store.update(book1Id, (b) => ({ ...b, numPages: 2 }))
store.remove([book1Id])
expect(beforeCreate).toHaveBeenCalledTimes(1)
expect(afterCreate).toHaveBeenCalledTimes(1)
expect(beforeChange).toHaveBeenCalledTimes(1)
expect(afterChange).toHaveBeenCalledTimes(1)
expect(beforeDelete).toHaveBeenCalledTimes(1)
expect(afterDelete).toHaveBeenCalledTimes(1)
cleanup()
store.put([book1])
store.remove([book1Id])
expect(beforeCreate).toHaveBeenCalledTimes(1)
expect(afterDelete).toHaveBeenCalledTimes(1)
})
it('[SE1] handlers only fire for their registered type', () => {
const bookHandler = vi.fn()
store.sideEffects.registerAfterCreateHandler('book', bookHandler)
store.put([Author.create({ name: 'Ursula K. Le Guin' })])
expect(bookHandler).not.toHaveBeenCalled()
})
it('[SE2] beforeCreate transforms the record that gets stored', () => {
store.sideEffects.registerBeforeCreateHandler('book', (record, source) => {
expect(source).toBe('user')
return { ...record, title: 'transformed' }
})
store.put([book1])
expect(store.get(book1Id)!.title).toBe('transformed')
})
it('[SE3] beforeChange transforms the update', () => {
store.put([book1])
store.sideEffects.registerBeforeChangeHandler('book', (_prev, next) => ({
...next,
title: next.title + '_transformed',
}))
store.update(book1Id, (b) => ({ ...b, title: 'updated' }))
expect(store.get(book1Id)!.title).toBe('updated_transformed')
})
it('[SE3] returning prev from beforeChange blocks the update entirely', () => {
store.put([book1])
store.sideEffects.registerBeforeChangeHandler('book', (prev, next) => {
if (next.numPages > 100) return prev
return next
})
const listener = vi.fn()
store.listen(listener)
store.update(book1Id, (b) => ({ ...b, numPages: 200 }))
expect(store.get(book1Id)!.numPages).toBe(1) // unchanged
expect(listener).not.toHaveBeenCalled() // no history entry either
})
it('[SE3] [S3] full blocking requires a reference-preserving validator', () => {
// a validator that returns a clone defeats the reference-equality skip
const CloningBook = createRecordType<Book>('book', {
validator: { validate: (book) => ({ ...(book as Book) }) },
scope: 'document',
})
const cloningStore = new Store<Book>({
props: {},
schema: StoreSchema.create<Book>({ book: CloningBook }),
})
cloningStore.put([book1])
cloningStore.sideEffects.registerBeforeChangeHandler('book', (prev) => prev)
const afterChange = vi.fn()
cloningStore.sideEffects.registerAfterChangeHandler('book', afterChange)
const listener = vi.fn()
cloningStore.listen(listener)
cloningStore.update(book1Id, (b) => ({ ...b, numPages: 200 }))
// the value is still blocked, and afterChange is suppressed by deep
// equality (AO5)...
expect(cloningStore.get(book1Id)).toEqual(book1)
expect(afterChange).not.toHaveBeenCalled()
// ...but a (no-op) history entry is recorded: not a complete no-op
expect(listener).toHaveBeenCalledTimes(1)
})
it('[SE4] beforeDelete can return false to prevent that deletion only', () => {
store.put([book1, book2])
store.sideEffects.registerBeforeDeleteHandler('book', (record) => {
if (record.id === book1Id) return false
return undefined
})
const afterDelete = vi.fn()
store.sideEffects.registerAfterDeleteHandler('book', afterDelete)
store.remove([book1Id, book2Id])
expect(store.has(book1Id)).toBe(true)
expect(store.has(book2Id)).toBe(false)
expect(afterDelete).toHaveBeenCalledTimes(1)
expect(afterDelete.mock.calls[0][0].id).toBe(book2Id)
})
it('[SE5] after handlers observe the final state of the operation', () => {
const onAfterCreate = vi.fn((current: Author) => {
expect(current).toEqual(
Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })
)
expect(store.get(Author.createId('tolkein'))).toEqual(current)
})
store.sideEffects.registerAfterCreateHandler('author', onAfterCreate)
store.put([Author.create({ name: 'J.R.R Tolkein', id: Author.createId('tolkein') })])
expect(onAfterCreate).toHaveBeenCalledTimes(1)
const onAfterChange = vi.fn((prev: Author, current: Author) => {
expect(prev.name).toBe('J.R.R Tolkein')
expect(current.name).toBe('Butch Cassidy')
expect(store.get(Author.createId('tolkein'))!.name).toBe('Butch Cassidy')
})
store.sideEffects.registerAfterChangeHandler('author', onAfterChange)
store.update(Author.createId('tolkein'), (r) => ({ ...r, name: 'Butch Cassidy' }))
expect(onAfterChange).toHaveBeenCalledTimes(1)
const onAfterDelete = vi.fn((prev: Author) => {
expect(prev.name).toBe('Butch Cassidy')
expect(store.has(Author.createId('tolkein'))).toBe(false)
})
store.sideEffects.registerAfterDeleteHandler('author', onAfterDelete)
store.remove([Author.createId('tolkein')])
expect(onAfterDelete).toHaveBeenCalledTimes(1)
})
it('[SE5] handlers receive the source', () => {
const sources: string[] = []
store.sideEffects.registerAfterCreateHandler('book', (_record, source) => {
sources.push(source)
})
store.put([book1])
store.mergeRemoteChanges(() => {
store.put([book2])
})
expect(sources).toEqual(['user', 'remote'])
})
it('[SE6] side effects disabled for an operation bypass all handlers', () => {
const beforeCreate = vi.fn((r: Book) => ({ ...r, title: 'transformed' }))
const afterCreate = vi.fn()
const beforeDelete = vi.fn(() => false as const)
const afterDelete = vi.fn()
const operationComplete = vi.fn()
store.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
store.sideEffects.registerAfterCreateHandler('book', afterCreate)
store.sideEffects.registerBeforeDeleteHandler('book', beforeDelete)
store.sideEffects.registerAfterDeleteHandler('book', afterDelete)
store.sideEffects.registerOperationCompleteHandler(operationComplete)
store.atomic(() => {
store.put([book1])
}, false)
expect(store.get(book1Id)!.title).toBe(book1.title) // not transformed
store.atomic(() => {
store.remove([book1Id])
}, false)
expect(store.has(book1Id)).toBe(false) // beforeDelete could not block
expect(beforeCreate).not.toHaveBeenCalled()
expect(afterCreate).not.toHaveBeenCalled()
expect(beforeDelete).not.toHaveBeenCalled()
expect(afterDelete).not.toHaveBeenCalled()
expect(operationComplete).not.toHaveBeenCalled()
})
it('[SE6] setIsEnabled(false) keeps handlers off across standalone mutations', () => {
const beforeCreate = vi.fn((r: Book) => ({ ...r, title: 'transformed' }))
const afterCreate = vi.fn()
store.sideEffects.registerBeforeCreateHandler('book', beforeCreate)
store.sideEffects.registerAfterCreateHandler('book', afterCreate)
store.sideEffects.setIsEnabled(false)
store.put([book1])
expect(beforeCreate).not.toHaveBeenCalled()
expect(afterCreate).not.toHaveBeenCalled()
expect(store.get(book1Id)!.title).toBe(book1.title)
// a nested attempt to switch them back on has no effect either
store.atomic(() => {
store.put([book2])
}, true)
expect(afterCreate).not.toHaveBeenCalled()
store.sideEffects.setIsEnabled(true)
store.remove([book1Id, book2Id])
store.put([book1])
expect(afterCreate).toHaveBeenCalledTimes(1)
})
})
describe('atomic operations (AO)', () => {
let callbacks: any[] = []
let onAfterCreate: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onAfterChange: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onAfterDelete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onBeforeCreate: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onBeforeChange: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onBeforeDelete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
let onOperationComplete: ReturnType<typeof vi.fn<(...args: any[]) => any>>
beforeEach(() => {
onAfterCreate = vi.fn((record) => callbacks.push({ type: 'create', record }))
onAfterChange = vi.fn((from, to) => callbacks.push({ type: 'change', from, to }))
onAfterDelete = vi.fn((record) => callbacks.push({ type: 'delete', record }))
onBeforeCreate = vi.fn((record) => record)
onBeforeChange = vi.fn((_from, to) => to)
onBeforeDelete = vi.fn((_record) => {})
onOperationComplete = vi.fn(() => callbacks.push({ type: 'complete' }))
callbacks = []
store.sideEffects.registerAfterCreateHandler('book', onAfterCreate)
store.sideEffects.registerAfterChangeHandler('book', onAfterChange)
store.sideEffects.registerAfterDeleteHandler('book', onAfterDelete)
store.sideEffects.registerBeforeCreateHandler('book', onBeforeCreate)
store.sideEffects.registerBeforeChangeHandler('book', onBeforeChange)
store.sideEffects.registerBeforeDeleteHandler('book', onBeforeDelete)
store.sideEffects.registerOperationCompleteHandler(onOperationComplete)
})
it('[AO1] atomic returns the function result and applies changes as one batch to effects', () => {
const sizes: number[] = []
const stop = react('count', () => {
sizes.push(store.query.ids('book').get().size)
})
const result = store.atomic(() => {
store.put([book1])
store.put([book2])
return 'completed'
})
expect(result).toBe('completed')
// the effect saw the empty store, then both books at once
expect(sizes).toEqual([0, 2])
stop()
})
it('[AO6] after callbacks fire when the outermost atomic completes', () => {
store.atomic(() => {
expect(callbacks).toHaveLength(0)
store.put([book1, book2])
expect(callbacks).toHaveLength(0)
})
expect(callbacks).toMatchObject([
{ type: 'create', record: { id: book1Id } },
{ type: 'create', record: { id: book2Id } },
{ type: 'complete' },
])
})
it('[AO2] nested atomic calls fold into the outer operation', () => {
store.atomic(() => {
store.put([book1])
store.atomic(() => {
store.put([book2])
})
expect(callbacks).toHaveLength(0)
})
expect(callbacks).toMatchObject([
{ type: 'create', record: { id: book1Id } },
{ type: 'create', record: { id: book2Id } },
{ type: 'complete' },
])
})
it('[AO2] mergeRemoteChanges inside an atomic operation throws', () => {
expect(() => {
store.atomic(() => {
store.mergeRemoteChanges(() => {
// never reached
})
})
}).toThrow('Cannot merge remote changes while in atomic operation')
})
it('[AO3] runCallbacks: false disables the before handlers', () => {
store.atomic(() => {
store.put([book1])
}, false)
expect(onBeforeCreate).not.toHaveBeenCalled()
expect(onAfterCreate).not.toHaveBeenCalled()
expect(store.has(book1Id)).toBe(true)
})
test('[AO3] a nested atomic can switch callbacks off but not back on', () => {
store.atomic(() => {
store.put([book1])
store.atomic(() => {
store.put([book2])
}, false)
})
expect(onBeforeCreate).toHaveBeenCalledTimes(1)
expect(onAfterCreate).toHaveBeenCalledTimes(2)
store.atomic(() => {
store.update(book1Id, (book) => ({ ...book, numPages: book.numPages + 1 }))
store.atomic(() => {
store.update(book2Id, (book) => ({ ...book, numPages: book.numPages + 1 }))
}, false)
})
expect(onBeforeChange).toHaveBeenCalledTimes(1)
expect(onAfterChange).toHaveBeenCalledTimes(2)
store.atomic(() => {
store.remove([book1Id])
store.atomic(() => {
store.remove([book2Id])
}, false)
})
expect(onBeforeDelete).toHaveBeenCalledTimes(1)
expect(onAfterDelete).toHaveBeenCalledTimes(2)
// switching ON inside an OFF block does not work
store.atomic(() => {
store.atomic(() => {
store.put([book1])
}, true)
}, false)
expect(onBeforeCreate).toHaveBeenCalledTimes(1)
})
it('[AO4] a record created then deleted in one operation produces no callbacks', () => {
store.atomic(() => {
store.put([book1])
store.remove([book1Id])
})
expect(callbacks).toMatchObject([{ type: 'complete' }])
})
it('[AO4] a record created then updated produces one afterCreate with the final value', () => {
store.atomic(() => {
store.put([book1])
store.update(book1Id, (b) => ({ ...b, numPages: 99 }))
})
expect(onAfterCreate).toHaveBeenCalledTimes(1)
expect(onAfterChange).not.toHaveBeenCalled()
expect(onAfterCreate.mock.calls[0][0].numPages).toBe(99)
})
it('[AO4] a record updated repeatedly produces one afterChange spanning first to last', () => {
store.put([book1])
callbacks = []
store.atomic(() => {
store.update(book1Id, (b) => ({ ...b, numPages: 2 }))
store.update(book1Id, (b) => ({ ...b, numPages: 3 }))
store.update(book1Id, (b) => ({ ...b, numPages: 4 }))
})
expect(onAfterChange).toHaveBeenCalledTimes(1)
expect(onAfterChange.mock.calls[0][0].numPages).toBe(1) // before
expect(onAfterChange.mock.calls[0][1].numPages).toBe(4) // after
})
test('[AO5] a round trip back to a deep-equal value does not fire afterChange', () => {
const book1A = book1
const book1B = { ...book1, title: book1.title + ' is a really great book fr fr' }
const book1C = structuredClone(book1A)
store.put([book1A])
store.atomic(() => {
store.put([book1B])
store.put([book1C])
})
expect(onAfterChange).toHaveBeenCalledTimes(0)
store.atomic(() => {
store.put([book1B])
store.put([book1C])
store.put([book1B])
})
expect(onAfterChange).toHaveBeenCalledTimes(1)
})
it('[AO6] changes made by after handlers run another round, with a depth limit', () => {
let limit = 10
onAfterCreate.mockImplementation((record: any) => {
if (record.numPages < limit) {
store.put([{ ...record, numPages: record.numPages + 1 }])
}
})
onAfterChange.mockImplementation((_from: any, to: any) => {
if (to.numPages < limit) {
store.put([{ ...to, numPages: to.numPages + 1 }])
}
})
// this should be fine:
store.put([book1])
expect(store.get(book1Id)!.numPages).toBe(limit)
// if we increase the limit, it should bail out:
limit = 10000
store.clear()
expect(() => {
store.put([book2])
}).toThrowErrorMatchingInlineSnapshot(
`[Error: Maximum store update depth exceeded, bailing out]`
)
})
it('[AO7] operationComplete keeps firing until the operation quiesces', () => {
// steps:
// 0, 1, 2: after change increment pages
// 3: after change, do nothing
// 4: operation complete, increment pages by 1000
// 5, 6: after change increment pages
// 7: after change, do nothing
// 8: operation complete, do nothing
// 9: done!
let step = 0
store.put([book1])
onAfterChange.mockImplementation((_prev: any, next: any) => {
if ([0, 1, 2, 5, 6].includes(step)) {
step++
store.put([{ ...next, numPages: next.numPages + 1 }])
} else if ([3, 7].includes(step)) {
step++
} else {
throw new Error(`Wrong step: ${step}`)
}
})
onOperationComplete.mockImplementation(() => {
if (step === 4) {
step++
const book = store.get(book1Id)!
store.put([{ ...book, numPages: book.numPages + 1000 }])
} else if (step === 8) {
step++
} else {
throw new Error(`Wrong step: ${step}`)
}
})
store.put([{ ...book1, numPages: 2 }])
expect(store.get(book1Id)!.numPages).toBe(1007)
expect(step).toBe(9)
})
it('[AO8] nested mergeRemoteChanges just runs its function', () => {
const sources: string[] = []
store.sideEffects.registerAfterCreateHandler('book', (_r, source) => sources.push(source))
store.mergeRemoteChanges(() => {
store.put([book1])
store.mergeRemoteChanges(() => {
store.put([book2])
})
})
expect(sources).toEqual(['remote', 'remote'])
})
test('[AO8] side-effect cascades from remote changes are attributed to user', () => {
const diffs: HistoryEntry<LibraryType>[] = []
store.listen((entry) => {
diffs.push(entry)
})
const firstOrderEffectSources: string[] = []
store.sideEffects.registerAfterCreateHandler('book', (record, source) => {
firstOrderEffectSources.push(source)
if (record.title.startsWith('Harry Potter')) {
store.put([{ ...record, title: record.title + ' is a really great book fr fr' }])
}
})
const secondOrderEffectSources: string[] = []
store.sideEffects.registerAfterChangeHandler('book', (_from, _to, source) => {
secondOrderEffectSources.push(source)
})
store.mergeRemoteChanges(() => {
store.put([{ ...book1, title: "Harry Potter and the Philosopher's Stone" }])
})
expect(firstOrderEffectSources).toEqual(['remote'])
// recursive changes are always user
expect(secondOrderEffectSources).toEqual(['user'])
expect(diffs).toMatchObject([
{
source: 'remote',
changes: {
added: { [book1Id]: { title: "Harry Potter and the Philosopher's Stone" } },
},
},
{
source: 'user',
changes: {
updated: {
[book1Id]: [
{ title: "Harry Potter and the Philosopher's Stone" },
{
title: "Harry Potter and the Philosopher's Stone is a really great book fr fr",
},
],
},
},
},
])
})
})
describe('remote attribution (AO)', () => {
it('[AO8] changes made by operationComplete handlers after a remote operation are attributed to user', () => {
store.put([book1, book2])
const log: string[] = []
store.addHistoryInterceptor((_entry, source) => log.push('history:' + source))
store.sideEffects.registerAfterChangeHandler('book', (_prev, _next, source) =>
log.push('afterChange:' + source)
)
let once = true
store.sideEffects.registerOperationCompleteHandler((source) => {
log.push('operationComplete:' + source)
if (once) {
once = false
store.put([{ ...book2, title: 'renamed' }])
}
})
store.mergeRemoteChanges(() => store.remove([book1Id]))
expect(log).toEqual([
'history:remote',
'operationComplete:remote',
'history:user',
'afterChange:user',
'operationComplete:user',
// the integrity check after the merge is its own (empty) user operation
'operationComplete:user',
])
})
})
describe('handler removal during dispatch (SE)', () => {
it('[SE1] a handler that removes itself while running does not stop later handlers from running', () => {
const calls: string[] = []
const removeA = store.sideEffects.registerAfterCreateHandler('book', () => {
calls.push('A')
removeA()
})
store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
store.put([book1])
expect(calls).toEqual(['A', 'B'])
store.put([book2])
expect(calls).toEqual(['A', 'B', 'B'])
})
it('[SE1] an operationComplete handler that removes itself does not skip the next one', () => {
const calls: string[] = []
const removeA = store.sideEffects.registerOperationCompleteHandler(() => {
calls.push('A')
removeA()
})
store.sideEffects.registerOperationCompleteHandler(() => calls.push('B'))
store.put([book1])
expect(calls).toEqual(['A', 'B'])
})
// The two tests below pin the other half of SE1: a list edit made mid-dispatch is not seen by the
// dispatch it happens in. Removal is deliberately not a way to cancel a handler for the event in
// flight, so don't "fix" these to match the pre-copy-on-write behaviour without changing SE1 too.
it('[SE1] removing a later handler mid-dispatch only takes effect from the next event', () => {
const calls: string[] = []
let removeB = () => {}
store.sideEffects.registerAfterCreateHandler('book', () => {
calls.push('A')
removeB()
})
removeB = store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
store.put([book1])
expect(calls).toEqual(['A', 'B'])
store.put([book2])
expect(calls).toEqual(['A', 'B', 'A'])
})
it('[SE1] registering a handler mid-dispatch only takes effect from the next event', () => {
const calls: string[] = []
let hasRegistered = false
store.sideEffects.registerAfterCreateHandler('book', () => {
calls.push('A')
if (hasRegistered) return
hasRegistered = true
store.sideEffects.registerAfterCreateHandler('book', () => calls.push('B'))
})
store.put([book1])
expect(calls).toEqual(['A'])
store.put([book2])
expect(calls).toEqual(['A', 'A', 'B'])
})
})

View File

@@ -0,0 +1,684 @@
import { UnknownRecord } from './BaseRecord'
import { Store } from './Store'
/**
* Handler function called before a record is created in the store.
* The handler receives the record to be created and can return a modified version.
* Use this to validate, transform, or modify records before they are added to the store.
*
* @param record - The record about to be created
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
* @returns The record to actually create (may be modified)
*
* @example
* ```ts
* const handler: StoreBeforeCreateHandler<MyRecord> = (record, source) => {
* // Ensure all user-created records have a timestamp
* if (source === 'user' && !record.createdAt) {
* return { ...record, createdAt: Date.now() }
* }
* return record
* }
* ```
*
* @public
*/
export type StoreBeforeCreateHandler<R extends UnknownRecord> = (
record: R,
source: 'remote' | 'user'
) => R
/**
* Handler function called after a record has been successfully created in the store.
* Use this for side effects that should happen after record creation, such as updating
* related records or triggering notifications.
*
* @param record - The record that was created
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
*
* @example
* ```ts
* const handler: StoreAfterCreateHandler<BookRecord> = (book, source) => {
* if (source === 'user') {
* console.log(`New book added: ${book.title}`)
* updateAuthorBookCount(book.authorId)
* }
* }
* ```
*
* @public
*/
export type StoreAfterCreateHandler<R extends UnknownRecord> = (
record: R,
source: 'remote' | 'user'
) => void
/**
* Handler function called before a record is updated in the store.
* The handler receives the current and new versions of the record and can return
* a modified version or the original to prevent the change.
*
* @param prev - The current version of the record in the store
* @param next - The proposed new version of the record
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
* @returns The record version to actually store (may be modified or the original to block change)
*
* @example
* ```ts
* const handler: StoreBeforeChangeHandler<ShapeRecord> = (prev, next, source) => {
* // Prevent shapes from being moved outside the canvas bounds
* if (next.x < 0 || next.y < 0) {
* return prev // Block the change
* }
* return next
* }
* ```
*
* @public
*/
export type StoreBeforeChangeHandler<R extends UnknownRecord> = (
prev: R,
next: R,
source: 'remote' | 'user'
) => R
/**
* Handler function called after a record has been successfully updated in the store.
* Use this for side effects that should happen after record changes, such as
* updating related records or maintaining consistency constraints.
*
* @param prev - The previous version of the record
* @param next - The new version of the record that was stored
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
*
* @example
* ```ts
* const handler: StoreAfterChangeHandler<ShapeRecord> = (prev, next, source) => {
* // Update connected arrows when a shape moves
* if (prev.x !== next.x || prev.y !== next.y) {
* updateConnectedArrows(next.id)
* }
* }
* ```
*
* @public
*/
export type StoreAfterChangeHandler<R extends UnknownRecord> = (
prev: R,
next: R,
source: 'remote' | 'user'
) => void
/**
* Handler function called before a record is deleted from the store.
* The handler can return `false` to prevent the deletion from occurring.
*
* @param record - The record about to be deleted
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
* @returns `false` to prevent deletion, `void` or any other value to allow it
*
* @example
* ```ts
* const handler: StoreBeforeDeleteHandler<BookRecord> = (book, source) => {
* // Prevent deletion of books that are currently checked out
* if (book.isCheckedOut) {
* console.warn('Cannot delete checked out book')
* return false
* }
* // Allow deletion for other books
* }
* ```
*
* @public
*/
export type StoreBeforeDeleteHandler<R extends UnknownRecord> = (
record: R,
source: 'remote' | 'user'
) => void | false
/**
* Handler function called after a record has been successfully deleted from the store.
* Use this for cleanup operations and maintaining referential integrity.
*
* @param record - The record that was deleted
* @param source - Whether the change originated from 'user' interaction or 'remote' synchronization
*
* @example
* ```ts
* const handler: StoreAfterDeleteHandler<ShapeRecord> = (shape, source) => {
* // Clean up arrows that were connected to this shape
* const connectedArrows = findArrowsConnectedTo(shape.id)
* store.remove(connectedArrows.map(arrow => arrow.id))
* }
* ```
*
* @public
*/
export type StoreAfterDeleteHandler<R extends UnknownRecord> = (
record: R,
source: 'remote' | 'user'
) => void
/**
* Handler function called when a store operation (atomic transaction) completes.
* This is useful for performing actions after a batch of changes has been applied,
* such as triggering saves or sending notifications.
*
* @param source - Whether the operation originated from 'user' interaction or 'remote' synchronization
*
* @example
* ```ts
* const handler: StoreOperationCompleteHandler = (source) => {
* if (source === 'user') {
* // Auto-save after user operations complete
* saveStoreSnapshot()
* }
* }
* ```
*
* @public
*/
export type StoreOperationCompleteHandler = (source: 'remote' | 'user') => void
/**
* The side effect manager (aka a "correct state enforcer") is responsible
* for making sure that the store's state is always correct and consistent. This includes
* things like: deleting a shape if its parent is deleted; unbinding
* arrows when their binding target is deleted; maintaining referential integrity; etc.
*
* Side effects are organized into lifecycle hooks that run before and after
* record operations (create, change, delete), allowing you to validate data,
* transform records, and maintain business rules.
*
* @example
* ```ts
* const sideEffects = new StoreSideEffects(store)
*
* // Ensure arrows are deleted when their target shape is deleted
* sideEffects.registerAfterDeleteHandler('shape', (shape) => {
* const arrows = store.query.records('arrow', () => ({
* toId: { eq: shape.id }
* })).get()
* store.remove(arrows.map(arrow => arrow.id))
* })
* ```
*
* @public
*/
export class StoreSideEffects<R extends UnknownRecord> {
/**
* Creates a new side effects manager for the given store.
*
* store - The store instance to manage side effects for
*/
constructor(private readonly store: Store<R>) {}
private _beforeCreateHandlers: { [K in string]?: StoreBeforeCreateHandler<any>[] } = {}
private _afterCreateHandlers: { [K in string]?: StoreAfterCreateHandler<any>[] } = {}
private _beforeChangeHandlers: { [K in string]?: StoreBeforeChangeHandler<any>[] } = {}
private _afterChangeHandlers: { [K in string]?: StoreAfterChangeHandler<any>[] } = {}
private _beforeDeleteHandlers: { [K in string]?: StoreBeforeDeleteHandler<any>[] } = {}
private _afterDeleteHandlers: { [K in string]?: StoreAfterDeleteHandler<any>[] } = {}
private _operationCompleteHandlers: StoreOperationCompleteHandler[] = []
private _isEnabled = true
// Handler arrays are replaced, never mutated in place: dispatch iterates the array it looked up,
// so a handler that removes itself (or registers another) while running must not shift the
// entries out from under that iteration.
private addHandler<H>(handlers: { [K in string]?: H[] }, typeName: string, handler: H) {
handlers[typeName] = [...(handlers[typeName] ?? []), handler]
return () => {
handlers[typeName] = withoutFirst(handlers[typeName] ?? [], handler)
}
}
/**
* Checks whether side effects are currently enabled.
* When disabled, all side effect handlers are bypassed.
*
* @returns `true` if side effects are enabled, `false` otherwise
* @internal
*/
isEnabled() {
return this._isEnabled
}
/**
* Enables or disables side effects processing.
* When disabled, no side effect handlers will be called.
*
* @param enabled - Whether to enable or disable side effects
* @internal
*/
setIsEnabled(enabled: boolean) {
this._isEnabled = enabled
}
/**
* Processes all registered 'before create' handlers for a record.
* Handlers are called in registration order and can transform the record.
*
* @param record - The record about to be created
* @param source - Whether the change originated from 'user' or 'remote'
* @returns The potentially modified record to actually create
* @internal
*/
handleBeforeCreate(record: R, source: 'remote' | 'user') {
if (!this._isEnabled) return record
const handlers = this._beforeCreateHandlers[record.typeName] as StoreBeforeCreateHandler<R>[]
if (handlers) {
let r = record
for (const handler of handlers) {
r = handler(r, source)
}
return r
}
return record
}
/**
* Processes all registered 'after create' handlers for a record.
* Handlers are called in registration order after the record is created.
*
* @param record - The record that was created
* @param source - Whether the change originated from 'user' or 'remote'
* @internal
*/
handleAfterCreate(record: R, source: 'remote' | 'user') {
if (!this._isEnabled) return
const handlers = this._afterCreateHandlers[record.typeName] as StoreAfterCreateHandler<R>[]
if (handlers) {
for (const handler of handlers) {
handler(record, source)
}
}
}
/**
* Processes all registered 'before change' handlers for a record.
* Handlers are called in registration order and can modify or block the change.
*
* @param prev - The current version of the record
* @param next - The proposed new version of the record
* @param source - Whether the change originated from 'user' or 'remote'
* @returns The potentially modified record to actually store
* @internal
*/
handleBeforeChange(prev: R, next: R, source: 'remote' | 'user') {
if (!this._isEnabled) return next
const handlers = this._beforeChangeHandlers[next.typeName] as StoreBeforeChangeHandler<R>[]
if (handlers) {
let r = next
for (const handler of handlers) {
r = handler(prev, r, source)
}
return r
}
return next
}
/**
* Processes all registered 'after change' handlers for a record.
* Handlers are called in registration order after the record is updated.
*
* @param prev - The previous version of the record
* @param next - The new version of the record that was stored
* @param source - Whether the change originated from 'user' or 'remote'
* @internal
*/
handleAfterChange(prev: R, next: R, source: 'remote' | 'user') {
if (!this._isEnabled) return
const handlers = this._afterChangeHandlers[next.typeName] as StoreAfterChangeHandler<R>[]
if (handlers) {
for (const handler of handlers) {
handler(prev, next, source)
}
}
}
/**
* Processes all registered 'before delete' handlers for a record.
* If any handler returns `false`, the deletion is prevented.
*
* @param record - The record about to be deleted
* @param source - Whether the change originated from 'user' or 'remote'
* @returns `true` to allow deletion, `false` to prevent it
* @internal
*/
handleBeforeDelete(record: R, source: 'remote' | 'user') {
if (!this._isEnabled) return true
const handlers = this._beforeDeleteHandlers[record.typeName] as StoreBeforeDeleteHandler<R>[]
if (handlers) {
for (const handler of handlers) {
if (handler(record, source) === false) {
return false
}
}
}
return true
}
/**
* Processes all registered 'after delete' handlers for a record.
* Handlers are called in registration order after the record is deleted.
*
* @param record - The record that was deleted
* @param source - Whether the change originated from 'user' or 'remote'
* @internal
*/
handleAfterDelete(record: R, source: 'remote' | 'user') {
if (!this._isEnabled) return
const handlers = this._afterDeleteHandlers[record.typeName] as StoreAfterDeleteHandler<R>[]
if (handlers) {
for (const handler of handlers) {
handler(record, source)
}
}
}
/**
* Processes all registered operation complete handlers.
* Called after an atomic store operation finishes.
*
* @param source - Whether the operation originated from 'user' or 'remote'
* @internal
*/
handleOperationComplete(source: 'remote' | 'user') {
if (!this._isEnabled) return
for (const handler of this._operationCompleteHandlers) {
handler(source)
}
}
/**
* Internal helper for registering multiple side effect handlers at once and keeping them organized.
* This provides a convenient way to register handlers for multiple record types and lifecycle events
* in a single call, returning a single cleanup function.
*
* @param handlersByType - An object mapping record type names to their respective handlers
* @returns A function that removes all registered handlers when called
*
* @example
* ```ts
* const cleanup = sideEffects.register({
* shape: {
* afterDelete: (shape) => console.log('Shape deleted:', shape.id),
* beforeChange: (prev, next) => ({ ...next, lastModified: Date.now() })
* },
* arrow: {
* afterCreate: (arrow) => updateConnectedShapes(arrow)
* }
* })
*
* // Later, remove all handlers
* cleanup()
* ```
*
* @internal
*/
register(handlersByType: {
[T in R as T['typeName']]?: {
beforeCreate?: StoreBeforeCreateHandler<T>
afterCreate?: StoreAfterCreateHandler<T>
beforeChange?: StoreBeforeChangeHandler<T>
afterChange?: StoreAfterChangeHandler<T>
beforeDelete?: StoreBeforeDeleteHandler<T>
afterDelete?: StoreAfterDeleteHandler<T>
}
}) {
const disposes: (() => void)[] = []
for (const [type, handlers] of Object.entries(handlersByType) as any) {
if (handlers?.beforeCreate) {
disposes.push(this.registerBeforeCreateHandler(type, handlers.beforeCreate))
}
if (handlers?.afterCreate) {
disposes.push(this.registerAfterCreateHandler(type, handlers.afterCreate))
}
if (handlers?.beforeChange) {
disposes.push(this.registerBeforeChangeHandler(type, handlers.beforeChange))
}
if (handlers?.afterChange) {
disposes.push(this.registerAfterChangeHandler(type, handlers.afterChange))
}
if (handlers?.beforeDelete) {
disposes.push(this.registerBeforeDeleteHandler(type, handlers.beforeDelete))
}
if (handlers?.afterDelete) {
disposes.push(this.registerAfterDeleteHandler(type, handlers.afterDelete))
}
}
return () => {
for (const dispose of disposes) dispose()
}
}
/**
* Register a handler to be called before a record of a certain type is created. Return a
* modified record from the handler to change the record that will be created.
*
* Use this handle only to modify the creation of the record itself. If you want to trigger a
* side-effect on a different record (for example, moving one shape when another is created),
* use {@link StoreSideEffects.registerAfterCreateHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeCreateHandler('shape', (shape, source) => {
* // only modify shapes created by the user
* if (source !== 'user') return shape
*
* //by default, arrow shapes have no label. Let's make sure they always have a label.
* if (shape.type === 'arrow') {
* return {...shape, props: {...shape.props, text: 'an arrow'}}
* }
*
* // other shapes get returned unmodified
* return shape
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerBeforeCreateHandler<T extends R['typeName']>(
typeName: T,
handler: StoreBeforeCreateHandler<R & { typeName: T }>
) {
return this.addHandler(this._beforeCreateHandlers, typeName, handler)
}
/**
* Register a handler to be called after a record is created. This is useful for side-effects
* that would update _other_ records. If you want to modify the record being created use
* {@link StoreSideEffects.registerBeforeCreateHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterCreateHandler('page', (page, source) => {
* // Automatically create a shape when a page is created
* editor.createShape({
* id: createShapeId(),
* type: 'text',
* props: { richText: toRichText(page.name) },
* })
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerAfterCreateHandler<T extends R['typeName']>(
typeName: T,
handler: StoreAfterCreateHandler<R & { typeName: T }>
) {
return this.addHandler(this._afterCreateHandlers, typeName, handler)
}
/**
* Register a handler to be called before a record is changed. The handler is given the old and
* new record - you can return a modified record to apply a different update, or the old record
* to block the update entirely.
*
* Use this handler only for intercepting updates to the record itself. If you want to update
* other records in response to a change, use
* {@link StoreSideEffects.registerAfterChangeHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeChangeHandler('shape', (prev, next, source) => {
* if (next.isLocked && !prev.isLocked) {
* // prevent shapes from ever being locked:
* return prev
* }
* // other types of change are allowed
* return next
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerBeforeChangeHandler<T extends R['typeName']>(
typeName: T,
handler: StoreBeforeChangeHandler<R & { typeName: T }>
) {
return this.addHandler(this._beforeChangeHandlers, typeName, handler)
}
/**
* Register a handler to be called after a record is changed. This is useful for side-effects
* that would update _other_ records - if you want to modify the record being changed, use
* {@link StoreSideEffects.registerBeforeChangeHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterChangeHandler('shape', (prev, next, source) => {
* if (next.props.color === 'red') {
* // there can only be one red shape at a time:
* const otherRedShapes = editor.getCurrentPageShapes().filter(s => s.props.color === 'red' && s.id !== next.id)
* editor.updateShapes(otherRedShapes.map(s => ({...s, props: {...s.props, color: 'blue'}})))
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerAfterChangeHandler<T extends R['typeName']>(
typeName: T,
handler: StoreAfterChangeHandler<R & { typeName: T }>
) {
return this.addHandler(this._afterChangeHandlers, typeName, handler)
}
/**
* Register a handler to be called before a record is deleted. The handler can return `false` to
* prevent the deletion.
*
* Use this handler only for intercepting deletions of the record itself. If you want to do
* something to other records in response to a deletion, use
* {@link StoreSideEffects.registerAfterDeleteHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => {
* if (shape.props.color === 'red') {
* // prevent red shapes from being deleted
* return false
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerBeforeDeleteHandler<T extends R['typeName']>(
typeName: T,
handler: StoreBeforeDeleteHandler<R & { typeName: T }>
) {
return this.addHandler(this._beforeDeleteHandlers, typeName, handler)
}
/**
* Register a handler to be called after a record is deleted. This is useful for side-effects
* that would update _other_ records - if you want to block the deletion of the record itself,
* use {@link StoreSideEffects.registerBeforeDeleteHandler} instead.
*
* @example
* ```ts
* editor.sideEffects.registerAfterDeleteHandler('shape', (shape, source) => {
* // if the last shape in a frame is deleted, delete the frame too:
* const parentFrame = editor.getShape(shape.parentId)
* if (!parentFrame || parentFrame.type !== 'frame') return
*
* const siblings = editor.getSortedChildIdsForParent(parentFrame)
* if (siblings.length === 0) {
* editor.deleteShape(parentFrame.id)
* }
* })
* ```
*
* @param typeName - The type of record to listen for
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*/
registerAfterDeleteHandler<T extends R['typeName']>(
typeName: T,
handler: StoreAfterDeleteHandler<R & { typeName: T }>
) {
return this.addHandler(this._afterDeleteHandlers, typeName, handler)
}
/**
* Register a handler to be called when a store completes an atomic operation.
*
* @example
* ```ts
* let count = 0
*
* editor.sideEffects.registerOperationCompleteHandler(() => count++)
*
* editor.selectAll()
* expect(count).toBe(1)
*
* editor.store.atomic(() => {
* editor.selectNone()
* editor.selectAll()
* })
*
* expect(count).toBe(2)
* ```
*
* @param handler - The handler to call
*
* @returns A callback that removes the handler.
*
* @public
*/
registerOperationCompleteHandler(handler: StoreOperationCompleteHandler) {
// copy on write, see addHandler
this._operationCompleteHandlers = [...this._operationCompleteHandlers, handler]
return () => {
this._operationCompleteHandlers = withoutFirst(this._operationCompleteHandlers, handler)
}
}
}
function withoutFirst<T>(array: T[], item: T): T[] {
const index = array.indexOf(item)
if (index < 0) return array
return [...array.slice(0, index), ...array.slice(index + 1)]
}

View File

@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it, MockedFunction, vi } from 'vitest'
import { devFreeze } from './devFreeze'
import { isDev } from './isDev'
// Tests for SPEC.md §24 (devFreeze).
// Rule IDs like [DF1] in test names refer to that document.
vi.mock('./isDev', () => ({
isDev: vi.fn(() => true),
}))
describe('devFreeze in dev/test builds (DF)', () => {
beforeEach(() => {
vi.restoreAllMocks()
;(isDev as MockedFunction<typeof isDev>).mockReturnValue(true)
})
it('[DF1] recursively freezes the object and returns it', () => {
const obj = {
a: 1,
b: { c: 2, d: { e: 3 } },
f: [1, { g: 4 }],
}
const result = devFreeze(obj)
expect(result).toBe(obj)
expect(Object.isFrozen(result)).toBe(true)
expect(Object.isFrozen(result.b)).toBe(true)
expect(Object.isFrozen(result.b.d)).toBe(true)
expect(Object.isFrozen(result.f)).toBe(true)
expect(Object.isFrozen(result.f[1])).toBe(true)
})
it('[DF1] mutating a frozen object afterwards throws in strict mode', () => {
'use strict'
const obj = devFreeze({ a: 1 })
expect(() => {
;(obj as any).a = 2
}).toThrow()
})
it('[DF2] allows arrays, plain objects, null-prototype objects, and structured-clone objects', () => {
const obj = { a: 1 }
expect(() => devFreeze(obj)).not.toThrow()
expect(Object.isFrozen(obj)).toBe(true)
const nullProtoObj = Object.create(null)
nullProtoObj.a = 1
expect(() => devFreeze(nullProtoObj)).not.toThrow()
expect(Object.isFrozen(nullProtoObj)).toBe(true)
const arr = [1, 2, 3]
expect(() => devFreeze(arr)).not.toThrow()
expect(Object.isFrozen(arr)).toBe(true)
const cloned = structuredClone({ a: 1 })
expect(() => devFreeze(cloned)).not.toThrow()
expect(Object.isFrozen(cloned)).toBe(true)
})
it('[DF2] throws for objects with non-plain prototypes', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
class CustomClass {
value = 42
}
expect(() => devFreeze(new CustomClass())).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(new Date())).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(/regex/)).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(new Map())).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(new Set())).toThrow('cannot include non-js data in a record')
// also when nested inside an otherwise valid object
expect(() => devFreeze({ valid: { a: 1 }, invalid: new Date() })).toThrow(
'cannot include non-js data in a record'
)
})
it('[DF2] throws for primitives, whose prototypes are not plain', () => {
expect(() => devFreeze('string')).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(42)).toThrow('cannot include non-js data in a record')
expect(() => devFreeze(true)).toThrow('cannot include non-js data in a record')
})
})
describe('devFreeze in production builds (DF)', () => {
beforeEach(() => {
vi.restoreAllMocks()
;(isDev as MockedFunction<typeof isDev>).mockReturnValue(false)
})
it('[DF3] returns the object unchanged without freezing', () => {
const obj = { a: 1, b: { c: 2 } }
const result = devFreeze(obj)
expect(result).toBe(obj)
expect(Object.isFrozen(result)).toBe(false)
expect(Object.isFrozen(result.b)).toBe(false)
})
it('[DF3] does not validate prototypes', () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
class CustomClass {
value = 42
}
expect(() => devFreeze(new CustomClass())).not.toThrow()
expect(consoleSpy).not.toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,52 @@
import { STRUCTURED_CLONE_OBJECT_PROTOTYPE } from '@tldraw/utils'
import { isDev } from './isDev'
/**
* Freeze an object when in development mode. Copied from
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze
*
* @example
*
* ```ts
* const frozen = devFreeze({ a: 1 })
* ```
*
* @param object - The object to freeze.
* @returns The frozen object when in development mode, or else the object when in other modes.
* @public
*/
export function devFreeze<T>(object: T): T {
if (!isDev()) return object
const proto = Object.getPrototypeOf(object)
if (
proto &&
!(
Array.isArray(object) ||
proto === Object.prototype ||
proto === null ||
proto === STRUCTURED_CLONE_OBJECT_PROTOTYPE
)
) {
console.error('cannot include non-js data in a record', object)
throw new Error('cannot include non-js data in a record')
}
if (Object.isFrozen(object)) {
return object
}
// Retrieve the property names defined on object
const propNames = Object.getOwnPropertyNames(object)
// Recursively freeze properties before freezing self
for (const name of propNames) {
const value = (object as any)[name]
if (value && typeof value === 'object') {
devFreeze(value)
}
}
return Object.freeze(object)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
import { objectMapFromEntries, objectMapValues } from '@tldraw/utils'
import { IdOf, UnknownRecord } from './BaseRecord'
import { intersectSets } from './setUtils'
import { StoreQueries } from './StoreQueries'
/**
* Defines matching criteria for query values. Supports equality, inequality, and greater-than comparisons.
*
* @example
* ```ts
* // Exact match
* const exactMatch: QueryValueMatcher<string> = { eq: 'Science Fiction' }
*
* // Not equal to
* const notMatch: QueryValueMatcher<string> = { neq: 'Romance' }
*
* // Greater than (numeric values only)
* const greaterThan: QueryValueMatcher<number> = { gt: 2020 }
* ```
*
* @public
*/
export type QueryValueMatcher<T> = { eq: T } | { neq: T } | { gt: number }
/**
* Query expression for filtering records by their property values. Maps record property names
* to matching criteria.
*
* @example
* ```ts
* // Query for books published after 2020 that are in stock
* const bookQuery: QueryExpression<Book> = {
* publishedYear: { gt: 2020 },
* inStock: { eq: true }
* }
*
* // Query for books not by a specific author
* const notByAuthor: QueryExpression<Book> = {
* authorId: { neq: 'author:tolkien' }
* }
*
* // Query with nested properties
* const nestedQuery: QueryExpression<Book> = {
* metadata: { sessionId: { eq: 'session:alpha' } }
* }
* ```
*
* @public
*/
/** @public */
export type QueryExpression<R extends object> = {
[k in keyof R & string]?: R[k] extends string | number | boolean | null | undefined
? QueryValueMatcher<R[k]>
: R[k] extends object
? QueryExpression<R[k]>
: QueryValueMatcher<R[k]>
}
function isQueryValueMatcher(value: unknown): value is QueryValueMatcher<unknown> {
if (typeof value !== 'object' || value === null) return false
return 'eq' in value || 'neq' in value || 'gt' in value
}
function extractMatcherPaths(
query: QueryExpression<any>,
prefix: string = ''
): Array<{ path: string; matcher: QueryValueMatcher<any> }> {
const paths: Array<{ path: string; matcher: QueryValueMatcher<any> }> = []
for (const [key, value] of Object.entries(query)) {
const currentPath = prefix ? `${prefix}\\${key}` : key
if (isQueryValueMatcher(value)) {
// It's a direct matcher
paths.push({ path: currentPath, matcher: value })
} else if (typeof value === 'object' && value !== null) {
// It's a nested query - recurse into it
paths.push(...extractMatcherPaths(value as QueryExpression<any>, currentPath))
}
}
return paths
}
export function objectMatchesQuery<T extends object>(query: QueryExpression<T>, object: T) {
for (const [key, matcher] of Object.entries(query)) {
const value = object[key as keyof T]
// if you add matching logic here, make sure you also update executeQuery,
// where initial data is pulled out of the indexes, since that requires different
// matching logic
if (isQueryValueMatcher(matcher)) {
if ('eq' in matcher && value !== matcher.eq) return false
// undefined values must not match neq: the indexes executeQuery reads only
// track defined values, and the two matching strategies have to agree
if ('neq' in matcher && (value === matcher.neq || value === undefined)) return false
if ('gt' in matcher && (typeof value !== 'number' || value <= matcher.gt)) return false
continue
}
// It's a nested query
if (typeof value !== 'object' || value === null) return false
if (!objectMatchesQuery(matcher as QueryExpression<any>, value as any)) {
return false
}
}
return true
}
/**
* Executes a query against the store using reactive indexes to efficiently find matching record IDs.
* Uses the store's internal indexes for optimal performance, especially for equality matches.
*
* @param store - The store queries interface providing access to reactive indexes
* @param typeName - The type name of records to query (e.g., 'book', 'author')
* @param query - Query expression defining the matching criteria
* @returns A Set containing the IDs of all records that match the query criteria
*
* @example
* ```ts
* // Find IDs of all books published after 2020 that are in stock
* const bookIds = executeQuery(store, 'book', {
* publishedYear: { gt: 2020 },
* inStock: { eq: true }
* })
*
* // Find IDs of books not by a specific author
* const otherBookIds = executeQuery(store, 'book', {
* authorId: { neq: 'author:tolkien' }
* })
*
* // Query with nested properties
* const nestedQueryIds = executeQuery(store, 'book', {
* metadata: { sessionId: { eq: 'session:alpha' } }
* })
* ```
*
* @public
*/
export function executeQuery<R extends UnknownRecord, TypeName extends R['typeName']>(
store: StoreQueries<R>,
typeName: TypeName,
query: QueryExpression<Extract<R, { typeName: TypeName }>>
): Set<IdOf<Extract<R, { typeName: TypeName }>>> {
type S = Extract<R, { typeName: TypeName }>
// Extract all paths with matchers (flattens nested queries)
const matcherPaths = extractMatcherPaths(query)
// Build a set of matching IDs for each path
const matchIds = objectMapFromEntries(
matcherPaths.map(({ path }) => [path, new Set<IdOf<S>>()] as const)
)
// For each path, use the index to find matching IDs
for (const { path, matcher } of matcherPaths) {
const index = store.index(typeName, path as any)
if ('eq' in matcher) {
const ids = index.get().get(matcher.eq)
if (ids) {
for (const id of ids) {
matchIds[path].add(id)
}
}
} else if ('neq' in matcher) {
for (const [value, ids] of index.get()) {
if (value !== matcher.neq) {
for (const id of ids) {
matchIds[path].add(id)
}
}
}
} else if ('gt' in matcher) {
for (const [value, ids] of index.get()) {
if (typeof value === 'number' && value > matcher.gt) {
for (const id of ids) {
matchIds[path].add(id)
}
}
}
}
// Short-circuit if this set is empty - intersection will be empty
if (matchIds[path].size === 0) {
return new Set()
}
}
// Intersect all the match sets
return intersectSets(objectMapValues(matchIds)) as Set<IdOf<S>>
}

View File

@@ -0,0 +1,20 @@
let _isDev = false
try {
_isDev = process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test'
} catch (_e) {
/* noop */
}
try {
_isDev =
_isDev ||
(import.meta as any).env.DEV ||
(import.meta as any).env.TEST ||
(import.meta as any).env.MODE === 'development' ||
(import.meta as any).env.MODE === 'test'
} catch (_e) {
/* noop */
}
export function isDev() {
return _isDev
}

View File

@@ -0,0 +1,392 @@
import { describe, expect, it } from 'vitest'
import { UnknownRecord } from './BaseRecord'
import {
type Migration,
type MigrationId,
createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
parseMigrationId,
sortMigrations,
validateMigrations,
} from './migrate'
import { SerializedStore } from './Store'
// Tests for SPEC.md §16 (migration authoring) and §17 (migration sorting).
// Rule IDs like [M2] in test names refer to that document.
const m = (id: MigrationId, others?: { dependsOn?: MigrationId[] }): Migration => ({
...others,
id,
scope: 'record',
up() {
// noop
},
})
describe('migration authoring (M)', () => {
it('[M1] createMigrationSequence returns the validated sequence', () => {
const sequence = createMigrationSequence({
sequenceId: 'test',
sequence: [m('test/1')],
retroactive: false,
})
expect(sequence.sequenceId).toBe('test')
expect(sequence.retroactive).toBe(false)
expect(sequence.sequence).toHaveLength(1)
})
it('[M1] retroactive defaults to true', () => {
const sequence = createMigrationSequence({
sequenceId: 'test',
sequence: [m('test/1')],
})
expect(sequence.retroactive).toBe(true)
})
it('[M1] createMigrationSequence validates the sequence', () => {
expect(() =>
createMigrationSequence({
sequenceId: 'test/invalid',
sequence: [],
})
).toThrow()
expect(() =>
createMigrationSequence({
sequenceId: 'test',
sequence: [m('test/2')],
})
).toThrow()
})
it('[M2] a standalone dependsOn entry squashes into the next migration', () => {
const result = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [{ dependsOn: ['bar/1'] }, m('foo/1')],
})
expect(result.sequence).toHaveLength(1)
expect(result.sequence[0].dependsOn).toEqual(['bar/1'])
})
it('[M2] a squashed dependsOn prepends to the migration’s own dependsOn', () => {
const sequence = createMigrationSequence({
sequenceId: 'test',
sequence: [
{ dependsOn: ['other/1' as MigrationId, 'another/2' as MigrationId] },
m('test/1', { dependsOn: ['existing/1' as MigrationId] }),
],
})
expect(sequence.sequence).toHaveLength(1)
expect(sequence.sequence[0].dependsOn).toEqual(['other/1', 'another/2', 'existing/1'])
})
it('[M2] a standalone dependsOn between migrations attaches to the one after it', () => {
const result = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [m('foo/1'), { dependsOn: ['bar/1'] }, m('foo/2')],
})
expect(result.sequence).toHaveLength(2)
expect(result.sequence[0].dependsOn ?? []).toEqual([])
expect(result.sequence[1].dependsOn).toEqual(['bar/1'])
})
it('[M2] a standalone dependsOn with no following migration is dropped', () => {
expect(
createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [{ dependsOn: ['bar/1'] }],
}).sequence
).toHaveLength(0)
const trailing = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [m('foo/1'), { dependsOn: ['bar/1'] }],
})
expect(trailing.sequence).toHaveLength(1)
expect(trailing.sequence[0].dependsOn ?? []).toEqual([])
})
it('[M4] createMigrationIds formats sequenceId/version ids', () => {
expect(
createMigrationIds('com.myapp.book', {
addGenre: 1,
addPublisher: 2,
removeOldField: 3,
})
).toEqual({
addGenre: 'com.myapp.book/1',
addPublisher: 'com.myapp.book/2',
removeOldField: 'com.myapp.book/3',
})
})
it('[M4] parseMigrationId splits the sequence id and version', () => {
expect(parseMigrationId('com.myapp.book/5')).toEqual({
sequenceId: 'com.myapp.book',
version: 5,
})
expect(parseMigrationId('test/1')).toEqual({ sequenceId: 'test', version: 1 })
expect(parseMigrationId('com.example.app/42')).toEqual({
sequenceId: 'com.example.app',
version: 42,
})
})
it('[M5] createRecordMigrationSequence filters by record type', () => {
const sequence = createRecordMigrationSequence({
recordType: 'book',
sequenceId: 'com.myapp.book',
sequence: [
{
id: 'com.myapp.book/1' as MigrationId,
up: (record: UnknownRecord) => ({ ...record, newField: 'value' }),
},
],
})
expect(sequence.sequenceId).toBe('com.myapp.book')
expect(sequence.sequence).toHaveLength(1)
expect(sequence.sequence[0].scope).toBe('record')
const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
expect(migration.filter?.({ id: 'book-1', typeName: 'book' } as any)).toBe(true)
expect(migration.filter?.({ id: 'user-1', typeName: 'user' } as any)).toBe(false)
})
it('[M5] a sequence-level filter composes with the record type filter', () => {
const sequence = createRecordMigrationSequence({
recordType: 'shape',
filter: (record) => (record as any).shapeType === 'rectangle',
sequenceId: 'com.myapp.shape',
sequence: [
{
id: 'com.myapp.shape/1' as MigrationId,
up: (record: UnknownRecord) => record,
},
],
})
const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
expect(
migration.filter?.({ id: 'shape-1', typeName: 'shape', shapeType: 'rectangle' } as any)
).toBe(true)
expect(
migration.filter?.({ id: 'shape-2', typeName: 'shape', shapeType: 'circle' } as any)
).toBe(false)
expect(migration.filter?.({ id: 'user-1', typeName: 'user' } as any)).toBe(false)
})
})
describe('validateMigrations (M)', () => {
it('[M3] accepts well-formed sequences, including mixed scopes', () => {
expect(() =>
validateMigrations({
sequenceId: 'test',
retroactive: true,
sequence: [
m('test/1'),
m('test/2'),
{
id: 'test/3',
scope: 'store',
up: (store: SerializedStore<UnknownRecord>) => store,
},
],
})
).not.toThrow()
})
it('[M3] throws on an invalid sequence id', () => {
expect(() =>
validateMigrations({ retroactive: false, sequence: [], sequenceId: 'foo/bar' })
).toThrowErrorMatchingInlineSnapshot(`[Error: sequenceId cannot contain a '/', got foo/bar]`)
expect(() =>
validateMigrations({ retroactive: false, sequence: [], sequenceId: '' })
).toThrowErrorMatchingInlineSnapshot(`[Error: sequenceId must be a non-empty string]`)
})
it('[M3] throws on a migration id outside the sequence or in the wrong format', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo.1' as any)],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`[Error: Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.1']`
)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo/one' as any)],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid migration id: 'foo/one']`)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo/1'), m('foo.2' as any)],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`[Error: Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.2']`
)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo/1'), m('foo/two' as any)],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(`[Error: Invalid migration id: 'foo/two']`)
})
it('[M3] throws if the first version is not 1', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo/2')],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`[Error: Expected the first migrationId to be 'foo/1' but got 'foo/2']`
)
})
it('[M3] throws if versions do not increase in increments of 1', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [m('foo/1'), m('foo/2'), m('foo/4')],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`[Error: Migration id numbers must increase in increments of 1, expected foo/3 but got 'foo/4']`
)
})
})
describe('sortMigrations (MS)', () => {
const sort = (migrations: Migration[]) => sortMigrations(migrations).map((m) => m.id)
it('[MS1] sorts migrations within a sequence by version', () => {
expect(sort([m('foo/2'), m('foo/1')])).toEqual(['foo/1', 'foo/2'])
expect(sort([m('foo/1'), m('foo/2')])).toEqual(['foo/1', 'foo/2'])
})
it('[MS1] sorts each of several sequences by version', () => {
const result = sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1')])
expect(result.filter((id) => id.startsWith('foo/'))).toEqual(['foo/1', 'foo/2'])
expect(result.filter((id) => id.startsWith('bar/'))).toEqual(['bar/1', 'bar/2'])
})
it('[MS2] a migration sorts after its explicit dependencies', () => {
expect(
sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1', { dependsOn: ['foo/2'] })])
).toEqual(['foo/1', 'foo/2', 'bar/1', 'bar/2'])
expect(
sort([m('foo/2'), m('bar/2'), m('foo/1', { dependsOn: ['bar/2'] }), m('bar/1')])
).toEqual(['bar/1', 'bar/2', 'foo/1', 'foo/2'])
})
it('[MS2] handles chains of dependencies across sequences', () => {
const a1 = m('a/1')
const b1 = m('b/1', { dependsOn: ['a/1'] })
const c1 = m('c/1', { dependsOn: ['b/1'] })
const d1 = m('d/1', { dependsOn: ['a/1', 'c/1'] })
expect(sortMigrations([d1, c1, b1, a1])).toEqual([a1, b1, c1, d1])
})
it('[MS2] satisfies both sequence order and cross-sequence dependencies', () => {
const sorted = sort([
m('plugin/1', { dependsOn: ['app/2', 'lib/1'] }),
m('lib/2'),
m('lib/1', { dependsOn: ['app/1'] }),
m('app/2'),
m('app/1'),
])
expect(sorted.indexOf('app/1')).toBeLessThan(sorted.indexOf('app/2'))
expect(sorted.indexOf('lib/1')).toBeLessThan(sorted.indexOf('lib/2'))
expect(sorted.indexOf('app/1')).toBeLessThan(sorted.indexOf('lib/1'))
expect(sorted.indexOf('app/2')).toBeLessThan(sorted.indexOf('plugin/1'))
expect(sorted.indexOf('lib/1')).toBeLessThan(sorted.indexOf('plugin/1'))
})
it('[MS3] schedules dependents close to their dependencies', () => {
// bar/3 depends on foo/1 — the bar sequence runs immediately after foo/1
expect(
sort([m('foo/2'), m('bar/3', { dependsOn: ['foo/1'] }), m('foo/1'), m('bar/1'), m('bar/2')])
).toEqual(['foo/1', 'bar/1', 'bar/2', 'bar/3', 'foo/2'])
})
it('[MS3] minimizes total distance for multiple explicit dependencies', () => {
expect(
sort([
m('foo/2'),
m('bar/2', { dependsOn: ['foo/1'] }),
m('foo/1'),
m('bar/1'),
m('baz/1', { dependsOn: ['foo/1'] }),
])
).toEqual(['foo/1', 'bar/1', 'bar/2', 'baz/1', 'foo/2'])
})
it('[MS3] keeps chains of explicit dependencies consecutive', () => {
expect(
sort([
m('foo/2'),
m('bar/1', { dependsOn: ['foo/1'] }),
m('foo/1'),
m('baz/1', { dependsOn: ['bar/1'] }),
])
).toEqual(['foo/1', 'bar/1', 'baz/1', 'foo/2'])
})
it('[MS4] throws on circular dependencies', () => {
expect(() => sort([m('foo/1', { dependsOn: ['foo/1'] })])).toThrowErrorMatchingInlineSnapshot(
`[Error: Circular dependency in migrations: foo/1]`
)
expect(() =>
sort([m('foo/1', { dependsOn: ['foo/2'] }), m('foo/2')])
).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: foo/1]`)
expect(() =>
sort([m('foo/1', { dependsOn: ['bar/1'] }), m('bar/1', { dependsOn: ['foo/1'] })])
).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: foo/1]`)
expect(() =>
sort([m('bar/1', { dependsOn: ['foo/1'] }), m('foo/1', { dependsOn: ['bar/1'] })])
).toThrowErrorMatchingInlineSnapshot(`[Error: Circular dependency in migrations: bar/1]`)
})
})
describe('sortMigrations: redundant dependencies (MS)', () => {
const sort = (migrations: Migration[]) => sortMigrations(migrations).map((mig) => mig.id)
it('[MS2] an explicit dependency on the implicit predecessor is not a cycle', () => {
expect(sort([m('foo/2', { dependsOn: ['foo/1'] }), m('foo/1')])).toEqual(['foo/1', 'foo/2'])
})
it('[MS2] a dependency listed twice counts once', () => {
expect(sort([m('bar/1', { dependsOn: ['foo/1', 'foo/1'] }), m('foo/1')])).toEqual([
'foo/1',
'bar/1',
])
})
})

View File

@@ -0,0 +1,557 @@
import { assert, objectMapEntries } from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { SerializedStore } from './Store'
import { SerializedSchema } from './StoreSchema'
function squashDependsOn(sequence: Array<Migration | StandaloneDependsOn>): Migration[] {
const result: Migration[] = []
for (let i = sequence.length - 1; i >= 0; i--) {
const elem = sequence[i]
if (!('id' in elem)) {
const dependsOn = elem.dependsOn
const prev = result[0]
if (prev) {
result[0] = {
...prev,
dependsOn: dependsOn.concat(prev.dependsOn ?? []),
}
}
} else {
result.unshift(elem)
}
}
return result
}
/**
* Creates a migration sequence that defines how to transform data as your schema evolves.
*
* A migration sequence contains a series of migrations that are applied in order to transform
* data from older versions to newer versions. Each migration is identified by a unique ID
* and can operate at either the record level (transforming individual records) or store level
* (transforming the entire store structure).
*
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @param options - Configuration for the migration sequence
* - sequenceId - Unique identifier for this migration sequence (e.g., 'com.myapp.book')
* - sequence - Array of migrations or dependency declarations to include in the sequence
* - retroactive - Whether migrations should apply to snapshots created before this sequence was added (defaults to true)
* @returns A validated migration sequence that can be included in a store schema
* @example
* ```ts
* const bookMigrations = createMigrationSequence({
* sequenceId: 'com.myapp.book',
* sequence: [
* {
* id: 'com.myapp.book/1',
* scope: 'record',
* up: (record) => ({ ...record, newField: 'default' })
* }
* ]
* })
* ```
* @public
*/
export function createMigrationSequence({
sequence,
sequenceId,
retroactive = true,
}: {
sequenceId: string
retroactive?: boolean
sequence: Array<Migration | StandaloneDependsOn>
}): MigrationSequence {
const migrations: MigrationSequence = {
sequenceId,
retroactive,
sequence: squashDependsOn(sequence),
}
validateMigrations(migrations)
return migrations
}
/**
* Creates a named set of migration IDs from version numbers and a sequence ID.
*
* This utility function helps generate properly formatted migration IDs that follow
* the required `sequenceId/version` pattern. It takes a sequence ID and a record
* of named versions, returning migration IDs that can be used in migration definitions.
*
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @param sequenceId - The sequence identifier (e.g., 'com.myapp.book')
* @param versions - Record mapping version names to numbers
* @returns Record mapping version names to properly formatted migration IDs
* @example
* ```ts
* const migrationIds = createMigrationIds('com.myapp.book', {
* addGenre: 1,
* addPublisher: 2,
* removeOldField: 3
* })
* // Result: {
* // addGenre: 'com.myapp.book/1',
* // addPublisher: 'com.myapp.book/2',
* // removeOldField: 'com.myapp.book/3'
* // }
* ```
* @public
*/
export function createMigrationIds<
const ID extends string,
const Versions extends Record<string, number>,
>(sequenceId: ID, versions: Versions): { [K in keyof Versions]: `${ID}/${Versions[K]}` } {
// Note: not objectMapFromEntries — `keyof Versions` is not narrowed to `string`, which that
// helper requires.
return Object.fromEntries(
objectMapEntries(versions).map(([key, version]) => [key, `${sequenceId}/${version}`] as const)
) as any
}
/**
* Creates a migration sequence specifically for record-level migrations.
*
* This is a convenience function that creates a migration sequence where all migrations
* operate at the record scope and are automatically filtered to apply only to records
* of a specific type. Each migration in the sequence will be enhanced with the record
* scope and appropriate filtering logic.
* @param opts - Configuration for the record migration sequence
* - recordType - The record type name these migrations should apply to
* - filter - Optional additional filter function to determine which records to migrate
* - retroactive - Whether migrations should apply to snapshots created before this sequence was added
* - sequenceId - Unique identifier for this migration sequence
* - sequence - Array of record migration definitions (scope will be added automatically)
* @returns A migration sequence configured for record-level operations
* @internal
*/
export function createRecordMigrationSequence(opts: {
recordType: string
filter?(record: UnknownRecord): boolean
retroactive?: boolean
sequenceId: string
sequence: Omit<Extract<Migration, { scope: 'record' }>, 'scope'>[]
}): MigrationSequence {
const sequenceId = opts.sequenceId
return createMigrationSequence({
sequenceId,
retroactive: opts.retroactive ?? true,
sequence: opts.sequence.map((m) => ({
...m,
scope: 'record',
filter: (r: UnknownRecord) =>
r.typeName === opts.recordType && (m.filter?.(r) ?? true) && (opts.filter?.(r) ?? true),
})),
})
}
/**
* Legacy migration interface for backward compatibility.
*
* This interface represents the old migration format that included both `up` and `down`
* transformation functions. While still supported, new code should use the `Migration`
* type which provides more flexibility and better integration with the current system.
* @public
*/
export interface LegacyMigration<Before = any, After = any> {
// eslint-disable-next-line tldraw/method-signature-style
up: (oldState: Before) => After
// eslint-disable-next-line tldraw/method-signature-style
down: (newState: After) => Before
}
/**
* Unique identifier for a migration in the format `sequenceId/version`.
*
* Migration IDs follow a specific pattern where the sequence ID identifies the migration
* sequence and the version number indicates the order within that sequence. For example:
* 'com.myapp.book/1', 'com.myapp.book/2', etc.
* @public
*/
export type MigrationId = `${string}/${number}`
/**
* Declares dependencies for migrations without being a migration itself.
*
* This interface allows you to specify that future migrations in a sequence depend on
* migrations from other sequences, without defining an actual migration transformation.
* It's used to establish cross-sequence dependencies in the migration graph.
* @public
*/
export interface StandaloneDependsOn {
readonly dependsOn: readonly MigrationId[]
}
/**
* Defines a single migration that transforms data from one schema version to another.
*
* A migration can operate at two different scopes:
* - `record`: Transforms individual records, with optional filtering to target specific records
* - `store`: Transforms the entire serialized store structure
*
* Each migration has a unique ID and can declare dependencies on other migrations that must
* be applied first. The `up` function performs the forward transformation, while the optional
* `down` function can reverse the migration if needed.
* @public
*/
export type Migration = {
readonly id: MigrationId
readonly dependsOn?: readonly MigrationId[] | undefined
} & (
| {
readonly scope: 'record'
// eslint-disable-next-line tldraw/method-signature-style
readonly filter?: (record: UnknownRecord) => boolean
// eslint-disable-next-line tldraw/method-signature-style
readonly up: (oldState: UnknownRecord) => void | UnknownRecord
// eslint-disable-next-line tldraw/method-signature-style
readonly down?: (newState: UnknownRecord) => void | UnknownRecord
}
| {
readonly scope: 'store'
// eslint-disable-next-line tldraw/method-signature-style
readonly up: (
oldState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
// eslint-disable-next-line tldraw/method-signature-style
readonly down?: (
newState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
}
| {
readonly scope: 'storage'
// eslint-disable-next-line tldraw/method-signature-style
readonly up: (storage: SynchronousRecordStorage<UnknownRecord>) => void
readonly down?: never
}
)
/**
* Abstraction over the store that can be used to perform migrations.
* @public
*/
export interface SynchronousRecordStorage<R extends UnknownRecord> {
get(id: string): R | undefined
set(id: string, record: R): void
delete(id: string): void
keys(): Iterable<string>
values(): Iterable<R>
entries(): Iterable<[string, R]>
}
/**
* Abstraction over the storage that can be used to perform migrations.
* @public
*/
export interface SynchronousStorage<R extends UnknownRecord> extends SynchronousRecordStorage<R> {
getSchema(): SerializedSchema
setSchema(schema: SerializedSchema): void
}
/**
* Base interface for legacy migration information.
*
* Contains the basic structure used by the legacy migration system, including version
* range information and the migration functions indexed by version number. This is
* maintained for backward compatibility with older migration definitions.
* @public
*/
export interface LegacyBaseMigrationsInfo {
firstVersion: number
currentVersion: number
migrators: { [version: number]: LegacyMigration }
}
/**
* Legacy migration configuration with support for sub-type migrations.
*
* This interface extends the base legacy migration info to support migrations that
* vary based on a sub-type key within records. This allows different migration paths
* for different variants of the same record type, which was useful in older migration
* systems but is now handled more elegantly by the current Migration system.
* @public
*/
export interface LegacyMigrations extends LegacyBaseMigrationsInfo {
subTypeKey?: string
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}
/**
* A complete sequence of migrations that can be applied to transform data.
*
* A migration sequence represents a series of ordered migrations that belong together,
* typically for a specific part of your schema. The sequence includes metadata about
* whether it should be applied retroactively to existing data and contains the actual
* migration definitions in execution order.
* @public
*/
export interface MigrationSequence {
sequenceId: string
/**
* retroactive should be true if the migrations should be applied to snapshots that were created before
* this migration sequence was added to the schema.
*
* In general:
*
* - retroactive should be true when app developers create their own new migration sequences.
* - retroactive should be false when library developers ship a migration sequence. When you install a library for the first time, any migrations that were added in the library before that point should generally _not_ be applied to your existing data.
*/
retroactive: boolean
sequence: Migration[]
}
/**
* Sorts migrations using a distance-minimizing topological sort.
*
* This function respects two types of dependencies:
* 1. Implicit sequence dependencies (foo/1 must come before foo/2)
* 2. Explicit dependencies via `dependsOn` property
*
* The algorithm minimizes the total distance between migrations and their explicit
* dependencies in the final ordering, while maintaining topological correctness.
* This means when migration A depends on migration B, A will be scheduled as close
* as possible to B (while respecting all constraints).
*
* Implementation uses Kahn's algorithm with priority scoring:
* - Builds dependency graph and calculates in-degrees
* - Uses priority queue that prioritizes migrations which unblock explicit dependencies
* - Processes migrations in urgency order while maintaining topological constraints
* - Detects cycles by ensuring all migrations are processed
*
* @param migrations - Array of migrations to sort
* @returns Sorted array of migrations in execution order
* @throws Assertion error if circular dependencies are detected
* @example
* ```ts
* const sorted = sortMigrations([
* { id: 'app/2', scope: 'record', up: (r) => r },
* { id: 'app/1', scope: 'record', up: (r) => r },
* { id: 'lib/1', scope: 'record', up: (r) => r, dependsOn: ['app/1'] }
* ])
* // Result: [app/1, app/2, lib/1] (respects both sequence and explicit deps)
* ```
* @public
*/
export function sortMigrations(migrations: Migration[]): Migration[] {
if (migrations.length === 0) return []
// Build dependency graph and calculate in-degrees
const byId = new Map(migrations.map((m) => [m.id, m]))
const dependents = new Map<MigrationId, Set<MigrationId>>() // who depends on this
const inDegree = new Map<MigrationId, number>()
const explicitDeps = new Map<MigrationId, Set<MigrationId>>() // explicit dependsOn relationships
// Initialize
for (const m of migrations) {
inDegree.set(m.id, 0)
dependents.set(m.id, new Set())
explicitDeps.set(m.id, new Set())
}
// Add implicit sequence dependencies and explicit dependencies
for (const m of migrations) {
const { version, sequenceId } = parseMigrationId(m.id)
// Implicit dependency on previous in sequence
const prevId = `${sequenceId}/${version - 1}` as MigrationId
if (byId.has(prevId)) {
dependents.get(prevId)!.add(m.id)
inDegree.set(m.id, inDegree.get(m.id)! + 1)
}
// Explicit dependencies
if (m.dependsOn) {
for (const depId of m.dependsOn) {
if (!byId.has(depId)) continue
explicitDeps.get(m.id)!.add(depId)
// A dependency that is also the implicit predecessor, or that is listed twice, must
// only count once: the edge is only ever removed once, so a double-counted in-degree
// would leave the migration unprocessed and be reported as a circular dependency.
const dependentsOfDep = dependents.get(depId)!
if (dependentsOfDep.has(m.id)) continue
dependentsOfDep.add(m.id)
inDegree.set(m.id, inDegree.get(m.id)! + 1)
}
}
}
// Priority queue: migrations ready to process (in-degree 0)
const ready = migrations.filter((m) => inDegree.get(m.id) === 0)
const result: Migration[] = []
const processed = new Set<MigrationId>()
while (ready.length > 0) {
// Calculate urgency scores for ready migrations and pick the best one
let bestCandidate: Migration | undefined
let bestCandidateScore = -Infinity
for (const m of ready) {
let urgencyScore = 0
for (const depId of dependents.get(m.id) || []) {
if (!processed.has(depId)) {
// Priority 1: Count all unprocessed dependents (to break ties)
urgencyScore += 1
// Priority 2: If this migration is explicitly depended on by others, boost priority
if (explicitDeps.get(depId)!.has(m.id)) {
urgencyScore += 100
}
}
}
if (
urgencyScore > bestCandidateScore ||
// Tiebreaker: prefer lower sequence/version
(urgencyScore === bestCandidateScore && m.id.localeCompare(bestCandidate?.id ?? '') < 0)
) {
bestCandidate = m
bestCandidateScore = urgencyScore
}
}
const nextMigration = bestCandidate!
ready.splice(ready.indexOf(nextMigration), 1)
// Cycle detection - if we have processed everything and still have items left, there's a cycle
// This is handled by Kahn's algorithm naturally - if we finish with items unprocessed, there's a cycle
// Process this migration
result.push(nextMigration)
processed.add(nextMigration.id)
// Update in-degrees and add newly ready migrations
for (const depId of dependents.get(nextMigration.id) || []) {
if (!processed.has(depId)) {
inDegree.set(depId, inDegree.get(depId)! - 1)
if (inDegree.get(depId) === 0) {
ready.push(byId.get(depId)!)
}
}
}
}
// Check for cycles - if we didn't process all migrations, there's a cycle
if (result.length !== migrations.length) {
const unprocessed = migrations.filter((m) => !processed.has(m.id))
assert(false, `Circular dependency in migrations: ${unprocessed[0].id}`)
}
return result
}
/**
* Parses a migration ID to extract the sequence ID and version number.
*
* Migration IDs follow the format `sequenceId/version`, and this function splits
* them into their component parts. This is used internally for sorting migrations
* and understanding their relationships.
* @param id - The migration ID to parse
* @returns Object containing the sequence ID and numeric version
* @example
* ```ts
* const { sequenceId, version } = parseMigrationId('com.myapp.book/5')
* // sequenceId: 'com.myapp.book', version: 5
* ```
* @internal
*/
export function parseMigrationId(id: MigrationId): { sequenceId: string; version: number } {
const [sequenceId, version] = id.split('/')
return { sequenceId, version: parseInt(version) }
}
function validateMigrationId(id: string, expectedSequenceId?: string) {
if (expectedSequenceId) {
assert(
id.startsWith(expectedSequenceId + '/'),
`Every migration in sequence '${expectedSequenceId}' must have an id starting with '${expectedSequenceId}/'. Got invalid id: '${id}'`
)
}
assert(id.match(/^(.*?)\/(0|[1-9]\d*)$/), `Invalid migration id: '${id}'`)
}
/**
* Validates that a migration sequence is correctly structured.
*
* Performs several validation checks to ensure the migration sequence is valid:
* - Sequence ID doesn't contain invalid characters
* - All migration IDs belong to the expected sequence
* - Migration versions start at 1 and increment by 1
* - Migration IDs follow the correct format
* @param migrations - The migration sequence to validate
* @throws Assertion error if any validation checks fail
* @example
* ```ts
* const sequence = createMigrationSequence({
* sequenceId: 'com.myapp.book',
* sequence: [{ id: 'com.myapp.book/1', scope: 'record', up: (r) => r }]
* })
* validateMigrations(sequence) // Passes validation
* ```
* @public
*/
export function validateMigrations(migrations: MigrationSequence) {
assert(
!migrations.sequenceId.includes('/'),
`sequenceId cannot contain a '/', got ${migrations.sequenceId}`
)
assert(migrations.sequenceId.length, 'sequenceId must be a non-empty string')
if (migrations.sequence.length === 0) {
return
}
validateMigrationId(migrations.sequence[0].id, migrations.sequenceId)
let n = parseMigrationId(migrations.sequence[0].id).version
assert(
n === 1,
`Expected the first migrationId to be '${migrations.sequenceId}/1' but got '${migrations.sequence[0].id}'`
)
for (let i = 1; i < migrations.sequence.length; i++) {
const id = migrations.sequence[i].id
validateMigrationId(id, migrations.sequenceId)
const m = parseMigrationId(id).version
assert(
m === n + 1,
`Migration id numbers must increase in increments of 1, expected ${migrations.sequenceId}/${n + 1} but got '${migrations.sequence[i].id}'`
)
n = m
}
}
/**
* Result type returned by migration operations.
*
* Migration operations can either succeed and return the transformed value,
* or fail with a specific reason. This discriminated union type allows for
* safe handling of both success and error cases when applying migrations.
* @public
*/
export type MigrationResult<T> =
| { type: 'success'; value: T }
| { type: 'error'; reason: MigrationFailureReason }
/** @public */
export const MigrationFailureReason = {
IncompatibleSubtype: 'incompatible-subtype',
UnknownType: 'unknown-type',
TargetVersionTooNew: 'target-version-too-new',
TargetVersionTooOld: 'target-version-too-old',
MigrationError: 'migration-error',
UnrecognizedSubtype: 'unrecognized-subtype',
} as const
/** @public */
export type MigrationFailureReason =
(typeof MigrationFailureReason)[keyof typeof MigrationFailureReason]
// This is the magic part for backward compat:
/** @public */
// eslint-disable-next-line @typescript-eslint/no-namespace
export declare namespace MigrationFailureReason {
export type IncompatibleSubtype = typeof MigrationFailureReason.IncompatibleSubtype
export type UnknownType = typeof MigrationFailureReason.UnknownType
export type TargetVersionTooNew = typeof MigrationFailureReason.TargetVersionTooNew
export type TargetVersionTooOld = typeof MigrationFailureReason.TargetVersionTooOld
export type MigrationError = typeof MigrationFailureReason.MigrationError
export type UnrecognizedSubtype = typeof MigrationFailureReason.UnrecognizedSubtype
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import { diffSets, intersectSets } from './setUtils'
// Tests for SPEC.md §25 (set utilities).
// Rule IDs like [SU1] in test names refer to that document.
describe('intersectSets (SU)', () => {
it('[SU1] returns the elements present in every set', () => {
expect(
intersectSets([new Set([1, 2, 3, 4]), new Set([2, 3, 4, 5]), new Set([3, 4, 5, 6])])
).toEqual(new Set([3, 4]))
expect(intersectSets([new Set([1, 2, 3]), new Set([4, 5, 6])])).toEqual(new Set())
expect(intersectSets([new Set([1, 2, 3]), new Set(), new Set([2, 3, 4])])).toEqual(new Set())
})
it('[SU1] no sets yields an empty set', () => {
expect(intersectSets([])).toEqual(new Set())
})
it('[SU1] a single set yields a copy', () => {
const set = new Set([1, 2, 3])
const result = intersectSets([set])
expect(result).not.toBe(set)
expect(result).toEqual(set)
})
})
describe('diffSets (SU)', () => {
it('[SU2] reports added and removed elements, with only the populated keys', () => {
expect(diffSets(new Set(['a', 'b']), new Set(['a', 'b', 'c']))).toEqual({
added: new Set(['c']),
})
expect(diffSets(new Set(['a', 'b', 'c']), new Set(['a', 'b']))).toEqual({
removed: new Set(['c']),
})
expect(diffSets(new Set(['a', 'b']), new Set(['b', 'c']))).toEqual({
added: new Set(['c']),
removed: new Set(['a']),
})
})
it('[SU2] returns undefined when the sets have the same members', () => {
expect(diffSets(new Set(['a', 'b', 'c']), new Set(['a', 'b', 'c']))).toBeUndefined()
expect(diffSets(new Set(), new Set())).toBeUndefined()
})
it('[SU2] compares object members by reference', () => {
const obj1 = { id: 1 }
const obj2 = { id: 2 }
const obj3 = { id: 3 }
expect(diffSets(new Set([obj1, obj2]), new Set([obj2, obj3]))).toEqual({
added: new Set([obj3]),
removed: new Set([obj1]),
})
// structurally equal but distinct objects are different members
expect(diffSets(new Set([{ id: 1 }]), new Set([{ id: 1 }]))).toBeDefined()
})
})

View File

@@ -0,0 +1,87 @@
import { CollectionDiff } from './Store'
/**
* Combine multiple sets into a single set containing only the common elements of all sets.
* Returns the intersection of all provided sets - elements that exist in every set.
* If no sets are provided, returns an empty set.
*
* @param sets - The sets to intersect. Can be an empty array.
* @returns A new set containing only elements that exist in all input sets
*
* @example
* ```ts
* const set1 = new Set([1, 2, 3])
* const set2 = new Set([2, 3, 4])
* const set3 = new Set([3, 4, 5])
*
* const intersection = intersectSets([set1, set2, set3])
* console.log(intersection) // Set {3}
*
* // Empty array returns empty set
* const empty = intersectSets([])
* console.log(empty) // Set {}
* ```
*
* @public
*/
export function intersectSets<T>(sets: Set<T>[]) {
if (sets.length === 0) return new Set<T>()
const first = sets[0]
const rest = sets.slice(1)
const result = new Set<T>()
for (const val of first) {
if (rest.every((set) => set.has(val))) {
result.add(val)
}
}
return result
}
/**
* Calculates a diff between two sets, identifying which elements were added or removed.
* Returns undefined if the sets are identical (no changes detected).
*
* @param prev - The previous set to compare from
* @param next - The next set to compare to
* @returns A CollectionDiff object with `added` and/or `removed` sets, or undefined if no changes
*
* @example
* ```ts
* const prev = new Set(['a', 'b', 'c'])
* const next = new Set(['b', 'c', 'd'])
*
* const diff = diffSets(prev, next)
* console.log(diff)
* // {
* // added: Set {'d'},
* // removed: Set {'a'}
* // }
*
* // No changes returns undefined
* const same = diffSets(prev, prev)
* console.log(same) // undefined
* ```
*
* @public
*/
export function diffSets<T>(prev: Set<T>, next: Set<T>): CollectionDiff<T> | undefined {
const result: CollectionDiff<T> = {}
for (const val of next) {
if (!prev.has(val)) {
result.added ??= new Set()
result.added.add(val)
}
}
for (const val of prev) {
if (!next.has(val)) {
result.removed ??= new Set()
result.removed.add(val)
}
}
return result.added || result.removed ? result : undefined
}

View File

@@ -0,0 +1,511 @@
import { atom, EffectScheduler, RESET_VALUE } from '@tldraw/state'
import { BaseRecord, IdOf, RecordId, UnknownRecord } from '../BaseRecord'
import { executeQuery } from '../executeQuery'
import { createRecordType } from '../RecordType'
import { CollectionDiff, Store } from '../Store'
import { RSIndexDiff } from '../StoreQueries'
import { StoreSchema } from '../StoreSchema'
interface Author extends BaseRecord<'author', RecordId<Author>> {
name: AuthorName
age: number
}
const Author = createRecordType<Author>('author', {
validator: {
validate(value) {
const author = value as Author
if (author.typeName !== 'author') throw Error()
if (!author.id.startsWith('author:')) throw Error()
if (!Number.isFinite(author.age)) throw Error()
if (author.age < 0) throw Error()
return author
},
},
scope: 'document',
}).withDefaultProperties(() => ({ age: 23 }))
interface Book extends BaseRecord<'book', RecordId<Book>> {
title: BookName
authorId: RecordId<Author>
}
const Book = createRecordType<Book>('book', {
validator: {
validate(value) {
const book = value as Book
if (!book.id.startsWith('book:')) throw Error()
if (book.typeName !== 'book') throw Error()
if (typeof book.title !== 'string') throw Error()
if (!book.authorId.startsWith('author')) throw Error()
return book
},
},
scope: 'document',
})
const bookComparator = (a: Book, b: Book) => a.id.localeCompare(b.id)
function rng(seed: number) {
return () => {
seed = (seed * 9301 + 49297) % 233280
return seed / 233280
}
}
type Record = Author | Book
type Op =
| { readonly type: 'add'; readonly record: Record }
| { readonly type: 'delete'; readonly id: IdOf<Record> }
| { readonly type: 'update'; readonly record: Record }
| { readonly type: 'set_book_name_query_param'; readonly bookName: BookName }
| { readonly type: 'set_author_id_query_param'; readonly authorId: IdOf<Author> }
const BOOK_NAMES = [
'Breakfast of Champions',
'Slaughterhouse-Five',
"Cat's Cradle",
'The Sirens of Titan',
'Mother Night',
'Galapagos',
'God Bless You, Mr. Rosewater',
'Welcome to the Monkey House',
'Bluebeard',
'Hocus Pocus',
'The Day the Earth Stood Still',
'The Long Dry',
'The Crying of Lot 49',
"Gravity's Rainbow",
'Player Piano',
'Infinite Jest',
'The Caves of Steel',
'The Naked Sun',
'The Robots of Dawn',
'The Stars My Destination',
'Stoner',
'The Martian Chronicles',
]
type BookName = (typeof BOOK_NAMES)[number]
const AUTHOR_NAMES = [
'Kurt Vonnegut',
'Douglas Adams',
'Isaac Asimov',
'Ray Bradbury',
'Robert A. Heinlein',
'Arthur C. Clarke',
'Frank Herbert',
'J.R.R. Tolkien',
'Philip K. Dick',
'Ursula K. Le Guin',
'William Gibson',
'Neil Gaiman',
'Terry Pratchett',
'George R.R. Martin',
'Stephen King',
'Tad Williams',
'Robert Jordan',
'Brandon Sanderson',
'Terry Brooks',
'Anne McCaffrey',
'Robert A. Heinlein',
]
type AuthorName = (typeof AUTHOR_NAMES)[number]
function getRandomBookName(getRandomNumber: () => number) {
return BOOK_NAMES[Math.floor(getRandomNumber() * 11)]
}
function getRandomAuthorName(getRandomNumber: () => number) {
return AUTHOR_NAMES[Math.floor(getRandomNumber() * 11)]
}
function getRandomBookFromStore(store: Store<Book | Author>, getRandomNumber: () => number): Book {
const books = store.allRecords().filter((record): record is Book => record.typeName === 'book')
return books[Math.floor(getRandomNumber() * books.length)]
}
function getRandomAuthorFromStore(
store: Store<Book | Author>,
getRandomNumber: () => number
): Author {
const authors = store
.allRecords()
.filter((record): record is Author => record.typeName === 'author')
return authors[Math.floor(getRandomNumber() * authors.length)]
}
function getRandomBookOp(
store: Store<Book | Author>,
getRandomNumber: () => number,
deletedRecords: Set<Book | Author>
): Op {
const num = Math.floor(getRandomNumber() * 5)
switch (num) {
case 0: {
if (getRandomNumber() < 0.3) {
const deleted = [...deletedRecords].find((record) => record.typeName === 'book')
if (deleted) {
deletedRecords.delete(deleted)
return { type: 'add', record: deleted }
}
}
const author = getRandomAuthorFromStore(store, getRandomNumber)
if (author) {
return {
type: 'add',
record: Book.create({
id: Book.createId(getRandomNumber().toString()),
authorId: author.id,
title: getRandomBookName(getRandomNumber),
}),
}
} else {
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
}
}
case 1: {
const bookToDelete = getRandomBookFromStore(store, getRandomNumber)
if (bookToDelete) {
return { type: 'delete', id: bookToDelete.id }
}
return getRandomBookOp(store, getRandomNumber, deletedRecords)
}
case 2:
case 3: {
const bookToUpdate = getRandomBookFromStore(store, getRandomNumber)
if (!bookToUpdate) return getRandomBookOp(store, getRandomNumber, deletedRecords)
return {
type: 'update',
record: { ...bookToUpdate, title: getRandomBookName(getRandomNumber) },
}
}
case 4: {
const bookToUpdate = getRandomBookFromStore(store, getRandomNumber)
if (!bookToUpdate) return getRandomBookOp(store, getRandomNumber, deletedRecords)
const authorToReassignBookTo = getRandomAuthorFromStore(store, getRandomNumber)
if (!authorToReassignBookTo) return getRandomBookOp(store, getRandomNumber, deletedRecords)
return {
type: 'update',
record: { ...bookToUpdate, authorId: authorToReassignBookTo.id },
}
}
default:
throw new Error('unreachable: ' + num)
}
}
function getRandomAuthorOp(
store: Store<Book | Author>,
getRandomNumber: () => number,
deletedRecords: Set<Book | Author>
): Op {
const num = Math.floor(getRandomNumber() * 5)
switch (num) {
case 0: {
if (getRandomNumber() < 0.3) {
const deleted = [...deletedRecords].find((record) => record.typeName === 'author')
if (deleted) {
deletedRecords.delete(deleted)
return { type: 'add', record: deleted }
}
}
return {
type: 'add',
record: Author.create({
id: Author.createId(getRandomNumber().toString()),
name: getRandomAuthorName(getRandomNumber),
}),
}
}
case 1: {
const authorToDelete = getRandomAuthorFromStore(store, getRandomNumber)
if (authorToDelete) {
return { type: 'delete', id: authorToDelete.id }
}
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
}
case 2:
case 3: {
const authorToUpdate = getRandomAuthorFromStore(store, getRandomNumber)
if (!authorToUpdate) return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
return {
type: 'update',
record: {
...authorToUpdate,
name: getRandomAuthorName(getRandomNumber),
},
}
}
case 4: {
const authorToUpdate = getRandomAuthorFromStore(store, getRandomNumber)
if (!authorToUpdate) return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
return {
type: 'update',
record: {
...authorToUpdate,
age: Math.floor(getRandomNumber() * 100),
},
}
}
default:
throw new Error('unreachable: ' + num)
}
}
function getRandomOp(
store: Store<Book | Author>,
getRandomNumber: () => number,
deletedRecords: Set<Book | Author>
): Op {
const num = Math.floor(getRandomNumber() * 3)
switch (num) {
case 0:
return getRandomBookOp(store, getRandomNumber, deletedRecords)
case 1:
return getRandomAuthorOp(store, getRandomNumber, deletedRecords)
case 2: {
if (getRandomNumber() > 0.5) {
const authorId = getRandomAuthorFromStore(store, getRandomNumber)?.id
if (authorId) {
return { type: 'set_author_id_query_param', authorId: authorId }
}
return getRandomOp(store, getRandomNumber, deletedRecords)
} else {
return { type: 'set_book_name_query_param', bookName: getRandomBookName(getRandomNumber) }
}
}
default:
throw new Error('unreachable: ' + num)
}
}
function recreateIndexFromDiffs(diffs: RSIndexDiff<any>[]) {
const result = new Map<string, Set<IdOf<UnknownRecord>>>()
for (const diff of diffs) {
for (const [key, changes] of diff) {
const index = result.get(key) || new Set<IdOf<UnknownRecord>>()
if (changes.added) {
for (const id of changes.added) {
index.add(id)
}
}
if (changes.removed) {
for (const id of changes.removed) {
index.delete(id)
}
}
if (index.size === 0) {
result.delete(key)
} else {
result.set(key, index)
}
}
}
return result
}
function reacreateSetFromDiffs<T>(diffs: CollectionDiff<T>[]) {
const result = new Set<T>()
for (const diff of diffs) {
if (diff.added) {
for (const item of diff.added) {
result.add(item)
}
}
if (diff.removed) {
for (const item of diff.removed) {
result.delete(item)
}
}
}
return result
}
const NUM_OPS = 200
function runTest(seed: number) {
const store = new Store({
props: {},
schema: StoreSchema.create<Book | Author>({
book: Book,
author: Author,
}),
})
store.sideEffects.registerBeforeDeleteHandler('author', (record) => {
const books = store.query.index('book', 'authorId').get().get(record.id)
if (books) store.remove([...books])
})
const getRandomNumber = rng(seed)
const authorNameIndex = store.query.index('author', 'name')
const authorIdIndex = store.query.index('book', 'authorId')
const authorNameIndexDiffs: RSIndexDiff<Author>[] = []
const authorIdIndexDiffs: RSIndexDiff<Book>[] = []
const authorIdQueryParam = atom('authorId', Author.createId('does-not-exist'))
const bookTitleQueryParam = atom('bookTitle', getRandomBookName(getRandomNumber))
const booksByAuthorQuery = store.query.records('book', () => ({
authorId: { eq: authorIdQueryParam.get() },
}))
const booksByTitleQuery = store.query.records('book', () => ({
title: { eq: bookTitleQueryParam.get() },
}))
const authorNameQueryParam = atom('authorName', getRandomAuthorName(getRandomNumber))
const authorIdsByNameQuery = store.query.ids('author', () => ({
name: { neq: authorNameQueryParam.get() },
}))
const ops = []
const deletedRecords = new Set<Book | Author>()
try {
let latestBooksByAuthorQueryResult: Book[] = []
let latestBooksByTitleQueryResult: Book[] = []
let latestAuthorIdsByNameQueryResult: Set<RecordId<Author>> = new Set()
const authorIdsByNameDiffs: CollectionDiff<RecordId<Author>>[] = []
const effect = new EffectScheduler('', (lastReactedEpoch: number) => {
const authorNameIndexDiff = authorNameIndex.getDiffSince(lastReactedEpoch)
const authorIdIndexDiff = authorIdIndex.getDiffSince(lastReactedEpoch)
const authorIdsByNameDiff = authorIdsByNameQuery.getDiffSince(lastReactedEpoch)
latestBooksByAuthorQueryResult = booksByAuthorQuery.get()
latestBooksByTitleQueryResult = booksByTitleQuery.get()
latestAuthorIdsByNameQueryResult = authorIdsByNameQuery.get()
if (
authorNameIndexDiff === RESET_VALUE ||
authorIdIndexDiff === RESET_VALUE ||
authorIdsByNameDiff == RESET_VALUE
) {
if (ops.length !== 0) {
throw new Error('unexpected RESET_VALUE ' + ops.length)
}
return
}
authorNameIndexDiffs.push(...authorNameIndexDiff)
authorIdIndexDiffs.push(...authorIdIndexDiff)
authorIdsByNameDiffs.push(...authorIdsByNameDiff)
})
effect.execute()
for (let i = 0; i < NUM_OPS; i++) {
const op = getRandomOp(store, getRandomNumber, deletedRecords)
ops.push(op)
switch (op.type) {
case 'add':
case 'update': {
store.put([op.record])
break
}
case 'delete': {
const r = store.get(op.id)
if (!r) throw new Error('record not found: ' + op.id)
deletedRecords.add(r)
store.remove([op.id])
if (op.id === 'book:0.5525377229080933') {
store.query.index('book', 'title').get()
}
break
}
case 'set_author_id_query_param': {
authorIdQueryParam.set(op.authorId)
break
}
case 'set_book_name_query_param': {
bookTitleQueryParam.set(op.bookName)
break
}
default:
throw new Error('unreachable runTest')
}
// don't check on every op, in case some logic breaks when there are multiple diff entries
if (getRandomNumber() > 0.5) {
effect.execute()
// these tests create a version of the index from scratch and check it against
// the incrementally-updated version to make sure the logic matches.
const authorNameIndexFromScratch = store.query
.__uncached_createIndex('author', 'name')
.get()
const authorIdIndexFromScratch = store.query
.__uncached_createIndex('book', 'authorId')
.get()
expect(authorNameIndex.get()).toEqual(authorNameIndexFromScratch)
expect(authorIdIndex.get()).toEqual(authorIdIndexFromScratch)
// these tests recreate the index from scratch based on the diffs so far and
// check it against the gold standard version to make sure the diff logic matches.
expect(recreateIndexFromDiffs(authorNameIndexDiffs)).toEqual(authorNameIndexFromScratch)
expect(recreateIndexFromDiffs(authorIdIndexDiffs)).toEqual(authorIdIndexFromScratch)
// these tests check the query results against filtering the whole record store from scratch
expect(latestBooksByAuthorQueryResult.sort(bookComparator)).toEqual(
store
.allRecords()
.filter(
(r): r is Book => r.typeName === 'book' && r.authorId === authorIdQueryParam.get()
)
.sort(bookComparator)
)
expect(new Set(latestBooksByAuthorQueryResult.map((b) => b.id))).toEqual(
executeQuery(store.query, 'book', { authorId: { eq: authorIdQueryParam.get() } })
)
expect(latestBooksByTitleQueryResult.sort(bookComparator)).toEqual(
store
.allRecords()
.filter(
(r): r is Book => r.typeName === 'book' && r.title === bookTitleQueryParam.get()
)
.sort(bookComparator)
)
expect(new Set(latestBooksByTitleQueryResult.map((b) => b.id))).toEqual(
executeQuery(store.query, 'book', { title: { eq: bookTitleQueryParam.get() } })
)
expect(latestAuthorIdsByNameQueryResult).toEqual(
new Set(
store
.allRecords()
.filter(
(r): r is Author => r.typeName === 'author' && r.name !== authorNameQueryParam.get()
)
.map((r) => r.id)
)
)
expect(latestAuthorIdsByNameQueryResult).toEqual(
executeQuery(store.query, 'author', { name: { neq: authorNameQueryParam.get() } })
)
// this test checks that the authorIdsByName set matches what you get when you reassemble it from the diffs
expect(reacreateSetFromDiffs(authorIdsByNameDiffs)).toEqual(
latestAuthorIdsByNameQueryResult
)
}
}
} catch (e) {
console.error(ops)
throw e
}
}
const NUM_TESTS = 100
for (let i = 0; i < NUM_TESTS; i++) {
const seed = Math.floor(Math.random() * 1000000)
test('with seed ' + seed, () => {
runTest(seed)
})
}
// regression tests
test('(regression) with seed 128383', () => {
runTest(128383)
})
test('(regression) with seed 894844', () => {
runTest(894844)
})
test('(regression) with seed 436077', () => {
runTest(436077)
})

View File

@@ -0,0 +1,83 @@
import assert from 'assert'
import { BaseRecord, RecordId } from '../BaseRecord'
import { createRecordType } from '../RecordType'
import { StoreSchema } from '../StoreSchema'
/** A user of tldraw */
interface User extends BaseRecord<'user', RecordId<User>> {
name: string
}
const User = createRecordType<User>('user', {
validator: {
validate: (record) => {
assert(
record && typeof record === 'object' && 'name' in record && typeof record.name === 'string'
)
return record as User
},
},
scope: 'document',
})
interface Shape<Props> extends BaseRecord<'shape', RecordId<Shape<object>>> {
type: string
x: number
y: number
props: Props
}
interface RectangleProps {
width: number
height: number
opactiy: number
}
interface OvalProps {
radius: number
borderStyle: 'solid' | 'dashed'
}
const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
validator: {
validate: (record) => {
assert(
record &&
typeof record === 'object' &&
'type' in record &&
typeof record.type === 'string' &&
'x' in record &&
typeof record.x === 'number' &&
'y' in record &&
typeof record.y === 'number' &&
'props' in record &&
typeof record.props === 'object'
)
return record as Shape<RectangleProps | OvalProps>
},
},
scope: 'document',
})
// this interface only exists to be removed
interface Org extends BaseRecord<'org', RecordId<Org>> {
name: string
}
const Org = createRecordType<Org>('org', {
validator: {
validate: (record) => {
assert(
record && typeof record === 'object' && 'name' in record && typeof record.name === 'string'
)
return record as Org
},
},
scope: 'document',
})
export const testSchemaV0 = StoreSchema.create({
user: User,
shape: Shape,
org: Org,
})

View File

@@ -0,0 +1,225 @@
import { assert } from '@tldraw/utils'
import { BaseRecord, RecordId } from '../BaseRecord'
import { createMigrationIds, createMigrationSequence } from '../migrate'
import { createRecordType } from '../RecordType'
import { SerializedStore } from '../Store'
import { StoreSchema } from '../StoreSchema'
const UserVersion = createMigrationIds('com.tldraw.user', {
AddLocale: 1,
AddPhoneNumber: 2,
} as const)
/** A user of tldraw */
interface User extends BaseRecord<'user', RecordId<User>> {
name: string
locale: string
phoneNumber: string | null
}
const userMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.user',
retroactive: true,
sequence: [
{
id: UserVersion.AddLocale,
scope: 'record',
filter: (r) => r.typeName === 'user',
up: (record: any) => {
record.locale = 'en'
},
down: (record: any) => {
delete record.locale
},
},
{
id: UserVersion.AddPhoneNumber,
scope: 'record',
filter: (r) => r.typeName === 'user',
up: (record: any) => {
record.phoneNumber = null
},
down: (record: any) => {
delete record.phoneNumber
},
},
],
})
const User = createRecordType<User>('user', {
validator: {
validate: (record) => {
assert(record && typeof record === 'object')
assert('id' in record && typeof record.id === 'string')
assert('name' in record && typeof record.name === 'string')
assert('locale' in record && typeof record.locale === 'string')
assert(
'phoneNumber' in record &&
(record.phoneNumber === null || typeof record.phoneNumber === 'string')
)
return record as User
},
},
scope: 'document',
}).withDefaultProperties(() => ({
/* STEP 6: Add any new default values for properties here */
name: 'New User',
}))
const ShapeVersion = createMigrationIds('com.tldraw.shape', {
AddRotation: 1,
AddParent: 2,
} as const)
const RectangleVersion = createMigrationIds('com.tldraw.shape.rectangle', {
AddOpacity: 1,
} as const)
const OvalVersion = createMigrationIds('com.tldraw.shape.oval', {
AddBorderStyle: 1,
} as const)
type ShapeId = RecordId<Shape<object>>
interface Shape<Props> extends BaseRecord<'shape', ShapeId> {
type: string
x: number
y: number
rotation: number
parentId: ShapeId | null
props: Props
}
interface RectangleProps {
width: number
height: number
opactiy: number
}
interface OvalProps {
radius: number
borderStyle: 'solid' | 'dashed'
}
const rootShapeMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.shape',
retroactive: true,
sequence: [
{
id: ShapeVersion.AddRotation,
scope: 'record',
filter: (r) => r.typeName === 'shape',
up: (record: any) => {
record.rotation = 0
},
down: (record: any) => {
delete record.rotation
},
},
{
id: ShapeVersion.AddParent,
scope: 'record',
filter: (r) => r.typeName === 'shape',
up: (record: any) => {
record.parentId = null
},
down: (record: any) => {
delete record.parentId
},
},
],
})
const rectangleMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.shape.rectangle',
retroactive: true,
sequence: [
{
id: RectangleVersion.AddOpacity,
scope: 'record',
filter: (r) => r.typeName === 'shape' && (r as Shape<RectangleProps>).type === 'rectangle',
up: (record: any) => {
record.props.opacity = 1
},
down: (record: any) => {
delete record.props.opacity
},
},
],
})
const ovalMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.shape.oval',
retroactive: true,
sequence: [
{
id: OvalVersion.AddBorderStyle,
scope: 'record',
filter: (r) => r.typeName === 'shape' && (r as Shape<OvalProps>).type === 'oval',
up: (record: any) => {
record.props.borderStyle = 'solid'
},
down: (record: any) => {
delete record.props.borderStyle
},
},
],
})
const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
validator: {
validate: (record) => {
assert(record && typeof record === 'object')
assert('id' in record && typeof record.id === 'string')
assert('type' in record && typeof record.type === 'string')
assert('x' in record && typeof record.x === 'number')
assert('y' in record && typeof record.y === 'number')
assert('rotation' in record && typeof record.rotation === 'number')
return record as Shape<RectangleProps | OvalProps>
},
},
scope: 'document',
}).withDefaultProperties(() => ({
x: 0,
y: 0,
rotation: 0,
parentId: null,
}))
const StoreVersions = createMigrationIds('com.tldraw.store', {
RemoveOrg: 1,
})
const snapshotMigrations = createMigrationSequence({
sequenceId: 'com.tldraw.store',
retroactive: true,
sequence: [
{
id: StoreVersions.RemoveOrg,
scope: 'store',
up: (store: SerializedStore<any>) => {
return Object.fromEntries(Object.entries(store).filter(([_, r]) => r.typeName !== 'org'))
},
down: (store: SerializedStore<any>) => {
// noop
return store
},
},
],
})
export const testSchemaV1 = StoreSchema.create<User | Shape<any>>(
{
user: User,
shape: Shape,
},
{
migrations: [
snapshotMigrations,
rootShapeMigrations,
rectangleMigrations,
ovalMigrations,
userMigrations,
],
}
)

View File

@@ -0,0 +1,7 @@
{
"extends": "../../internal/config/tsconfig.base.json",
"include": ["src"],
"exclude": ["node_modules", "dist", ".tsbuild*"],
"compilerOptions": { "outDir": "./.tsbuild", "rootDir": "src" },
"references": [{ "path": "../state" }, { "path": "../utils" }]
}

View File

@@ -0,0 +1,14 @@
/// <reference types="vitest" />
import path from 'path'
import { mergeConfig } from 'vitest/config'
import baseConfig from '../../internal/config/vitest/node-preset'
// Store package has additional crypto setup for testing
export default mergeConfig(baseConfig, {
test: {
setupFiles: [
path.resolve(__dirname, './setupTests.js'),
...((baseConfig as any).test?.setupFiles || []),
],
},
})