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
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:
77
packages/store/src/index.ts
Normal file
77
packages/store/src/index.ts
Normal 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
|
||||
)
|
||||
663
packages/store/src/lib/AtomMap.test.ts
Normal file
663
packages/store/src/lib/AtomMap.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
465
packages/store/src/lib/AtomMap.ts
Normal file
465
packages/store/src/lib/AtomMap.ts
Normal 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'
|
||||
}
|
||||
116
packages/store/src/lib/AtomSet.test.ts
Normal file
116
packages/store/src/lib/AtomSet.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
52
packages/store/src/lib/AtomSet.ts
Normal file
52
packages/store/src/lib/AtomSet.ts
Normal 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'
|
||||
}
|
||||
32
packages/store/src/lib/BaseRecord.test.ts
Normal file
32
packages/store/src/lib/BaseRecord.test.ts
Normal 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')
|
||||
}
|
||||
})
|
||||
})
|
||||
136
packages/store/src/lib/BaseRecord.ts
Normal file
136
packages/store/src/lib/BaseRecord.ts
Normal 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
|
||||
}
|
||||
179
packages/store/src/lib/ImmutableMap.test.ts
Normal file
179
packages/store/src/lib/ImmutableMap.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
1167
packages/store/src/lib/ImmutableMap.ts
Normal file
1167
packages/store/src/lib/ImmutableMap.ts
Normal file
File diff suppressed because it is too large
Load Diff
116
packages/store/src/lib/IncrementalSetConstructor.test.ts
Normal file
116
packages/store/src/lib/IncrementalSetConstructor.test.ts
Normal 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']) } })
|
||||
})
|
||||
})
|
||||
191
packages/store/src/lib/IncrementalSetConstructor.ts
Normal file
191
packages/store/src/lib/IncrementalSetConstructor.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
216
packages/store/src/lib/RecordType.test.ts
Normal file
216
packages/store/src/lib/RecordType.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
379
packages/store/src/lib/RecordType.ts
Normal file
379
packages/store/src/lib/RecordType.ts
Normal 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`)
|
||||
}
|
||||
}
|
||||
177
packages/store/src/lib/RecordsDiff.test.ts
Normal file
177
packages/store/src/lib/RecordsDiff.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
347
packages/store/src/lib/RecordsDiff.ts
Normal file
347
packages/store/src/lib/RecordsDiff.ts
Normal 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
|
||||
}
|
||||
767
packages/store/src/lib/Store.test.ts
Normal file
767
packages/store/src/lib/Store.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
1467
packages/store/src/lib/Store.ts
Normal file
1467
packages/store/src/lib/Store.ts
Normal file
File diff suppressed because it is too large
Load Diff
516
packages/store/src/lib/StoreListeners.test.ts
Normal file
516
packages/store/src/lib/StoreListeners.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
})
|
||||
892
packages/store/src/lib/StoreQueries.test.ts
Normal file
892
packages/store/src/lib/StoreQueries.test.ts
Normal 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])
|
||||
)
|
||||
})
|
||||
})
|
||||
635
packages/store/src/lib/StoreQueries.ts
Normal file
635
packages/store/src/lib/StoreQueries.ts
Normal 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 }>)
|
||||
}
|
||||
}
|
||||
1109
packages/store/src/lib/StoreSchema.test.ts
Normal file
1109
packages/store/src/lib/StoreSchema.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
813
packages/store/src/lib/StoreSchema.ts
Normal file
813
packages/store/src/lib/StoreSchema.ts
Normal 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
|
||||
}
|
||||
}
|
||||
725
packages/store/src/lib/StoreSideEffects.test.ts
Normal file
725
packages/store/src/lib/StoreSideEffects.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
684
packages/store/src/lib/StoreSideEffects.ts
Normal file
684
packages/store/src/lib/StoreSideEffects.ts
Normal 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)]
|
||||
}
|
||||
111
packages/store/src/lib/devFreeze.test.ts
Normal file
111
packages/store/src/lib/devFreeze.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
52
packages/store/src/lib/devFreeze.ts
Normal file
52
packages/store/src/lib/devFreeze.ts
Normal 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)
|
||||
}
|
||||
1451
packages/store/src/lib/executeQuery.test.ts
Normal file
1451
packages/store/src/lib/executeQuery.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
192
packages/store/src/lib/executeQuery.ts
Normal file
192
packages/store/src/lib/executeQuery.ts
Normal 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>>
|
||||
}
|
||||
20
packages/store/src/lib/isDev.ts
Normal file
20
packages/store/src/lib/isDev.ts
Normal 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
|
||||
}
|
||||
392
packages/store/src/lib/migrate.test.ts
Normal file
392
packages/store/src/lib/migrate.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
557
packages/store/src/lib/migrate.ts
Normal file
557
packages/store/src/lib/migrate.ts
Normal 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
|
||||
}
|
||||
64
packages/store/src/lib/setUtils.test.ts
Normal file
64
packages/store/src/lib/setUtils.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
87
packages/store/src/lib/setUtils.ts
Normal file
87
packages/store/src/lib/setUtils.ts
Normal 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
|
||||
}
|
||||
511
packages/store/src/lib/test/recordStoreFuzzing.test.ts
Normal file
511
packages/store/src/lib/test/recordStoreFuzzing.test.ts
Normal 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)
|
||||
})
|
||||
83
packages/store/src/lib/test/testSchema.v0.ts
Normal file
83
packages/store/src/lib/test/testSchema.v0.ts
Normal 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,
|
||||
})
|
||||
225
packages/store/src/lib/test/testSchema.v1.ts
Normal file
225
packages/store/src/lib/test/testSchema.v1.ts
Normal 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,
|
||||
],
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user