@tldraw/store
Version:
tldraw infinite canvas SDK (store).
401 lines (357 loc) • 10.8 kB
text/typescript
import { describe, expect, it } from 'vitest'
import { UnknownRecord } from './BaseRecord'
import { SerializedStore } from './Store'
import {
type Migration,
type MigrationId,
type MigrationSequence,
type StandaloneDependsOn,
createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
parseMigrationId,
sortMigrations,
validateMigrations,
} from './migrate'
describe('createMigrationIds', () => {
it('creates properly formatted migration IDs', () => {
const ids = createMigrationIds('com.myapp.book', {
addGenre: 1,
addPublisher: 2,
removeOldField: 3,
})
expect(ids).toEqual({
addGenre: 'com.myapp.book/1',
addPublisher: 'com.myapp.book/2',
removeOldField: 'com.myapp.book/3',
})
})
})
describe('parseMigrationId', () => {
it('parses migration IDs correctly', () => {
expect(parseMigrationId('com.myapp.book/5' as MigrationId)).toEqual({
sequenceId: 'com.myapp.book',
version: 5,
})
expect(parseMigrationId('test/1' as MigrationId)).toEqual({
sequenceId: 'test',
version: 1,
})
expect(parseMigrationId('com.example.app/42' as MigrationId)).toEqual({
sequenceId: 'com.example.app',
version: 42,
})
})
})
describe('createMigrationSequence', () => {
it('creates and validates migration sequences', () => {
const migration: Migration = {
id: 'test/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => ({ ...record, newField: 'default' }),
}
const sequence = createMigrationSequence({
sequenceId: 'test',
sequence: [migration],
retroactive: false,
})
expect(sequence.sequenceId).toBe('test')
expect(sequence.retroactive).toBe(false)
expect(sequence.sequence).toHaveLength(1)
})
it('squashes standalone dependsOn entries', () => {
const dependsOn: StandaloneDependsOn = {
dependsOn: ['other/1' as MigrationId, 'another/2' as MigrationId],
}
const migration: Migration = {
id: 'test/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['existing/1' as MigrationId],
}
const sequence = createMigrationSequence({
sequenceId: 'test',
sequence: [dependsOn, migration],
})
expect(sequence.sequence).toHaveLength(1)
expect(sequence.sequence[0].dependsOn).toEqual(['other/1', 'another/2', 'existing/1'])
})
it('validates the migration sequence', () => {
expect(() => {
createMigrationSequence({
sequenceId: 'test/invalid',
sequence: [],
})
}).toThrow()
})
})
describe('createRecordMigrationSequence', () => {
it('creates record-scoped migrations with type filtering', () => {
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')
// Test the filter function
const migration = sequence.sequence[0] as Extract<Migration, { scope: 'record' }>
const bookRecord = { id: 'book-1' as any, typeName: 'book' } as any as UnknownRecord
const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
expect(migration.filter?.(bookRecord)).toBe(true)
expect(migration.filter?.(userRecord)).toBe(false)
})
it('combines record type filter with custom 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' }>
const rectangleShape = {
id: 'shape-1' as any,
typeName: 'shape',
shapeType: 'rectangle',
} as any as UnknownRecord
const circleShape = {
id: 'shape-2' as any,
typeName: 'shape',
shapeType: 'circle',
} as any as UnknownRecord
const userRecord = { id: 'user-1' as any, typeName: 'user' } as any as UnknownRecord
expect(migration.filter?.(rectangleShape)).toBe(true)
expect(migration.filter?.(circleShape)).toBe(false)
expect(migration.filter?.(userRecord)).toBe(false)
})
})
describe('validateMigrations', () => {
it('validates sequential migration versions', () => {
const sequence: MigrationSequence = {
sequenceId: 'test',
retroactive: true,
sequence: [
{
id: 'test/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
{
id: 'test/2' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
{
id: 'test/3' as MigrationId,
scope: 'store',
up: (store: SerializedStore<UnknownRecord>) => store,
},
],
}
expect(() => validateMigrations(sequence)).not.toThrow()
})
it('throws on sequence ID with slash', () => {
const sequence: MigrationSequence = {
sequenceId: 'test/invalid',
retroactive: true,
sequence: [],
}
expect(() => validateMigrations(sequence)).toThrow(
"sequenceId cannot contain a '/', got test/invalid"
)
})
it('throws on invalid migration ID format', () => {
const sequence: MigrationSequence = {
sequenceId: 'test',
retroactive: true,
sequence: [
{
id: 'invalid-id' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
],
}
expect(() => validateMigrations(sequence)).toThrow(
"Every migration in sequence 'test' must have an id starting with 'test/'. Got invalid id: 'invalid-id'"
)
})
it('throws on first migration not starting at version 1', () => {
const sequence: MigrationSequence = {
sequenceId: 'test',
retroactive: true,
sequence: [
{
id: 'test/5' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
],
}
expect(() => validateMigrations(sequence)).toThrow(
"Expected the first migrationId to be 'test/1' but got 'test/5'"
)
})
it('throws on non-sequential migration versions', () => {
const sequence: MigrationSequence = {
sequenceId: 'test',
retroactive: true,
sequence: [
{
id: 'test/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
{
id: 'test/3' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
},
],
}
expect(() => validateMigrations(sequence)).toThrow(
"Migration id numbers must increase in increments of 1, expected test/2 but got 'test/3'"
)
})
})
describe('sortMigrations', () => {
it('sorts migrations by sequence order', () => {
const migration1: Migration = {
id: 'app/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const migration2: Migration = {
id: 'app/2' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
expect(sortMigrations([migration2, migration1])).toEqual([migration1, migration2])
})
it('respects explicit dependencies', () => {
const libMigration: Migration = {
id: 'lib/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['app/1' as MigrationId],
}
const appMigration: Migration = {
id: 'app/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const sorted = sortMigrations([libMigration, appMigration])
expect(sorted).toEqual([appMigration, libMigration])
})
it('handles complex dependency chains', () => {
const a1: Migration = {
id: 'a/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const b1: Migration = {
id: 'b/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['a/1' as MigrationId],
}
const c1: Migration = {
id: 'c/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['b/1' as MigrationId],
}
const d1: Migration = {
id: 'd/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['a/1' as MigrationId, 'c/1' as MigrationId],
}
const sorted = sortMigrations([d1, c1, b1, a1])
expect(sorted).toEqual([a1, b1, c1, d1])
})
it('prioritizes explicit dependencies over sequence order', () => {
const later: Migration = {
id: 'z/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const earlier: Migration = {
id: 'a/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['z/1' as MigrationId],
}
const sorted = sortMigrations([earlier, later])
expect(sorted).toEqual([later, earlier])
})
it('throws on circular dependencies', () => {
const a: Migration = {
id: 'a/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['b/1' as MigrationId],
}
const b: Migration = {
id: 'b/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['a/1' as MigrationId],
}
expect(() => sortMigrations([a, b])).toThrow('Circular dependency in migrations: a/1')
})
it('handles multiple sequences with cross-dependencies', () => {
const app1: Migration = {
id: 'app/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const app2: Migration = {
id: 'app/2' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const lib1: Migration = {
id: 'lib/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['app/1' as MigrationId],
}
const lib2: Migration = {
id: 'lib/2' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
}
const plugin1: Migration = {
id: 'plugin/1' as MigrationId,
scope: 'record',
up: (record: UnknownRecord) => record,
dependsOn: ['app/2' as MigrationId, 'lib/1' as MigrationId],
}
const sorted = sortMigrations([plugin1, lib2, lib1, app2, app1])
// Verify constraints are satisfied
const app1Index = sorted.indexOf(app1)
const app2Index = sorted.indexOf(app2)
const lib1Index = sorted.indexOf(lib1)
const lib2Index = sorted.indexOf(lib2)
const plugin1Index = sorted.indexOf(plugin1)
// Sequence dependencies
expect(app1Index).toBeLessThan(app2Index)
expect(lib1Index).toBeLessThan(lib2Index)
// Explicit dependencies
expect(app1Index).toBeLessThan(lib1Index) // lib/1 depends on app/1
expect(app2Index).toBeLessThan(plugin1Index) // plugin/1 depends on app/2
expect(lib1Index).toBeLessThan(plugin1Index) // plugin/1 depends on lib/1
})
})