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

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

View File

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