New migrations again (#3220)

Describe what your pull request does. If appropriate, add GIFs or images
showing the before and after.

### Change Type

- [x] `sdk` — Changes the tldraw SDK
- [x] `galaxy brain` — Architectural changes



### Test Plan

1. Add a step-by-step description of how to test your PR here.
2.

- [ ] Unit Tests
- [ ] End to end tests

### Release Notes

#### BREAKING CHANGES

- The `Migrations` type is now called `LegacyMigrations`.
- The serialized schema format (e.g. returned by
`StoreSchema.serialize()` and `Store.getSnapshot()`) has changed. You
don't need to do anything about it unless you were reading data directly
from the schema for some reason. In which case it'd be best to avoid
that in the future! We have no plans to change the schema format again
(this time was traumatic enough) but you never know.
- `compareRecordVersions` and the `RecordVersion` type have both
disappeared. There is no replacement. These were public by mistake
anyway, so hopefully nobody had been using it.
- `compareSchemas` is a bit less useful now. Our migrations system has
become a little fuzzy to allow for simpler UX when adding/removing
custom extensions and 3rd party dependencies, and as a result we can no
longer compare serialized schemas in any rigorous manner. You can rely
on this function to return `0` if the schemas are the same. Otherwise it
will return `-1` if the schema on the right _seems_ to be newer than the
schema on the left, but it cannot guarantee that in situations where
migration sequences have been removed over time (e.g. if you remove one
of the builtin tldraw shapes).

Generally speaking, the best way to check schema compatibility now is to
call `store.schema.getMigrationsSince(persistedSchema)`. This will throw
an error if there is no upgrade path from the `persistedSchema` to the
current version.

- `defineMigrations` has been deprecated and will be removed in a future
release. For upgrade instructions see
https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations

- `migrate` has been removed. Nobody should have been using this but if
you were you'll need to find an alternative. For migrating tldraw data,
you should stick to using `schema.migrateStoreSnapshot` and, if you are
building a nuanced sync engine that supports some amount of backwards
compatibility, also feel free to use `schema.migratePersistedRecord`.
- the `Migration` type has changed. If you need the old one for some
reason it has been renamed to `LegacyMigration`. It will be removed in a
future release.
- the `Migrations` type has been renamed to `LegacyMigrations` and will
be removed in a future release.
- the `SerializedSchema` type has been augmented. If you need the old
version specifically you can use `SerializedSchemaV1`

---------

Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
David Sheldrick 2024-04-15 13:53:42 +01:00 committed by GitHub
parent 63f20d1834
commit 4f70a4f4e8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 109320 additions and 106484 deletions

2
.gitignore vendored
View file

@ -7,6 +7,8 @@ yarn-error.log*
pnpm-debug.log* pnpm-debug.log*
lerna-debug.log* lerna-debug.log*
.rooms
node_modules node_modules
dist dist
dist-cjs dist-cjs

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

View file

@ -1,9 +1,75 @@
--- ---
title: Collaboration title: Collaboration
status: published status: published
author: steveruizok author: ds300
date: 3/22/2023 date: 3/22/2023
order: 8 order: 8
--- ---
See the [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) for an example of how to use yjs with the `tldraw` library. We've designed the tldraw SDK to work with any collaboration backend. Depending on which backend you choose, you will need an interface that pipes changes coming from the editor to the backend and then merge changes from the backend back to the editor.
The best way to get started is by adapting one of our examples.
### Yjs sync example
We created a [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) to illustrate a way of using the [yjs](https://yjs.dev) library with the tldraw SDK. If you need a "drop in solution" for prototyping multiplayer experiences with tldraw, start here.
### Sockets example
We have a [sockets example](https://github.com/tldraw/tldraw-sockets-example) that uses [PartyKit](https://www.partykit.io/) as a backend. Unlike the yjs example, this example does not use any special data structures to handle conflicts. It should be a good starting point if you needed to write your own conflict-resolution logic.
### Our own sync engine
We developed our own sync engine for use on tldraw.com based on a push/pull/rebase-style algorithm. It powers our "shared projects", such as [this one](https://tldraw.com/r). The engine's source code can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync). It was designed to be hosted on Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/).
We don't suggest using this code directly. However, like our other examples, it may serve as a good reference for your own sync engine.
## Store data
For information about how to synchronize the store with other processes, i.e. how to get data out and put data in, including from remote sources, see the (Persistence)[/docs/persistence] page.
## User presence
Tldraw has support for displaying the 'presence' of other users. Presence information consists of:
- The user's pointer position
- The user's set of selected shapes
- The user's viewport bounds (the part of the canvas they are currently viewing)
- The user's name, id, and a color to represent them
This information will usually come from two sources:
- The tldraw editor state (e.g. pointer position, selected shapes)
- The data layer of whichever app tldraw has been embedded in (e.g. user name, user id)
Tldraw is agnostic about how this data is shared among users. However, in order for tldraw to use the presence data it needs to be put into the editor's store as `instance_presence` records.
We provide a helper for constructing a reactive signal for an `instance_presence` record locally, which can then be sent to other clients somehow. It is called [createPresenceStateDerivation](?).
```ts
import { createPresenceStateDerivation, react, atom } from 'tldraw'
// First you need to create a Signal containing the basic user details: id, name, and color
const user = atom<{ id: string; color: string; name: string }>('user', {
id: myUser.id,
color: myUser.color,
name: myUser.name,
})
// if you don't have your own user data backend, you can use our localStorage-only user preferences store
// import { getUserPreferences, computed } from 'tldraw'
// const user = computed('user', getUserPreferences)
// Then, with access to your store instance, you can create a presence signal
const userPresence = createPresenceStateDerivation(user)(store)
// Then you can listen for changes to the presence signal and send them to other clients
const unsub = react('update presence', () => {
const presence = userPresence.get()
broadcastPresence(presence)
})
```
The other clients would then call `store.put([presence])` to add the presence information to their store.
Any such `instance_presence` records tldraw finds in the store that have a different user `id` than the editor's configured user id will cause the presence information to be rendered on the canvas.

View file

@ -50,21 +50,9 @@ editor.getSelectedShapeIds() // [myShapeId, myOtherShapeId]
Each change to the state happens within a transaction. You can batch changes into a single transaction using the [Editor#batch](?) method. It's a good idea to batch wherever possible, as this reduces the overhead for persisting or distributing those changes. Each change to the state happens within a transaction. You can batch changes into a single transaction using the [Editor#batch](?) method. It's a good idea to batch wherever possible, as this reduces the overhead for persisting or distributing those changes.
### Listening for changes ### Listening for changes, and merging changes from other sources
You can subscribe to changes using the [Store#listen](?) method on [Editor#store](?). Each time a transaction completes, the editor will call the callback with a history entry. This entry contains information about the records that were added, changed, or deleted, as well as whether the change was caused by the user or from a remote change. For information about how to synchronize the store with other processes, i.e. how to get data out and put data in, see the (Persistence)[/docs/persistence] page.
```ts
editor.store.listen((entry) => {
entry // { changes, source }
})
```
### Remote changes
By default, changes to the editor's store are assumed to have come from the editor itself. You can use the [Store#mergeRemoteChanges](?) method of the editor's [Editor#store](?) to make changes in the store that will be emitted via [Store#listen](?) with the `source` property as `'remote'`.
If you're setting up some kind of multiplayer backend, you would want to send only the `'user'` changes to the server and merge the changes from the server using [Store#mergeRemoteChanges](?) (`editor.store.mergeRemoteChanges`).
### Undo and redo ### Undo and redo

View file

@ -17,7 +17,7 @@ Persistence in tldraw means storing information about the editor's state to a da
## The `"persistenceKey"` prop ## The `"persistenceKey"` prop
Both the `<Tldraw>` or `<TldrawEditor>` components support local persitence and cross-tab synchronization via the `persistenceKey` prop. Passing a value to this prop will persist the contents of the editor locally to the browser's IndexedDb. Both the `<Tldraw>` or `<TldrawEditor>` components support local persistence and cross-tab synchronization via the `persistenceKey` prop. Passing a value to this prop will persist the contents of the editor locally to the browser's IndexedDb.
```tsx ```tsx
import { Tldraw } from 'tldraw' import { Tldraw } from 'tldraw'
@ -54,7 +54,7 @@ export default function () {
In the example above, both editors would synchronize their document locally. They would still have two independent instance states (e.g. selections) but the document would be kept in sync and persisted under the same key. In the example above, both editors would synchronize their document locally. They would still have two independent instance states (e.g. selections) but the document would be kept in sync and persisted under the same key.
## Snapshots ## Document Snapshots
You can get a JSON snapshot of the editor's content using the [Editor#store](?)'s [Store#getSnapshot](?) method. You can get a JSON snapshot of the editor's content using the [Editor#store](?)'s [Store#getSnapshot](?) method.
@ -96,7 +96,7 @@ function LoadButton() {
A [snapshot](/reference/store/StoreSnapshot) includes both the store's [serialized records](/reference/store/SerializedStore) and its [serialized schema](/reference/store/SerializedSchema), which is used for migrations. A [snapshot](/reference/store/StoreSnapshot) includes both the store's [serialized records](/reference/store/SerializedStore) and its [serialized schema](/reference/store/SerializedSchema), which is used for migrations.
> By default, the `getSnapshot` method returns only the editor's document data. If you want to get records from a different scope, You can pass in `session`, `document`, `presence`, or else `all` for all scopes. > By default, the `getSnapshot` method returns only the editor's document data. If you want to get records from a different scope, you can pass in `session`, `document`, `presence`, or else `all` for all scopes.
Note that loading a snapshot does not reset the editor's in memory state or UI state. For example, loading a snapshot during a resizing operation may lead to a crash. This is because the resizing state maintains its own cache of information about which shapes it is resizing, and its possible that those shapes may no longer exist! Note that loading a snapshot does not reset the editor's in memory state or UI state. For example, loading a snapshot during a resizing operation may lead to a crash. This is because the resizing state maintains its own cache of information about which shapes it is resizing, and its possible that those shapes may no longer exist!
@ -170,3 +170,242 @@ export default function () {
``` ```
For a good example of this pattern, see the [yjs-example](https://github.com/tldraw/tldraw-yjs-example). For a good example of this pattern, see the [yjs-example](https://github.com/tldraw/tldraw-yjs-example).
## Listening for changes
You can listen for incremental updates to the document state by calling `editor.store.listen`, e.g.
```ts
const unlisten = editor.store.listen(
(update) => {
console.log('update', update)
},
{ scope: 'document', source: 'user' }
)
```
These updates contain information about which records were added, removed, and updated. See [HistoryEntry](?)
The `scope` filter can be used to listen for changes to a specific record scope, e.g. `document`, `session`, `presence`, or `all`.
The `source` filter can be used to listen for changes from a specific source, e.g. `user`, `remote`, or `all`. (See [Store#mergeRemoteChanges](?) for more information on remote changes.)
Note that these incremental updates do not include the schema version. You should make sure that you keep a record of the latest schema version for your snapshots.
You can get the schema version by calling `editor.store.schema.serialize()` and the returned value can replace the `schema` property in the snapshot next time you need to load a snapshot. The schema does not change at runtime so you only need to do this once per session.
## Handling remote changes
If you need to synchronize changes from a remote source, e.g. a multiplayer backend, you can use the `editor.store.mergeRemoteChanges` method. This will 'tag' the changes with the `source` property as `'remote'` so you can filter them out when listening for changes.
```ts
myRemoteSource.on('change', (changes) => {
editor.store.mergeRemoteChanges(() => {
changes.forEach((change) => {
// Apply the changes to the store
editor.store.put(/* ... */)
})
})
})
```
## Migrations
Tldraw uses migrations to bring data from old snapshots up to date. These run automatically when calling `editor.store.loadSnapshot`.
### Running migrations manually
If you need to run migrations on a snapshot without loading it into the store, you can call [StoreSchema#migrateStoreSnapshot](?) directly.
```ts
import { createTLSchema } from 'tldraw'
const snapshot = await getSnapshotFromSomewhere()
const migrationResult = createTLSchema().migrateStoreSnapshot(snapshot)
if (migrationResult.type === 'success') {
console.log('Migrated snapshot', migrationResult.value)
} else {
console.error('Migration failed', migrationResult.reason)
}
```
### Custom migrations
Tldraw supports a couple of ways of adding custom data types to the tldraw store:
- [Custom shape types](/docs/shapes#Custom-shapes-1)
- [`meta` properties](/docs/shapes#Meta-information) on all of our built-in record types.
You might wish to migrate your custom data types over time as you make changes to them.
To enable this, tldraw provides two ways to add custom migrations:
1. **Shape props migrations**, specifically for migrating the shape.props objects on your custom shape types.
2. **The `migrations` config option**, which is more general purpose but much less commonly needed. This will allow you to migrate any data in the store.
#### Shape props migrations
If you have a custom shape type, you can define a `migrations` property on the shape util class. Use the `createShapePropsMigrationSequence` helper to define this property.
```ts
import { createShapePropsMigrationSequence, createShapePropsMigrationIds, ShapeUtil } from 'tldraw'
// Migrations must start a 1 and be sequential integers.
const Versions = createShapePropMigrationIds('custom-shape', {
AddColor: 1,
})
class MyCustomShapeUtil extends ShapeUtil {
static type = 'custom-shape'
static migrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddColor,
up(props) {
// set the default color
props.color = 'black'
},
},
],
})
// ...
}
```
#### The `migrations` config option
First create a set of migration ids.
```ts
import { createMigrationIds } from 'tldraw'
// The first argument is a unique namespace for your migration sequence.
// We recommend using a reverse domain name, e.g. we use 'com.tldraw.foo.bar'
const SEQUENCE_ID = 'com.example.my-app'
const Versions = createMigrationIds(SEQUENCE_ID, {
// Migrations must start at 1 and be sequential integers.
AddColor: 1,
})
```
Then create a migration sequence.
```ts
import { createMigrationSequence, isShape } from 'tldraw'
const myMigrations = createMigrationSequence({
sequenceId: SEQUENCE_ID,
sequence: [
{
id: Versions.AddColor,
// Scope can be one of
// - 'store' to have the up function called on the whole snapshot at once
// - 'record' to have the up function called on each record individually
scope: 'record',
// if scope is 'record', you can filter which records the migration runs on
filter: (record) => isShape(record) && record.type === 'custom-shape',
up(record) {
record.props.color = 'black'
},
},
],
})
```
And finally pass your migrations in to tldraw via the `migrations` config option. There are a few places where you might need to do this, depending on how specialized your usage of Tldraw is:
```tsx
// When rendering the Tldraw component
<Tldraw
...
migrations={[myMigrations]}
/>
// or when creating the store
store = createTLStore({
...
migrations: [myMigrations],
})
// or when creating the schema
schema = createTLSchema({
...
migrations: [myMigrations],
})
```
### Updating legacy shape migrations (defineMigrations)
You can convert your legacy migrations to the new migrations format by the following process:
1. Wrap your version numbers in `createShapePropsMigrationIds`
```diff
- const Versions = {
+ const Versions = createShapePropMigrationIds('custom-shape', {
AddColor: 1
- }
+ })
```
2. Replace your `defineMigrations` call with `createShapePropsMigrationSequence`
```ts
const migrations = defineMigrations({
currentVersion: Versions.AddColor,
migrators: {
[Versions.AddColor]: {
up: (shape: any) => ({ ...shape, props: { ...shape.props, color: 'black' } }),
down: ({ props: { color, ...props }, ...shape }: any) => ({ ...shape, props }),
},
},
})
```
Becomes
```ts
const migrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddColor,
// [!!!] You no longer have access to the top-level shape object.
// Only the shape.props object is passed in to the migrator function.
up(props) {
// [!!!] You no longer need to return a new copy of the shape object.
// Instead, you can modify the props object in place.
props.color = 'black'
},
// [!!!] You no longer need to specify a down migration.
},
],
})
```
## Examples
### Local persistence
Tldraw ships with a local-only sync engine based on `IndexedDb` and `BroadcastChannel` called [`TLLocalSyncClient`](https://github.com/tldraw/tldraw/blob/main/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts).
### Tldraw.com sync engine
[tldraw.com/r](https://tldraw.com/r) currently uses a simple custom sync engine based on a push/pull/rebase-style algorithm.
It can be found [here](https://github.com/tldraw/tldraw/tree/main/packages/tlsync).
It was optimized for Cloudflare workers with [DurableObjects](https://developers.cloudflare.com/durable-objects/)
We don't suggest using our code directly yet, but it may serve as a good reference for your own sync engine.
### Yjs sync example
We created a [tldraw-yjs example](https://github.com/tldraw/tldraw-yjs-example) to illustrate a way of using yjs with the tldraw SDK.
### Shape props migrations example
Our [custom-config example](/examples/shapes/tools/custom-config) shows how to add custom shape props migrations to the tldraw store.
### Meta properties migrations example
Our [custom-config example](/examples/shapes/tools/custom-config) shows how to add custom migrations to the tldraw store.

View file

@ -239,4 +239,6 @@ You can turn on `pointer-events` to allow users to interact inside of the shape.
You can make shapes "editable" to help decide when they're interactive or not. You can make shapes "editable" to help decide when they're interactive or not.
...and more! ### Migrations
You can add migrations for your shape props by adding a `migrations` property to your shape's util class. See [the persistence docs](/docs/persistence#Shape-props-migrations) for more information.

View file

@ -140,7 +140,7 @@ describe(ClientWebSocketAdapter, () => {
const message: TLSocketClientSentEvent<TLRecord> = { const message: TLSocketClientSentEvent<TLRecord> = {
type: 'connect', type: 'connect',
connectRequestId: 'test', connectRequestId: 'test',
schema: { schemaVersion: 0, storeVersion: 0, recordVersions: {} }, schema: { schemaVersion: 1, storeVersion: 0, recordVersions: {} },
protocolVersion: TLSYNC_PROTOCOL_VERSION, protocolVersion: TLSYNC_PROTOCOL_VERSION,
lastServerClock: 0, lastServerClock: 0,
} }

View file

@ -1,4 +1,4 @@
import { Editor, Tldraw } from 'tldraw' import { Editor, TLStoreSnapshot, Tldraw } from 'tldraw'
import { PlayingCardTool } from './PlayingCardShape/playing-card-tool' import { PlayingCardTool } from './PlayingCardShape/playing-card-tool'
import { PlayingCardUtil } from './PlayingCardShape/playing-card-util' import { PlayingCardUtil } from './PlayingCardShape/playing-card-util'
import snapshot from './snapshot.json' import snapshot from './snapshot.json'
@ -27,7 +27,7 @@ export default function BoundsSnappingShapeExample() {
// [c] // [c]
onMount={handleMount} onMount={handleMount}
// [d] // [d]
snapshot={snapshot} snapshot={snapshot as TLStoreSnapshot}
/> />
</div> </div>
) )

View file

@ -1,21 +1,26 @@
import { defineMigrations } from 'tldraw' import { createShapePropsMigrationIds } from '@tldraw/tlschema/src/records/TLShape'
import { createShapePropsMigrationSequence } from 'tldraw'
const versions = createShapePropsMigrationIds(
// this must match the shape type in the shape definition
'card',
{
AddSomeProperty: 1,
}
)
// Migrations for the custom card shape (optional but very helpful) // Migrations for the custom card shape (optional but very helpful)
export const cardShapeMigrations = defineMigrations({ export const cardShapeMigrations = createShapePropsMigrationSequence({
currentVersion: 1, sequence: [
migrators: { {
1: { id: versions.AddSomeProperty,
// for example, removing a property from the shape up(props) {
up(shape) { // it is safe to mutate the props object here
const migratedUpShape = { ...shape } props.someProperty = 'some value'
delete migratedUpShape._somePropertyToRemove
return migratedUpShape
}, },
down(shape) { down(props) {
const migratedDownShape = { ...shape } delete props.someProperty
migratedDownShape._somePropertyToRemove = 'some value'
return migratedDownShape
}, },
}, },
}, ],
}) })

View file

@ -1,5 +1,14 @@
import { useState } from 'react' import { useState } from 'react'
import { Box, Editor, StoreSnapshot, TLPageId, TLRecord, Tldraw, TldrawImage } from 'tldraw' import {
Box,
Editor,
StoreSnapshot,
TLPageId,
TLRecord,
TLStoreSnapshot,
Tldraw,
TldrawImage,
} from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
import initialSnapshot from './snapshot.json' import initialSnapshot from './snapshot.json'
@ -7,7 +16,9 @@ import initialSnapshot from './snapshot.json'
export default function TldrawImageExample() { export default function TldrawImageExample() {
const [editor, setEditor] = useState<Editor>() const [editor, setEditor] = useState<Editor>()
const [snapshot, setSnapshot] = useState<StoreSnapshot<TLRecord>>(initialSnapshot) const [snapshot, setSnapshot] = useState<StoreSnapshot<TLRecord>>(
initialSnapshot as TLStoreSnapshot
)
const [currentPageId, setCurrentPageId] = useState<TLPageId | undefined>() const [currentPageId, setCurrentPageId] = useState<TLPageId | undefined>()
const [showBackground, setShowBackground] = useState(true) const [showBackground, setShowBackground] = useState(true)
const [isDarkMode, setIsDarkMode] = useState(false) const [isDarkMode, setIsDarkMode] = useState(false)

View file

@ -0,0 +1,74 @@
import { Tldraw, createMigrationIds, createMigrationSequence } from 'tldraw'
import 'tldraw/tldraw.css'
import { snapshot } from './snapshot'
import { components } from './ui-overrides'
/**
* This example demonstrates how to add custom migrations for `meta` properties, or any other
* data in your store snapshots.
*
* If you have a custom shape type and you want to add migrations for its `props` object,
* there is a simpler dedicated API for that. Check out [the docs](https://tldraw.dev/docs/persistence#Shape-props-migrations) for more info.
*/
/**
* Let's say you added some page metadata, e.g. to allow setting the background color of a page independently.
*/
interface _PageMetaV1 {
backgroundTheme?: 'red' | 'blue' | 'green' | 'purple'
}
/**
* And then perhaps later on you decided to remove support for 'purple' because it's an ugly color.
* So all purple pages will become blue.
*/
export interface PageMetaV2 {
backgroundTheme?: 'red' | 'blue' | 'green'
}
/**
* You would then create a migration to update the metadata from v1 to v2.
*/
// First pick a 'sequence id' that is unique to your app
const sequenceId = 'com.example.my-app'
// Then create a 'migration id' for each version of your metadata
const versions = createMigrationIds(sequenceId, {
// the numbers must start at 1 and increment by 1
RemovePurple: 1,
})
const migrations = createMigrationSequence({
sequenceId,
sequence: [
{
id: versions.RemovePurple,
// `scope: 'record` tells the schema to call this migration on individual records.
// `scope: 'store'` would call it on the entire snapshot, to allow for actions like deleting/creating records.
scope: 'record',
// When `scope` is 'record', you can specify a filter function to only apply the migration to records that match the filter.
filter: (record) => record.typeName === 'page',
// This up function will be called on all records that match the filter
up(page: any) {
if (page.meta.backgroundTheme === 'purple') {
page.meta.backgroundTheme = 'blue'
page.name += ' (was purple)'
}
},
},
],
})
export default function MetaMigrationsExample() {
return (
<div className="tldraw__editor">
<Tldraw
// Pass in the custom migrations
migrations={[migrations]}
// When you load a snapshot from a previous version, the migrations will be applied automatically
snapshot={snapshot}
// This adds a dropdown to the canvas for changing the backgroundTheme property
components={components}
/>
</div>
)
}

View file

@ -0,0 +1,12 @@
---
title: Meta Migrations
component: ./MetaMigrations.tsx
category: data/assets
priority: 6
---
Create custom migrations for `meta` properties.
---
You can add arbitrary data migrations for tldraw snapshot data. This is mainly useful for updating the `meta` property of a record as your data types evolve.

View file

@ -0,0 +1,57 @@
import { TLStoreSnapshot } from 'tldraw'
export const snapshot = {
store: {
'document:document': {
gridSize: 10,
name: '',
meta: {},
id: 'document:document',
typeName: 'document',
},
'page:red': {
meta: {
backgroundTheme: 'red',
},
id: 'page:red',
name: 'Red',
index: 'a1',
typeName: 'page',
},
'page:green': {
meta: {
backgroundTheme: 'green',
},
id: 'page:green',
name: 'Green',
index: 'a2',
typeName: 'page',
},
'page:blue': {
meta: {
backgroundTheme: 'blue',
},
id: 'page:blue',
name: 'Blue',
index: 'a3',
typeName: 'page',
},
'page:purple': {
meta: {
backgroundTheme: 'purple',
},
id: 'page:purple',
name: 'Purple',
index: 'a0',
typeName: 'page',
},
},
schema: {
schemaVersion: 2,
sequences: {
'com.tldraw.store': 4,
'com.tldraw.document': 2,
'com.tldraw.page': 1,
},
},
} as TLStoreSnapshot

View file

@ -0,0 +1,41 @@
import { useLayoutEffect } from 'react'
import { TLComponents, track, useEditor } from 'tldraw'
import { PageMetaV2 } from './MetaMigrations'
export const components: TLComponents = {
TopPanel: track(() => {
const editor = useEditor()
const currentPage = editor.getCurrentPage()
const meta: PageMetaV2 = currentPage.meta
useLayoutEffect(() => {
const elem = document.querySelector('.tl-background') as HTMLElement
if (!elem) return
elem.style.backgroundColor = meta.backgroundTheme ?? 'unset'
}, [meta.backgroundTheme])
return (
<span style={{ pointerEvents: 'all', padding: '5px 15px', margin: 10, fontSize: 18 }}>
bg: &nbsp;
<select
value={meta.backgroundTheme ?? 'none'}
onChange={(e) => {
if (e.currentTarget.value === 'none') {
editor.updatePage({ ...currentPage, meta: {} })
} else {
editor.updatePage({
...currentPage,
meta: { backgroundTheme: e.currentTarget.value },
})
}
}}
>
<option value="none">None</option>
<option value="red">Red</option>
<option value="blue">Blue</option>
<option value="green">Green</option>
</select>
</span>
)
}),
}

View file

@ -84,7 +84,7 @@ deletes that shape.
[1] [1]
This is where we define our state node by extending the StateNode class. Since This is where we define our state node by extending the StateNode class. Since
there are no children states We can simply give it an id and define methods we there are no children states We can give it an id and define methods we
want to override to handle events. want to override to handle events.

View file

@ -4,6 +4,7 @@ import {
T, T,
TLBaseShape, TLBaseShape,
TLOnResizeHandler, TLOnResizeHandler,
TLStoreSnapshot,
Tldraw, Tldraw,
resizeBox, resizeBox,
} from 'tldraw' } from 'tldraw'
@ -94,7 +95,7 @@ export default function ShapeWithMigrationsExample() {
// Pass in the array of custom shape classes // Pass in the array of custom shape classes
shapeUtils={customShapeUtils} shapeUtils={customShapeUtils}
// Use a snapshot to load an old version of the shape // Use a snapshot to load an old version of the shape
snapshot={snapshot} snapshot={snapshot as TLStoreSnapshot}
/> />
</div> </div>
) )

View file

@ -1,6 +1,8 @@
import { Tldraw } from 'tldraw' import { TLStoreSnapshot, Tldraw } from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
import jsonSnapshot from './snapshot.json' import _jsonSnapshot from './snapshot.json'
const jsonSnapshot = _jsonSnapshot as TLStoreSnapshot
// There's a guide at the bottom of this file! // There's a guide at the bottom of this file!

View file

@ -21,7 +21,7 @@ module.exports = {
}, },
], ],
}, },
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', testRegex: '.+\\.(test|spec)\\.(jsx?|tsx?)$',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
modulePathIgnorePatterns: [ modulePathIgnorePatterns: [
'<rootDir>/test/__fixtures__', '<rootDir>/test/__fixtures__',

View file

@ -71,7 +71,7 @@
] ]
}, },
"devDependencies": { "devDependencies": {
"@microsoft/api-extractor": "^7.41.0", "@microsoft/api-extractor": "^7.43.1",
"@next/eslint-plugin-next": "^13.3.0", "@next/eslint-plugin-next": "^13.3.0",
"@swc/core": "^1.3.55", "@swc/core": "^1.3.55",
"@swc/jest": "^0.2.34", "@swc/jest": "^0.2.34",

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { SerializedStore, Store, StoreSnapshot } from '@tldraw/store' import { MigrationSequence, SerializedStore, Store, StoreSnapshot } from '@tldraw/store'
import { TLRecord, TLStore } from '@tldraw/tlschema' import { TLRecord, TLStore } from '@tldraw/tlschema'
import { Expand, Required, annotateError } from '@tldraw/utils' import { Expand, Required, annotateError } from '@tldraw/utils'
import React, { import React, {
@ -49,6 +49,7 @@ export type TldrawEditorProps = Expand<
} }
| { | {
store?: undefined store?: undefined
migrations?: readonly MigrationSequence[]
snapshot?: StoreSnapshot<TLRecord> snapshot?: StoreSnapshot<TLRecord>
initialData?: SerializedStore<TLRecord> initialData?: SerializedStore<TLRecord>
persistenceKey?: string persistenceKey?: string

View file

@ -1,11 +1,5 @@
import { Signal, computed, transact } from '@tldraw/state' import { Signal, computed, transact } from '@tldraw/state'
import { import { RecordsDiff, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
RecordsDiff,
UnknownRecord,
defineMigrations,
migrate,
squashRecordDiffs,
} from '@tldraw/store'
import { import {
CameraRecordType, CameraRecordType,
InstancePageStateRecordType, InstancePageStateRecordType,
@ -22,6 +16,7 @@ import {
getFromSessionStorage, getFromSessionStorage,
objectMapFromEntries, objectMapFromEntries,
setInSessionStorage, setInSessionStorage,
structuredClone,
} from '@tldraw/utils' } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId' import { uniqueId } from '../utils/uniqueId'
@ -79,7 +74,18 @@ const Versions = {
Initial: 0, Initial: 0,
} as const } as const
const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Versions.Initial const CURRENT_SESSION_STATE_SNAPSHOT_VERSION = Math.max(...Object.values(Versions))
function migrate(snapshot: any) {
if (snapshot.version < Versions.Initial) {
// initial version
// noop
}
// add further migrations down here. see TLUserPreferences.ts for an example.
// finally
snapshot.version = CURRENT_SESSION_STATE_SNAPSHOT_VERSION
}
/** /**
* The state of the editor instance, not including any document state. * The state of the editor instance, not including any document state.
@ -124,10 +130,6 @@ const sessionStateSnapshotValidator: T.Validator<TLSessionStateSnapshot> = T.obj
), ),
}) })
const sessionStateSnapshotMigrations = defineMigrations({
currentVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
})
function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null { function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateSnapshot | null {
if (!state || typeof state !== 'object') { if (!state || typeof state !== 'object') {
console.warn('Invalid instance state') console.warn('Invalid instance state')
@ -137,27 +139,17 @@ function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateS
console.warn('No version in instance state') console.warn('No version in instance state')
return null return null
} }
const result = migrate<TLSessionStateSnapshot>({ if (state.version !== CURRENT_SESSION_STATE_SNAPSHOT_VERSION) {
value: state, state = structuredClone(state)
fromVersion: state.version, migrate(state)
toVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
migrations: sessionStateSnapshotMigrations,
})
if (result.type === 'error') {
console.warn(result.reason)
return null
} }
const value = { ...result.value, version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION }
try { try {
sessionStateSnapshotValidator.validate(value) return sessionStateSnapshotValidator.validate(state)
} catch (e) { } catch (e) {
console.warn(e) console.warn(e)
return null return null
} }
return value
} }
/** /**

View file

@ -1,7 +1,6 @@
import { atom } from '@tldraw/state' import { atom } from '@tldraw/state'
import { defineMigrations, migrate } from '@tldraw/store'
import { getDefaultTranslationLocale } from '@tldraw/tlschema' import { getDefaultTranslationLocale } from '@tldraw/tlschema'
import { getFromLocalStorage, setInLocalStorage } from '@tldraw/utils' import { getFromLocalStorage, setInLocalStorage, structuredClone } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId' import { uniqueId } from '../utils/uniqueId'
@ -55,66 +54,28 @@ const Versions = {
AddExcalidrawSelectMode: 5, AddExcalidrawSelectMode: 5,
} as const } as const
const userMigrations = defineMigrations({ const CURRENT_VERSION = Math.max(...Object.values(Versions))
currentVersion: Versions.AddExcalidrawSelectMode,
migrators: { function migrateSnapshot(data: { version: number; user: any }) {
[Versions.AddAnimationSpeed]: { if (data.version < Versions.AddAnimationSpeed) {
up: (user) => { data.user.animationSpeed = 1
return { }
...user, if (data.version < Versions.AddIsSnapMode) {
animationSpeed: 1, data.user.isSnapMode = false
} }
}, if (data.version < Versions.MakeFieldsNullable) {
down: ({ animationSpeed: _, ...user }) => { // noop
return user }
}, if (data.version < Versions.AddEdgeScrollSpeed) {
}, data.user.edgeScrollSpeed = 1
[Versions.AddIsSnapMode]: { }
up: (user: TLUserPreferences) => { if (data.version < Versions.AddExcalidrawSelectMode) {
return { ...user, isSnapMode: false } data.user.isWrapMode = false
}, }
down: ({ isSnapMode: _, ...user }: TLUserPreferences) => {
return user // finally
}, data.version = CURRENT_VERSION
}, }
[Versions.MakeFieldsNullable]: {
up: (user: TLUserPreferences) => {
return user
},
down: (user: TLUserPreferences) => {
return {
id: user.id,
name: user.name ?? defaultUserPreferences.name,
locale: user.locale ?? defaultUserPreferences.locale,
color: user.color ?? defaultUserPreferences.color,
animationSpeed: user.animationSpeed ?? defaultUserPreferences.animationSpeed,
isDarkMode: user.isDarkMode ?? defaultUserPreferences.isDarkMode,
isSnapMode: user.isSnapMode ?? defaultUserPreferences.isSnapMode,
isWrapMode: user.isWrapMode ?? defaultUserPreferences.isWrapMode,
}
},
},
[Versions.AddEdgeScrollSpeed]: {
up: (user: TLUserPreferences) => {
return {
...user,
edgeScrollSpeed: 1,
}
},
down: ({ edgeScrollSpeed: _, ...user }: TLUserPreferences) => {
return user
},
},
[Versions.AddExcalidrawSelectMode]: {
up: (user: TLUserPreferences) => {
return { ...user, isWrapMode: false }
},
down: ({ isWrapMode: _, ...user }: TLUserPreferences) => {
return user
},
},
},
})
/** @internal */ /** @internal */
export const USER_COLORS = [ export const USER_COLORS = [
@ -171,7 +132,7 @@ export function getFreshUserPreferences(): TLUserPreferences {
} }
} }
function migrateUserPreferences(userData: unknown) { function migrateUserPreferences(userData: unknown): TLUserPreferences {
if (userData === null || typeof userData !== 'object') { if (userData === null || typeof userData !== 'object') {
return getFreshUserPreferences() return getFreshUserPreferences()
} }
@ -180,24 +141,15 @@ function migrateUserPreferences(userData: unknown) {
return getFreshUserPreferences() return getFreshUserPreferences()
} }
const migrationResult = migrate<TLUserPreferences>({ const snapshot = structuredClone(userData) as any
value: userData.user,
fromVersion: userData.version,
toVersion: userMigrations.currentVersion ?? 0,
migrations: userMigrations,
})
if (migrationResult.type === 'error') { migrateSnapshot(snapshot)
return getFreshUserPreferences()
}
try { try {
userTypeValidator.validate(migrationResult.value) return userTypeValidator.validate(snapshot.user)
} catch (e) { } catch (e) {
return getFreshUserPreferences() return getFreshUserPreferences()
} }
return migrationResult.value
} }
function loadUserPreferences(): TLUserPreferences { function loadUserPreferences(): TLUserPreferences {
@ -212,7 +164,10 @@ const globalUserPreferences = atom<TLUserPreferences | null>('globalUserData', n
function storeUserPreferences() { function storeUserPreferences() {
setInLocalStorage( setInLocalStorage(
USER_DATA_KEY, USER_DATA_KEY,
JSON.stringify({ version: userMigrations.currentVersion, user: globalUserPreferences.get() }) JSON.stringify({
version: CURRENT_VERSION,
user: globalUserPreferences.get(),
})
) )
} }
@ -253,7 +208,7 @@ function broadcastUserPreferencesChange() {
origin: getBroadcastOrigin(), origin: getBroadcastOrigin(),
data: { data: {
user: getUserPreferences(), user: getUserPreferences(),
version: userMigrations.currentVersion, version: CURRENT_VERSION,
}, },
} satisfies UserChangeBroadcastMessage) } satisfies UserChangeBroadcastMessage)
} }

View file

@ -1,4 +1,4 @@
import { HistoryEntry, SerializedStore, Store, StoreSchema } from '@tldraw/store' import { HistoryEntry, MigrationSequence, SerializedStore, Store, StoreSchema } from '@tldraw/store'
import { import {
SchemaShapeInfo, SchemaShapeInfo,
TLRecord, TLRecord,
@ -15,7 +15,7 @@ export type TLStoreOptions = {
initialData?: SerializedStore<TLRecord> initialData?: SerializedStore<TLRecord>
defaultName?: string defaultName?: string
} & ( } & (
| { shapeUtils?: readonly TLAnyShapeUtilConstructor[] } | { shapeUtils?: readonly TLAnyShapeUtilConstructor[]; migrations?: readonly MigrationSequence[] }
| { schema?: StoreSchema<TLRecord, TLStoreProps> } | { schema?: StoreSchema<TLRecord, TLStoreProps> }
) )
@ -38,6 +38,7 @@ export function createTLStore({ initialData, defaultName = '', ...rest }: TLStor
shapes: currentPageShapesToShapeMap( shapes: currentPageShapesToShapeMap(
checkShapesAndAddCore('shapeUtils' in rest && rest.shapeUtils ? rest.shapeUtils : []) checkShapesAndAddCore('shapeUtils' in rest && rest.shapeUtils ? rest.shapeUtils : [])
), ),
migrations: 'migrations' in rest ? rest.migrations : [],
}) })
return new Store({ return new Store({

View file

@ -217,24 +217,6 @@ export class Editor extends EventEmitter<TLEventMap> {
const allShapeUtils = checkShapesAndAddCore(shapeUtils) const allShapeUtils = checkShapesAndAddCore(shapeUtils)
const shapeTypesInSchema = new Set(
Object.keys(store.schema.types.shape.migrations.subTypeMigrations!)
)
for (const shapeUtil of allShapeUtils) {
if (!shapeTypesInSchema.has(shapeUtil.type)) {
throw Error(
`Editor and store have different shapes: "${shapeUtil.type}" was passed into the editor but not the schema`
)
}
shapeTypesInSchema.delete(shapeUtil.type)
}
if (shapeTypesInSchema.size > 0) {
throw Error(
`Editor and store have different shapes: "${
[...shapeTypesInSchema][0]
}" is present in the store schema but not provided to the editor`
)
}
const _shapeUtils = {} as Record<string, ShapeUtil<any>> const _shapeUtils = {} as Record<string, ShapeUtil<any>>
const _styleProps = {} as Record<string, Map<StyleProp<unknown>, string>> const _styleProps = {} as Record<string, Map<StyleProp<unknown>, string>>
const allStylesById = new Map<string, StyleProp<unknown>>() const allStylesById = new Map<string, StyleProp<unknown>>()

View file

@ -1,6 +1,13 @@
/* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-unused-vars */
import { Migrations } from '@tldraw/store' import { LegacyMigrations, MigrationSequence } from '@tldraw/store'
import { ShapeProps, TLHandle, TLShape, TLShapePartial, TLUnknownShape } from '@tldraw/tlschema' import {
ShapeProps,
TLHandle,
TLShape,
TLShapePartial,
TLShapePropsMigrations,
TLUnknownShape,
} from '@tldraw/tlschema'
import { ReactElement } from 'react' import { ReactElement } from 'react'
import { Box } from '../../primitives/Box' import { Box } from '../../primitives/Box'
import { Vec } from '../../primitives/Vec' import { Vec } from '../../primitives/Vec'
@ -19,7 +26,7 @@ export interface TLShapeUtilConstructor<
new (editor: Editor): U new (editor: Editor): U
type: T['type'] type: T['type']
props?: ShapeProps<T> props?: ShapeProps<T>
migrations?: Migrations migrations?: LegacyMigrations | TLShapePropsMigrations | MigrationSequence
} }
/** @public */ /** @public */
@ -35,7 +42,7 @@ export interface TLShapeUtilCanvasSvgDef {
export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> { export abstract class ShapeUtil<Shape extends TLUnknownShape = TLUnknownShape> {
constructor(public editor: Editor) {} constructor(public editor: Editor) {}
static props?: ShapeProps<TLUnknownShape> static props?: ShapeProps<TLUnknownShape>
static migrations?: Migrations static migrations?: LegacyMigrations | TLShapePropsMigrations
/** /**
* The type of the shape util, which should match the shape's type. * The type of the shape util, which should match the shape's type.

View file

@ -69,7 +69,7 @@ test('the client connects on instantiation, announcing its schema', async () =>
expect(channel.postMessage).toHaveBeenCalledTimes(1) expect(channel.postMessage).toHaveBeenCalledTimes(1)
const [msg] = channel.postMessage.mock.calls[0] const [msg] = channel.postMessage.mock.calls[0]
expect(msg).toMatchObject({ type: 'announce', schema: { recordVersions: {} } }) expect(msg).toMatchObject({ type: 'announce', schema: {} })
}) })
test('when a client receives an announce with a newer schema version it reloads itself', async () => { test('when a client receives an announce with a newer schema version it reloads itself', async () => {

View file

@ -1,11 +1,5 @@
import { Signal, transact } from '@tldraw/state' import { Signal, transact } from '@tldraw/state'
import { import { RecordsDiff, SerializedSchema, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
RecordsDiff,
SerializedSchema,
UnknownRecord,
compareSchemas,
squashRecordDiffs,
} from '@tldraw/store'
import { TLStore } from '@tldraw/tlschema' import { TLStore } from '@tldraw/tlschema'
import { assert } from '@tldraw/utils' import { assert } from '@tldraw/utils'
import { import {
@ -183,6 +177,7 @@ export class TLLocalSyncClient {
data.sessionStateSnapshot ?? extractSessionStateFromLegacySnapshot(documentSnapshot) data.sessionStateSnapshot ?? extractSessionStateFromLegacySnapshot(documentSnapshot)
const migrationResult = this.store.schema.migrateStoreSnapshot({ const migrationResult = this.store.schema.migrateStoreSnapshot({
store: documentSnapshot, store: documentSnapshot,
// eslint-disable-next-line deprecation/deprecation
schema: data.schema ?? this.store.schema.serializeEarliestVersion(), schema: data.schema ?? this.store.schema.serializeEarliestVersion(),
}) })
@ -211,11 +206,9 @@ export class TLLocalSyncClient {
const msg = data as Message const msg = data as Message
// if their schema is earlier than ours, we need to tell them so they can refresh // if their schema is earlier than ours, we need to tell them so they can refresh
// if their schema is later than ours, we need to refresh // if their schema is later than ours, we need to refresh
const comparison = compareSchemas( const res = this.store.schema.getMigrationsSince(msg.schema)
this.serializedSchema,
msg.schema ?? this.store.schema.serializeEarliestVersion() if (!res.ok) {
)
if (comparison === -1) {
// we are older, refresh // we are older, refresh
// but add a safety check to make sure we don't get in an infinite loop // but add a safety check to make sure we don't get in an infinite loop
const timeSinceInit = Date.now() - this.initTime const timeSinceInit = Date.now() - this.initTime
@ -232,7 +225,7 @@ export class TLLocalSyncClient {
this.isReloading = true this.isReloading = true
window?.location?.reload?.() window?.location?.reload?.()
return return
} else if (comparison === 1) { } else if (res.value.length > 0) {
// they are older, tell them to refresh and not write any more data // they are older, tell them to refresh and not write any more data
this.debug('telling them to reload') this.debug('telling them to reload')
this.channel.postMessage({ type: 'announce', schema: this.serializedSchema }) this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })

View file

@ -1,7 +1,7 @@
{ {
"metadata": { "metadata": {
"toolPackage": "@microsoft/api-extractor", "toolPackage": "@microsoft/api-extractor",
"toolVersion": "7.41.0", "toolVersion": "7.43.1",
"schemaVersion": 1011, "schemaVersion": 1011,
"oldestForwardsCompatibleVersion": 1001, "oldestForwardsCompatibleVersion": 1001,
"tsdocConfig": { "tsdocConfig": {

View file

@ -1,7 +1,7 @@
{ {
"metadata": { "metadata": {
"toolPackage": "@microsoft/api-extractor", "toolPackage": "@microsoft/api-extractor",
"toolVersion": "7.41.0", "toolVersion": "7.43.1",
"schemaVersion": 1011, "schemaVersion": 1011,
"oldestForwardsCompatibleVersion": 1001, "oldestForwardsCompatibleVersion": 1001,
"tsdocConfig": { "tsdocConfig": {

View file

@ -1,15 +1,3 @@
let didWarnDotValue = false
// remove this once we've removed all getters from our app
export function logDotValueWarning() {
if (didWarnDotValue) return
didWarnDotValue = true
console.warn(
'Using Signal.value is deprecated and will be removed in the near future. Please use Signal.get() instead.'
)
}
let didWarnComputedGetter = false let didWarnComputedGetter = false
export function logComputedGetterWarning() { export function logComputedGetterWarning() {

View file

@ -6,6 +6,7 @@
import { Atom } from '@tldraw/state'; import { Atom } from '@tldraw/state';
import { Computed } from '@tldraw/state'; import { Computed } from '@tldraw/state';
import { Result } from '@tldraw/utils';
// @public // @public
export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>; export type AllRecords<T extends Store<any>> = ExtractR<ExtractRecordType<T>>;
@ -27,45 +28,52 @@ export type CollectionDiff<T> = {
removed?: Set<T>; removed?: Set<T>;
}; };
// @public (undocumented)
export function compareRecordVersions(a: RecordVersion, b: RecordVersion): -1 | 0 | 1;
// @public (undocumented)
export const compareSchemas: (a: SerializedSchema, b: SerializedSchema) => -1 | 0 | 1;
// @public // @public
export type ComputedCache<Data, R extends UnknownRecord> = { export type ComputedCache<Data, R extends UnknownRecord> = {
get(id: IdOf<R>): Data | undefined; get(id: IdOf<R>): Data | undefined;
}; };
// @public
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(sequenceId: ID, versions: Versions): {
[K in keyof Versions]: `${ID}/${Versions[K]}`;
};
// @public
export function createMigrationSequence({ sequence, sequenceId, retroactive, }: {
retroactive?: boolean;
sequence: Array<Migration | StandaloneDependsOn>;
sequenceId: string;
}): MigrationSequence;
// @internal (undocumented)
export function createRecordMigrationSequence(opts: {
filter?: (record: UnknownRecord) => boolean;
recordType: string;
retroactive?: boolean;
sequence: Omit<Extract<Migration, {
scope: 'record';
}>, 'scope'>[];
sequenceId: string;
}): MigrationSequence;
// @public // @public
export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: { export function createRecordType<R extends UnknownRecord>(typeName: R['typeName'], config: {
migrations?: Migrations;
validator?: StoreValidator<R>;
scope: RecordScope; scope: RecordScope;
validator?: StoreValidator<R>;
}): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>; }): RecordType<R, keyof Omit<R, 'id' | 'typeName'>>;
// @public (undocumented) // @public @deprecated (undocumented)
export function defineMigrations<FirstVersion extends EMPTY_SYMBOL | number = EMPTY_SYMBOL, CurrentVersion extends EMPTY_SYMBOL | Exclude<number, 0> = EMPTY_SYMBOL>(opts: { export function defineMigrations(opts: {
firstVersion?: CurrentVersion extends number ? FirstVersion : never; currentVersion?: number;
currentVersion?: CurrentVersion; firstVersion?: number;
migrators?: CurrentVersion extends number ? FirstVersion extends number ? CurrentVersion extends FirstVersion ? { migrators?: Record<number, LegacyMigration>;
[version in Exclude<Range_2<1, CurrentVersion>, 0>]: Migration;
} : {
[version in Exclude<Range_2<FirstVersion, CurrentVersion>, FirstVersion>]: Migration;
} : {
[version in Exclude<Range_2<1, CurrentVersion>, 0>]: Migration;
} : never;
subTypeKey?: string; subTypeKey?: string;
subTypeMigrations?: Record<string, BaseMigrationsInfo>; subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>;
}): Migrations; }): LegacyMigrations;
// @public // @public
export function devFreeze<T>(object: T): T; export function devFreeze<T>(object: T): T;
// @public (undocumented)
export function getRecordVersion(record: UnknownRecord, serializedSchema: SerializedSchema): RecordVersion;
// @public // @public
export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = { export type HistoryEntry<R extends UnknownRecord = UnknownRecord> = {
changes: RecordsDiff<R>; changes: RecordsDiff<R>;
@ -83,35 +91,42 @@ export class IncrementalSetConstructor<T> {
add(item: T): void; add(item: T): void;
// @public // @public
get(): { get(): {
value: Set<T>;
diff: CollectionDiff<T>; diff: CollectionDiff<T>;
value: Set<T>;
} | undefined; } | undefined;
// @public // @public
remove(item: T): void; remove(item: T): void;
} }
// @public (undocumented) // @public (undocumented)
export function migrate<T>({ value, migrations, fromVersion, toVersion, }: { export type LegacyMigration<Before = any, After = any> = {
value: unknown;
migrations: Migrations;
fromVersion: number;
toVersion: number;
}): MigrationResult<T>;
// @public (undocumented)
export function migrateRecord<R extends UnknownRecord>({ record, migrations, fromVersion, toVersion, }: {
record: unknown;
migrations: Migrations;
fromVersion: number;
toVersion: number;
}): MigrationResult<R>;
// @public (undocumented)
export type Migration<Before = any, After = any> = {
up: (oldState: Before) => After;
down: (newState: After) => Before; down: (newState: After) => Before;
up: (oldState: Before) => After;
}; };
// @public (undocumented)
export interface LegacyMigrations extends LegacyBaseMigrationsInfo {
// (undocumented)
subTypeKey?: string;
// (undocumented)
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>;
}
// @public (undocumented)
export type Migration = {
readonly dependsOn?: readonly MigrationId[] | undefined;
readonly id: MigrationId;
} & ({
readonly down?: (newState: SerializedStore<UnknownRecord>) => SerializedStore<UnknownRecord> | void;
readonly scope: 'store';
readonly up: (oldState: SerializedStore<UnknownRecord>) => SerializedStore<UnknownRecord> | void;
} | {
readonly down?: (newState: UnknownRecord) => UnknownRecord | void;
readonly filter?: (record: UnknownRecord) => boolean;
readonly scope: 'record';
readonly up: (oldState: UnknownRecord) => UnknownRecord | void;
});
// @public (undocumented) // @public (undocumented)
export enum MigrationFailureReason { export enum MigrationFailureReason {
// (undocumented) // (undocumented)
@ -128,23 +143,33 @@ export enum MigrationFailureReason {
UnrecognizedSubtype = "unrecognized-subtype" UnrecognizedSubtype = "unrecognized-subtype"
} }
// @public (undocumented)
export type MigrationId = `${string}/${number}`;
// @public (undocumented) // @public (undocumented)
export type MigrationResult<T> = { export type MigrationResult<T> = {
type: 'error';
reason: MigrationFailureReason; reason: MigrationFailureReason;
type: 'error';
} | { } | {
type: 'success'; type: 'success';
value: T; value: T;
}; };
// @public (undocumented) // @public (undocumented)
export interface Migrations extends BaseMigrationsInfo { export interface MigrationSequence {
retroactive: boolean;
// (undocumented) // (undocumented)
subTypeKey?: string; sequence: Migration[];
// (undocumented) // (undocumented)
subTypeMigrations?: Record<string, BaseMigrationsInfo>; sequenceId: string;
} }
// @internal (undocumented)
export function parseMigrationId(id: MigrationId): {
sequenceId: string;
version: number;
};
// @public (undocumented) // @public (undocumented)
export type RecordId<R extends UnknownRecord> = string & { export type RecordId<R extends UnknownRecord> = string & {
__type__: R; __type__: R;
@ -153,8 +178,8 @@ export type RecordId<R extends UnknownRecord> = string & {
// @public // @public
export type RecordsDiff<R extends UnknownRecord> = { export type RecordsDiff<R extends UnknownRecord> = {
added: Record<IdOf<R>, R>; added: Record<IdOf<R>, R>;
updated: Record<IdOf<R>, [from: R, to: R]>;
removed: Record<IdOf<R>, R>; removed: Record<IdOf<R>, R>;
updated: Record<IdOf<R>, [from: R, to: R]>;
}; };
// @public // @public
@ -162,9 +187,8 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
constructor( constructor(
typeName: R['typeName'], config: { typeName: R['typeName'], config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>; readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>;
readonly migrations: Migrations;
readonly validator?: StoreValidator<R>;
readonly scope?: RecordScope; readonly scope?: RecordScope;
readonly validator?: StoreValidator<R>;
}); });
clone(record: R): R; clone(record: R): R;
create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R; create(properties: Pick<R, RequiredProperties> & Omit<Partial<R>, RequiredProperties>): R;
@ -175,8 +199,6 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
createId(customUniquePart?: string): IdOf<R>; createId(customUniquePart?: string): IdOf<R>;
isId(id?: string): id is IdOf<R>; isId(id?: string): id is IdOf<R>;
isInstance: (record?: UnknownRecord) => record is R; isInstance: (record?: UnknownRecord) => record is R;
// (undocumented)
readonly migrations: Migrations;
parseId(id: IdOf<R>): string; parseId(id: IdOf<R>): string;
// (undocumented) // (undocumented)
readonly scope: RecordScope; readonly scope: RecordScope;
@ -187,28 +209,35 @@ export class RecordType<R extends UnknownRecord, RequiredProperties extends keyo
withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>; withDefaultProperties<DefaultProps extends Omit<Partial<R>, 'id' | 'typeName'>>(createDefaultProperties: () => DefaultProps): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>;
} }
// @public (undocumented)
export type RecordVersion = {
rootVersion: number;
subTypeVersion?: number;
};
// @public (undocumented) // @public (undocumented)
export function reverseRecordsDiff(diff: RecordsDiff<any>): RecordsDiff<any>; export function reverseRecordsDiff(diff: RecordsDiff<any>): RecordsDiff<any>;
// @public (undocumented) // @public (undocumented)
export interface SerializedSchema { export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2;
// @public (undocumented)
export interface SerializedSchemaV1 {
recordVersions: Record<string, { recordVersions: Record<string, {
version: number;
subTypeVersions: Record<string, number>;
subTypeKey: string; subTypeKey: string;
subTypeVersions: Record<string, number>;
version: number;
} | { } | {
version: number; version: number;
}>; }>;
schemaVersion: number; schemaVersion: 1;
storeVersion: number; storeVersion: number;
} }
// @public (undocumented)
export interface SerializedSchemaV2 {
// (undocumented)
schemaVersion: 2;
// (undocumented)
sequences: {
[sequenceId: string]: number;
};
}
// @public // @public
export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>; export type SerializedStore<R extends UnknownRecord> = Record<IdOf<R>, R>;
@ -218,8 +247,8 @@ export function squashRecordDiffs<T extends UnknownRecord>(diffs: RecordsDiff<T>
// @public // @public
export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> { export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
constructor(config: { constructor(config: {
initialData?: SerializedStore<R>;
schema: StoreSchema<R, Props>; schema: StoreSchema<R, Props>;
initialData?: SerializedStore<R>;
props: Props; props: Props;
}); });
allRecords: () => R[]; allRecords: () => R[];
@ -234,8 +263,8 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
extractingChanges(fn: () => void): RecordsDiff<R>; extractingChanges(fn: () => void): RecordsDiff<R>;
filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): { filterChangesByScope(change: RecordsDiff<R>, scope: RecordScope): {
added: { [K in IdOf<R>]: R; }; added: { [K in IdOf<R>]: R; };
updated: { [K_1 in IdOf<R>]: [from: R, to: R]; };
removed: { [K in IdOf<R>]: R; }; removed: { [K in IdOf<R>]: R; };
updated: { [K_1 in IdOf<R>]: [from: R, to: R]; };
} | null; } | null;
// (undocumented) // (undocumented)
_flushHistory(): void; _flushHistory(): void;
@ -281,10 +310,10 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
// @public (undocumented) // @public (undocumented)
export type StoreError = { export type StoreError = {
error: Error; error: Error;
phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord';
recordBefore?: unknown;
recordAfter: unknown;
isExistingValidationIssue: boolean; isExistingValidationIssue: boolean;
phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord';
recordAfter: unknown;
recordBefore?: unknown;
}; };
// @public // @public
@ -301,16 +330,20 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
// @internal (undocumented) // @internal (undocumented)
createIntegrityChecker(store: Store<R, P>): (() => void) | undefined; createIntegrityChecker(store: Store<R, P>): (() => void) | undefined;
// (undocumented) // (undocumented)
get currentStoreVersion(): number; getMigrationsSince(persistedSchema: SerializedSchema): Result<Migration[], string>;
// (undocumented) // (undocumented)
migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>; migratePersistedRecord(record: R, persistedSchema: SerializedSchema, direction?: 'down' | 'up'): MigrationResult<R>;
// (undocumented) // (undocumented)
migrateStoreSnapshot(snapshot: StoreSnapshot<R>): MigrationResult<SerializedStore<R>>; migrateStoreSnapshot(snapshot: StoreSnapshot<R>): MigrationResult<SerializedStore<R>>;
// (undocumented) // (undocumented)
serialize(): SerializedSchema; readonly migrations: Record<string, MigrationSequence>;
// (undocumented) // (undocumented)
serialize(): SerializedSchemaV2;
// @deprecated (undocumented)
serializeEarliestVersion(): SerializedSchema; serializeEarliestVersion(): SerializedSchema;
// (undocumented) // (undocumented)
readonly sortedMigrations: readonly Migration[];
// (undocumented)
readonly types: { readonly types: {
[Record in R as Record['typeName']]: RecordType<R, any>; [Record in R as Record['typeName']]: RecordType<R, any>;
}; };
@ -320,21 +353,21 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
// @public (undocumented) // @public (undocumented)
export type StoreSchemaOptions<R extends UnknownRecord, P> = { export type StoreSchemaOptions<R extends UnknownRecord, P> = {
snapshotMigrations?: Migrations; createIntegrityChecker?: (store: Store<R, P>) => void;
onValidationFailure?: (data: { onValidationFailure?: (data: {
error: unknown; error: unknown;
store: Store<R>;
record: R;
phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord'; phase: 'createRecord' | 'initialize' | 'tests' | 'updateRecord';
record: R;
recordBefore: null | R; recordBefore: null | R;
store: Store<R>;
}) => R; }) => R;
createIntegrityChecker?: (store: Store<R, P>) => void; migrations?: MigrationSequence[];
}; };
// @public (undocumented) // @public (undocumented)
export type StoreSnapshot<R extends UnknownRecord> = { export type StoreSnapshot<R extends UnknownRecord> = {
store: SerializedStore<R>;
schema: SerializedSchema; schema: SerializedSchema;
store: SerializedStore<R>;
}; };
// @public (undocumented) // @public (undocumented)

File diff suppressed because it is too large Load diff

View file

@ -15,19 +15,26 @@ export type {
StoreValidators, StoreValidators,
} from './lib/Store' } from './lib/Store'
export { StoreSchema } from './lib/StoreSchema' export { StoreSchema } from './lib/StoreSchema'
export type { SerializedSchema, StoreSchemaOptions } from './lib/StoreSchema' export type {
export { compareSchemas } from './lib/compareSchemas' SerializedSchema,
SerializedSchemaV1,
SerializedSchemaV2,
StoreSchemaOptions,
} from './lib/StoreSchema'
export { devFreeze } from './lib/devFreeze' export { devFreeze } from './lib/devFreeze'
export { export {
MigrationFailureReason, MigrationFailureReason,
compareRecordVersions, createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
// eslint-disable-next-line deprecation/deprecation
defineMigrations, defineMigrations,
getRecordVersion, parseMigrationId,
migrate, type LegacyMigration,
migrateRecord, type LegacyMigrations,
type Migration, type Migration,
type MigrationId,
type MigrationResult, type MigrationResult,
type Migrations, type MigrationSequence,
type RecordVersion,
} from './lib/migrate' } from './lib/migrate'
export type { AllRecords } from './lib/type-utils' export type { AllRecords } from './lib/type-utils'

View file

@ -2,7 +2,6 @@ import { structuredClone } from '@tldraw/utils'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord' import { IdOf, OmitMeta, UnknownRecord } from './BaseRecord'
import { StoreValidator } from './Store' import { StoreValidator } from './Store'
import { Migrations } from './migrate'
export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['create']> export type RecordTypeRecord<R extends RecordType<any, any>> = ReturnType<R['create']>
@ -28,7 +27,6 @@ export class RecordType<
RequiredProperties extends keyof Omit<R, 'id' | 'typeName'>, RequiredProperties extends keyof Omit<R, 'id' | 'typeName'>,
> { > {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator: StoreValidator<R> readonly validator: StoreValidator<R>
readonly scope: RecordScope readonly scope: RecordScope
@ -43,13 +41,11 @@ export class RecordType<
public readonly typeName: R['typeName'], public readonly typeName: R['typeName'],
config: { config: {
readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties> readonly createDefaultProperties: () => Exclude<OmitMeta<R>, RequiredProperties>
readonly migrations: Migrations
readonly validator?: StoreValidator<R> readonly validator?: StoreValidator<R>
readonly scope?: RecordScope readonly scope?: RecordScope
} }
) { ) {
this.createDefaultProperties = config.createDefaultProperties this.createDefaultProperties = config.createDefaultProperties
this.migrations = config.migrations
this.validator = config.validator ?? { validate: (r: unknown) => r as R } this.validator = config.validator ?? { validate: (r: unknown) => r as R }
this.scope = config.scope ?? 'document' this.scope = config.scope ?? 'document'
} }
@ -188,7 +184,6 @@ export class RecordType<
): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>> { ): RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>> {
return new RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>(this.typeName, { return new RecordType<R, Exclude<RequiredProperties, keyof DefaultProps>>(this.typeName, {
createDefaultProperties: createDefaultProperties as any, createDefaultProperties: createDefaultProperties as any,
migrations: this.migrations,
validator: this.validator, validator: this.validator,
scope: this.scope, scope: this.scope,
}) })
@ -221,14 +216,12 @@ export class RecordType<
export function createRecordType<R extends UnknownRecord>( export function createRecordType<R extends UnknownRecord>(
typeName: R['typeName'], typeName: R['typeName'],
config: { config: {
migrations?: Migrations
validator?: StoreValidator<R> validator?: StoreValidator<R>
scope: RecordScope scope: RecordScope
} }
): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> { ): RecordType<R, keyof Omit<R, 'id' | 'typeName'>> {
return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, { return new RecordType<R, keyof Omit<R, 'id' | 'typeName'>>(typeName, {
createDefaultProperties: () => ({}) as any, createDefaultProperties: () => ({}) as any,
migrations: config.migrations ?? { currentVersion: 0, firstVersion: 0, migrators: {} },
validator: config.validator, validator: config.validator,
scope: config.scope, scope: config.scope,
}) })

View file

@ -617,11 +617,17 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`) throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`)
} }
transact(() => { const prevRunCallbacks = this._runCallbacks
this.clear() try {
this.put(Object.values(migrationResult.value)) this._runCallbacks = false
this.ensureStoreIsUsable() transact(() => {
}) this.clear()
this.put(Object.values(migrationResult.value))
this.ensureStoreIsUsable()
})
} finally {
this._runCallbacks = prevRunCallbacks
}
} }
/** /**

View file

@ -1,19 +1,28 @@
import { getOwnProperty, objectMapValues } from '@tldraw/utils' import {
import { IdOf, UnknownRecord } from './BaseRecord' Result,
assert,
exhaustiveSwitchError,
getOwnProperty,
structuredClone,
} from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { RecordType } from './RecordType' import { RecordType } from './RecordType'
import { SerializedStore, Store, StoreSnapshot } from './Store' import { SerializedStore, Store, StoreSnapshot } from './Store'
import { import {
Migration,
MigrationFailureReason, MigrationFailureReason,
MigrationId,
MigrationResult, MigrationResult,
Migrations, MigrationSequence,
migrate, parseMigrationId,
migrateRecord, sortMigrations,
validateMigrations,
} from './migrate' } from './migrate'
/** @public */ /** @public */
export interface SerializedSchema { export interface SerializedSchemaV1 {
/** Schema version is the version for this type you're looking at right now */ /** Schema version is the version for this type you're looking at right now */
schemaVersion: number schemaVersion: 1
/** /**
* Store version is the version for the structure of the store. e.g. higher level structure like * Store version is the version for the structure of the store. e.g. higher level structure like
* removing or renaming a record type. * removing or renaming a record type.
@ -34,10 +43,39 @@ export interface SerializedSchema {
> >
} }
/** @public */
export interface SerializedSchemaV2 {
schemaVersion: 2
sequences: {
[sequenceId: string]: number
}
}
/** @public */
export type SerializedSchema = SerializedSchemaV1 | SerializedSchemaV2
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: {},
}
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)
}
/** @public */ /** @public */
export type StoreSchemaOptions<R extends UnknownRecord, P> = { export type StoreSchemaOptions<R extends UnknownRecord, P> = {
/** @public */ migrations?: MigrationSequence[]
snapshotMigrations?: Migrations
/** @public */ /** @public */
onValidationFailure?: (data: { onValidationFailure?: (data: {
error: unknown error: unknown
@ -62,16 +100,30 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
return new StoreSchema<R, P>(types as any, options ?? {}) return new StoreSchema<R, P>(types as any, options ?? {})
} }
readonly migrations: Record<string, MigrationSequence> = {}
readonly sortedMigrations: readonly Migration[]
private constructor( private constructor(
public readonly types: { public readonly types: {
[Record in R as Record['typeName']]: RecordType<R, any> [Record in R as Record['typeName']]: RecordType<R, any>
}, },
private readonly options: StoreSchemaOptions<R, P> 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)
// eslint-disable-next-line no-restricted-syntax for (const migration of this.sortedMigrations) {
get currentStoreVersion(): number { if (!migration.dependsOn?.length) continue
return this.options.snapshotMigrations?.currentVersion ?? 0 for (const dep of migration.dependsOn) {
const depMigration = allMigrations.find((m) => m.id === dep)
assert(depMigration, `Migration '${migration.id}' depends on missing migration '${dep}'`)
}
}
} }
validateRecord( validateRecord(
@ -101,138 +153,151 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
} }
} }
// TODO: use a weakmap to store the result of this function
public getMigrationsSince(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)))
}
migratePersistedRecord( migratePersistedRecord(
record: R, record: R,
persistedSchema: SerializedSchema, persistedSchema: SerializedSchema,
direction: 'up' | 'down' = 'up' direction: 'up' | 'down' = 'up'
): MigrationResult<R> { ): MigrationResult<R> {
const ourType = getOwnProperty(this.types, record.typeName) const migrations = this.getMigrationsSince(persistedSchema)
const persistedType = persistedSchema.recordVersions[record.typeName] if (!migrations.ok) {
if (!persistedType || !ourType) { // TODO: better error
return { type: 'error', reason: MigrationFailureReason.UnknownType } console.error('Error migrating record', migrations.error)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
} }
const ourVersion = ourType.migrations.currentVersion let migrationsToApply = migrations.value
const persistedVersion = persistedType.version if (migrationsToApply.length === 0) {
if (ourVersion !== persistedVersion) {
const result =
direction === 'up'
? migrateRecord<R>({
record,
migrations: ourType.migrations,
fromVersion: persistedVersion,
toVersion: ourVersion,
})
: migrateRecord<R>({
record,
migrations: ourType.migrations,
fromVersion: ourVersion,
toVersion: persistedVersion,
})
if (result.type === 'error') {
return result
}
record = result.value
}
if (!ourType.migrations.subTypeKey) {
return { type: 'success', value: record } return { type: 'success', value: record }
} }
// we've handled the main version migration, now we need to handle subtypes if (migrationsToApply.some((m) => m.scope === 'store')) {
// subtypes are used by shape and asset types to migrate the props shape, which is configurable return {
// by library consumers. type: 'error',
reason:
const ourSubTypeMigrations = direction === 'down'
ourType.migrations.subTypeMigrations?.[ ? MigrationFailureReason.TargetVersionTooOld
record[ourType.migrations.subTypeKey as keyof R] as string : MigrationFailureReason.TargetVersionTooNew,
] }
const persistedSubTypeVersion =
'subTypeVersions' in persistedType
? persistedType.subTypeVersions[record[ourType.migrations.subTypeKey as keyof R] as string]
: undefined
// if ourSubTypeMigrations is undefined then we don't have access to the migrations for this subtype
// that is almost certainly because we are running on the server and this type was supplied by a 3rd party.
// It could also be that we are running in a client that is outdated. Either way, we can't migrate this record
// and we need to let the consumer know so they can handle it.
if (ourSubTypeMigrations === undefined) {
return { type: 'error', reason: MigrationFailureReason.UnrecognizedSubtype }
} }
// if the persistedSubTypeVersion is undefined then the record was either created after the schema if (direction === 'down') {
// was persisted, or it was created in a different place to where the schema was persisted. if (!migrationsToApply.every((m) => m.down)) {
// either way we don't know what to do with it safely, so let's return failure. return {
if (persistedSubTypeVersion === undefined) { type: 'error',
return { type: 'error', reason: MigrationFailureReason.IncompatibleSubtype } reason: MigrationFailureReason.TargetVersionTooOld,
}
}
migrationsToApply = migrationsToApply.slice().reverse()
} }
const result = record = structuredClone(record)
direction === 'up' try {
? migrateRecord<R>({ for (const migration of migrationsToApply) {
record, if (migration.scope === 'store') throw new Error(/* won't happen, just for TS */)
migrations: ourSubTypeMigrations, const shouldApply = migration.filter ? migration.filter(record) : true
fromVersion: persistedSubTypeVersion, if (!shouldApply) continue
toVersion: ourSubTypeMigrations.currentVersion, const result = migration[direction]!(record)
}) if (result) {
: migrateRecord<R>({ record = structuredClone(result) as any
record, }
migrations: ourSubTypeMigrations, }
fromVersion: ourSubTypeMigrations.currentVersion, } catch (e) {
toVersion: persistedSubTypeVersion, console.error('Error migrating record', e)
}) return { type: 'error', reason: MigrationFailureReason.MigrationError }
if (result.type === 'error') {
return result
} }
return { type: 'success', value: result.value } return { type: 'success', value: record }
} }
migrateStoreSnapshot(snapshot: StoreSnapshot<R>): MigrationResult<SerializedStore<R>> { migrateStoreSnapshot(snapshot: StoreSnapshot<R>): MigrationResult<SerializedStore<R>> {
let { store } = snapshot let { store } = snapshot
const migrations = this.getMigrationsSince(snapshot.schema)
const migrations = this.options.snapshotMigrations if (!migrations.ok) {
if (!migrations) { // 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: store } return { type: 'success', value: store }
} }
// apply store migrations first
const ourStoreVersion = migrations.currentVersion
const persistedStoreVersion = snapshot.schema.storeVersion ?? 0
if (ourStoreVersion < persistedStoreVersion) { store = structuredClone(store)
return { type: 'error', reason: MigrationFailureReason.TargetVersionTooOld }
}
if (ourStoreVersion > persistedStoreVersion) { try {
const result = migrate<SerializedStore<R>>({ for (const migration of migrationsToApply) {
value: store, if (migration.scope === 'record') {
migrations, for (const [id, record] of Object.entries(store)) {
fromVersion: persistedStoreVersion, const shouldApply = migration.filter ? migration.filter(record as UnknownRecord) : true
toVersion: ourStoreVersion, if (!shouldApply) continue
}) const result = migration.up!(record as any)
if (result) {
if (result.type === 'error') { store[id as keyof typeof store] = structuredClone(result) as any
return result }
}
} else if (migration.scope === 'store') {
const result = migration.up!(store)
if (result) {
store = structuredClone(result) as any
}
} else {
exhaustiveSwitchError(migration)
}
} }
store = result.value } catch (e) {
console.error('Error migrating store', e)
return { type: 'error', reason: MigrationFailureReason.MigrationError }
} }
const updated: R[] = []
for (const r of objectMapValues(store)) {
const result = this.migratePersistedRecord(r, snapshot.schema)
if (result.type === 'error') {
return result
} else if (result.value && result.value !== r) {
updated.push(result.value)
}
}
if (updated.length) {
store = { ...store }
for (const r of updated) {
store[r.id as IdOf<R>] = r
}
}
return { type: 'success', value: store } return { type: 'success', value: store }
} }
@ -241,58 +306,26 @@ export class StoreSchema<R extends UnknownRecord, P = unknown> {
return this.options.createIntegrityChecker?.(store) ?? undefined return this.options.createIntegrityChecker?.(store) ?? undefined
} }
serialize(): SerializedSchema { serialize(): SerializedSchemaV2 {
return { return {
schemaVersion: 1, schemaVersion: 2,
storeVersion: this.options.snapshotMigrations?.currentVersion ?? 0, sequences: Object.fromEntries(
recordVersions: Object.fromEntries( Object.values(this.migrations).map(({ sequenceId, sequence }) => [
objectMapValues(this.types).map((type) => [ sequenceId,
type.typeName, sequence.length ? parseMigrationId(sequence.at(-1)!.id).version : 0,
type.migrations.subTypeKey && type.migrations.subTypeMigrations
? {
version: type.migrations.currentVersion,
subTypeKey: type.migrations.subTypeKey,
subTypeVersions: type.migrations.subTypeMigrations
? Object.fromEntries(
Object.entries(type.migrations.subTypeMigrations).map(([k, v]) => [
k,
v.currentVersion,
])
)
: undefined,
}
: {
version: type.migrations.currentVersion,
},
]) ])
), ),
} }
} }
/**
* @deprecated This is only here for legacy reasons, don't use it unless you have david's blessing!
*/
serializeEarliestVersion(): SerializedSchema { serializeEarliestVersion(): SerializedSchema {
return { return {
schemaVersion: 1, schemaVersion: 2,
storeVersion: this.options.snapshotMigrations?.firstVersion ?? 0, sequences: Object.fromEntries(
recordVersions: Object.fromEntries( Object.values(this.migrations).map(({ sequenceId }) => [sequenceId, 0])
objectMapValues(this.types).map((type) => [
type.typeName,
type.migrations.subTypeKey && type.migrations.subTypeMigrations
? {
version: type.migrations.firstVersion,
subTypeKey: type.migrations.subTypeKey,
subTypeVersions: type.migrations.subTypeMigrations
? Object.fromEntries(
Object.entries(type.migrations.subTypeMigrations).map(([k, v]) => [
k,
v.firstVersion,
])
)
: undefined,
}
: {
version: type.migrations.firstVersion,
},
])
), ),
} }
} }

View file

@ -1,55 +0,0 @@
import { SerializedSchema } from './StoreSchema'
/** @public */
export const compareSchemas = (a: SerializedSchema, b: SerializedSchema): 0 | 1 | -1 => {
if (a.schemaVersion > b.schemaVersion) {
return 1
}
if (a.schemaVersion < b.schemaVersion) {
return -1
}
if (a.storeVersion > b.storeVersion) {
return 1
}
if (a.storeVersion < b.storeVersion) {
return -1
}
for (const key of Object.keys(a.recordVersions)) {
const aRecordVersion = a.recordVersions[key]
const bRecordVersion = b.recordVersions[key]
if (aRecordVersion.version > bRecordVersion.version) {
return 1
}
if (aRecordVersion.version < bRecordVersion.version) {
return -1
}
if ('subTypeVersions' in aRecordVersion && !('subTypeVersions' in bRecordVersion)) {
// todo: this assumes that subtypes were added in an up migration rather than removed. We should probably
// make sure that in either case the parent version is bumped
return 1
}
if (!('subTypeVersions' in aRecordVersion) && 'subTypeVersions' in bRecordVersion) {
// todo: this assumes that subtypes were added in an up migration rather than removed. We should probably
// make sure that in either case the parent version is bumped
return -1
}
if (!('subTypeVersions' in aRecordVersion) || !('subTypeVersions' in bRecordVersion)) {
// this will never happen
continue
}
for (const subType of Object.keys(aRecordVersion.subTypeVersions)) {
const aSubTypeVersion = aRecordVersion.subTypeVersions[subType]
const bSubTypeVersion = bRecordVersion.subTypeVersions[subType]
if (aSubTypeVersion > bSubTypeVersion) {
return 1
}
if (aSubTypeVersion < bSubTypeVersion) {
return -1
}
}
}
return 0
}

View file

@ -1,26 +1,27 @@
import { UnknownRecord, isRecord } from './BaseRecord' import { assert, objectMapEntries } from '@tldraw/utils'
import { SerializedSchema } from './StoreSchema' import { UnknownRecord } from './BaseRecord'
import { SerializedStore } from './Store'
type EMPTY_SYMBOL = symbol let didWarn = false
/** @public */ /**
export function defineMigrations< * @public
FirstVersion extends number | EMPTY_SYMBOL = EMPTY_SYMBOL, * @deprecated use `createShapePropsMigrationSequence` instead. See [the docs](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for how to migrate.
CurrentVersion extends Exclude<number, 0> | EMPTY_SYMBOL = EMPTY_SYMBOL, */
>(opts: { export function defineMigrations(opts: {
firstVersion?: CurrentVersion extends number ? FirstVersion : never firstVersion?: number
currentVersion?: CurrentVersion currentVersion?: number
migrators?: CurrentVersion extends number migrators?: Record<number, LegacyMigration>
? FirstVersion extends number
? CurrentVersion extends FirstVersion
? { [version in Exclude<Range<1, CurrentVersion>, 0>]: Migration }
: { [version in Exclude<Range<FirstVersion, CurrentVersion>, FirstVersion>]: Migration }
: { [version in Exclude<Range<1, CurrentVersion>, 0>]: Migration }
: never
subTypeKey?: string subTypeKey?: string
subTypeMigrations?: Record<string, BaseMigrationsInfo> subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}): Migrations { }): LegacyMigrations {
const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts const { currentVersion, firstVersion, migrators = {}, subTypeKey, subTypeMigrations } = opts
if (!didWarn) {
console.warn(
`The 'defineMigrations' function is deprecated and will be removed in a future release. Use the new migrations API instead. See the migration guide for more info: https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations`
)
didWarn = true
}
// Some basic guards against impossible version combinations, some of which will be caught by TypeScript // Some basic guards against impossible version combinations, some of which will be caught by TypeScript
if (typeof currentVersion === 'number' && typeof firstVersion === 'number') { if (typeof currentVersion === 'number' && typeof firstVersion === 'number') {
@ -40,22 +41,236 @@ export function defineMigrations<
} }
} }
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.
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @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 given a named set of version numbers and a sequence id.
*
* See the [migration guide](https://tldraw.dev/docs/persistence#Migrations) for more info on how to use this API.
* @public
* @public
*/
export function createMigrationIds<ID extends string, Versions extends Record<string, number>>(
sequenceId: ID,
versions: Versions
): { [K in keyof Versions]: `${ID}/${Versions[K]}` } {
return Object.fromEntries(
objectMapEntries(versions).map(([key, version]) => [key, `${sequenceId}/${version}`] as const)
) as any
}
/** @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) =>
'id' in m
? {
...m,
scope: 'record',
filter: (r: UnknownRecord) =>
r.typeName === opts.recordType &&
(m.filter?.(r) ?? true) &&
(opts.filter?.(r) ?? true),
}
: m
),
})
}
/** @public */ /** @public */
export type Migration<Before = any, After = any> = { export type LegacyMigration<Before = any, After = any> = {
up: (oldState: Before) => After up: (oldState: Before) => After
down: (newState: After) => Before down: (newState: After) => Before
} }
interface BaseMigrationsInfo { /** @public */
firstVersion: number export type MigrationId = `${string}/${number}`
currentVersion: number
migrators: { [version: number]: Migration } export type StandaloneDependsOn = {
readonly dependsOn: readonly MigrationId[]
} }
/** @public */ /** @public */
export interface Migrations extends BaseMigrationsInfo { export type Migration = {
readonly id: MigrationId
readonly dependsOn?: readonly MigrationId[] | undefined
} & (
| {
readonly scope: 'record'
readonly filter?: (record: UnknownRecord) => boolean
readonly up: (oldState: UnknownRecord) => void | UnknownRecord
readonly down?: (newState: UnknownRecord) => void | UnknownRecord
}
| {
readonly scope: 'store'
readonly up: (
oldState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
readonly down?: (
newState: SerializedStore<UnknownRecord>
) => void | SerializedStore<UnknownRecord>
}
)
interface LegacyBaseMigrationsInfo {
firstVersion: number
currentVersion: number
migrators: { [version: number]: LegacyMigration }
}
/** @public */
export interface LegacyMigrations extends LegacyBaseMigrationsInfo {
subTypeKey?: string subTypeKey?: string
subTypeMigrations?: Record<string, BaseMigrationsInfo> subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}
/** @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[]
}
export function sortMigrations(migrations: Migration[]): Migration[] {
// we do a topological sort using dependsOn and implicit dependencies between migrations in the same sequence
const byId = new Map(migrations.map((m) => [m.id, m]))
const isProcessing = new Set<MigrationId>()
const result: Migration[] = []
function process(m: Migration) {
assert(!isProcessing.has(m.id), `Circular dependency in migrations: ${m.id}`)
isProcessing.add(m.id)
const { version, sequenceId } = parseMigrationId(m.id)
const parent = byId.get(`${sequenceId}/${version - 1}`)
if (parent) {
process(parent)
}
if (m.dependsOn) {
for (const dep of m.dependsOn) {
const depMigration = byId.get(dep)
if (depMigration) {
process(depMigration)
}
}
}
byId.delete(m.id)
result.push(m)
}
for (const m of byId.values()) {
process(m)
}
return result
}
/** @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}'`)
}
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
}
} }
/** @public */ /** @public */
@ -72,246 +287,3 @@ export enum MigrationFailureReason {
MigrationError = 'migration-error', MigrationError = 'migration-error',
UnrecognizedSubtype = 'unrecognized-subtype', UnrecognizedSubtype = 'unrecognized-subtype',
} }
/** @public */
export type RecordVersion = { rootVersion: number; subTypeVersion?: number }
/** @public */
export function getRecordVersion(
record: UnknownRecord,
serializedSchema: SerializedSchema
): RecordVersion {
const persistedType = serializedSchema.recordVersions[record.typeName]
if (!persistedType) {
return { rootVersion: 0 }
}
if ('subTypeKey' in persistedType) {
const subType = record[persistedType.subTypeKey as keyof typeof record]
const subTypeVersion = persistedType.subTypeVersions[subType]
return { rootVersion: persistedType.version, subTypeVersion }
}
return { rootVersion: persistedType.version }
}
/** @public */
export function compareRecordVersions(a: RecordVersion, b: RecordVersion) {
if (a.rootVersion > b.rootVersion) {
return 1
}
if (a.rootVersion < b.rootVersion) {
return -1
}
if (a.subTypeVersion != null && b.subTypeVersion != null) {
if (a.subTypeVersion > b.subTypeVersion) {
return 1
}
if (a.subTypeVersion < b.subTypeVersion) {
return -1
}
}
return 0
}
/** @public */
export function migrateRecord<R extends UnknownRecord>({
record,
migrations,
fromVersion,
toVersion,
}: {
record: unknown
migrations: Migrations
fromVersion: number
toVersion: number
}): MigrationResult<R> {
let currentVersion = fromVersion
if (!isRecord(record)) throw new Error('[migrateRecord] object is not a record')
const { typeName, id, ...others } = record
let recordWithoutMeta = others
while (currentVersion < toVersion) {
const nextVersion = currentVersion + 1
const migrator = migrations.migrators[nextVersion]
if (!migrator) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooNew,
}
}
recordWithoutMeta = migrator.up(recordWithoutMeta) as any
currentVersion = nextVersion
}
while (currentVersion > toVersion) {
const nextVersion = currentVersion - 1
const migrator = migrations.migrators[currentVersion]
if (!migrator) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
}
}
recordWithoutMeta = migrator.down(recordWithoutMeta) as any
currentVersion = nextVersion
}
return {
type: 'success',
value: { ...recordWithoutMeta, id, typeName } as any,
}
}
/** @public */
export function migrate<T>({
value,
migrations,
fromVersion,
toVersion,
}: {
value: unknown
migrations: Migrations
fromVersion: number
toVersion: number
}): MigrationResult<T> {
let currentVersion = fromVersion
while (currentVersion < toVersion) {
const nextVersion = currentVersion + 1
const migrator = migrations.migrators[nextVersion]
if (!migrator) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooNew,
}
}
value = migrator.up(value)
currentVersion = nextVersion
}
while (currentVersion > toVersion) {
const nextVersion = currentVersion - 1
const migrator = migrations.migrators[currentVersion]
if (!migrator) {
return {
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
}
}
value = migrator.down(value)
currentVersion = nextVersion
}
return {
type: 'success',
value: value as T,
}
}
type Range<From extends number, To extends number> = To extends From
? From
: To | Range<From, Decrement<To>>
type Decrement<n extends number> = n extends 0
? never
: n extends 1
? 0
: n extends 2
? 1
: n extends 3
? 2
: n extends 4
? 3
: n extends 5
? 4
: n extends 6
? 5
: n extends 7
? 6
: n extends 8
? 7
: n extends 9
? 8
: n extends 10
? 9
: n extends 11
? 10
: n extends 12
? 11
: n extends 13
? 12
: n extends 14
? 13
: n extends 15
? 14
: n extends 16
? 15
: n extends 17
? 16
: n extends 18
? 17
: n extends 19
? 18
: n extends 20
? 19
: n extends 21
? 20
: n extends 22
? 21
: n extends 23
? 22
: n extends 24
? 23
: n extends 25
? 24
: n extends 26
? 25
: n extends 27
? 26
: n extends 28
? 27
: n extends 29
? 28
: n extends 30
? 29
: n extends 31
? 30
: n extends 32
? 31
: n extends 33
? 32
: n extends 34
? 33
: n extends 35
? 34
: n extends 36
? 35
: n extends 37
? 36
: n extends 38
? 37
: n extends 39
? 38
: n extends 40
? 39
: n extends 41
? 40
: n extends 42
? 41
: n extends 43
? 42
: n extends 44
? 43
: n extends 45
? 44
: n extends 46
? 45
: n extends 47
? 46
: n extends 48
? 47
: n extends 49
? 48
: n extends 50
? 49
: n extends 51
? 50
: never

View file

@ -1,78 +0,0 @@
import { compareSchemas } from '../compareSchemas'
import { testSchemaV0 } from './testSchema.v0'
import { testSchemaV1 } from './testSchema.v1'
describe('compareSchemas', () => {
it('returns 0 for identical schemas', () => {
expect(compareSchemas(testSchemaV0.serialize(), testSchemaV0.serialize())).toBe(0)
expect(
compareSchemas(JSON.parse(JSON.stringify(testSchemaV0.serialize())), testSchemaV0.serialize())
).toBe(0)
expect(
compareSchemas(testSchemaV0.serialize(), JSON.parse(JSON.stringify(testSchemaV0.serialize())))
).toBe(0)
expect(
compareSchemas(
JSON.parse(JSON.stringify(testSchemaV0.serialize())),
JSON.parse(JSON.stringify(testSchemaV0.serialize()))
)
).toBe(0)
})
it('returns 1 when the left schema is later than the right schema', () => {
expect(
compareSchemas(JSON.parse(JSON.stringify(testSchemaV1.serialize())), testSchemaV0.serialize())
).toBe(1)
expect(
compareSchemas(testSchemaV1.serialize(), JSON.parse(JSON.stringify(testSchemaV0.serialize())))
).toBe(1)
expect(
compareSchemas(
JSON.parse(JSON.stringify(testSchemaV1.serialize())),
JSON.parse(JSON.stringify(testSchemaV0.serialize()))
)
).toBe(1)
})
it('returns -1 when the left schema is earlier than the right schema', () => {
expect(
compareSchemas(JSON.parse(JSON.stringify(testSchemaV0.serialize())), testSchemaV1.serialize())
).toBe(-1)
expect(
compareSchemas(testSchemaV0.serialize(), JSON.parse(JSON.stringify(testSchemaV1.serialize())))
).toBe(-1)
expect(
compareSchemas(
JSON.parse(JSON.stringify(testSchemaV0.serialize())),
JSON.parse(JSON.stringify(testSchemaV1.serialize()))
)
).toBe(-1)
})
it('works when a record version was updated', () => {
const schema = testSchemaV0.serialize()
schema.recordVersions.shape.version++
expect(compareSchemas(schema, testSchemaV0.serialize())).toBe(1)
expect(compareSchemas(testSchemaV0.serialize(), schema)).toBe(-1)
})
it('works when a record subtype was updated', () => {
const schema = testSchemaV0.serialize()
if ('subTypeVersions' in schema.recordVersions.shape) {
schema.recordVersions.shape.subTypeVersions.rectangle++
}
expect(compareSchemas(schema, testSchemaV0.serialize())).toBe(1)
expect(compareSchemas(testSchemaV0.serialize(), schema)).toBe(-1)
})
it('works when the schema format version is updated', () => {
const schema = testSchemaV0.serialize()
schema.schemaVersion++
expect(compareSchemas(schema, testSchemaV0.serialize())).toBe(1)
expect(compareSchemas(testSchemaV0.serialize(), schema)).toBe(-1)
})
it('works when the store version is updated', () => {
const schema = testSchemaV0.serialize()
schema.storeVersion++
expect(compareSchemas(schema, testSchemaV0.serialize())).toBe(1)
expect(compareSchemas(testSchemaV0.serialize(), schema)).toBe(-1)
})
})

View file

@ -0,0 +1,75 @@
import { createMigrationSequence } from '../migrate'
describe(createMigrationSequence, () => {
it('allows dependsOn to be deferred', () => {
expect(
createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [{ dependsOn: ['bar/1'] }],
}).sequence.length
).toBe(0)
const result = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
{ dependsOn: ['bar/1'] },
],
})
expect(result.sequence.length).toBe(1)
expect(result.sequence[0].dependsOn?.length).toBeFalsy()
const result2 = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [
{ dependsOn: ['bar/1'] },
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
],
})
expect(result2.sequence.length).toBe(1)
expect(result2.sequence[0].dependsOn).toEqual(['bar/1'])
const result3 = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
{ dependsOn: ['bar/1'] },
{
id: 'foo/2',
scope: 'record',
up() {
// noop
},
},
],
})
expect(result3.sequence.length).toBe(2)
expect(result3.sequence[0].dependsOn?.length).toBeFalsy()
expect(result3.sequence[1].dependsOn).toEqual(['bar/1'])
})
})

View file

@ -11,32 +11,32 @@ describe('define migrations tests', () => {
it('defines migrations', () => { it('defines migrations', () => {
expect(() => { expect(() => {
// no versions // no versions
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error first version without current version
firstVersion: Versions.Initial, firstVersion: Versions.Initial,
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// no versions // no versions
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error first version without current version
firstVersion: Versions.February, firstVersion: Versions.February,
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// empty migrators // empty migrators
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error
migrators: {}, migrators: {},
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// no versions! // no versions!
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error
migrators: { migrators: {
[Versions.February]: { [Versions.February]: {
up: (rec: any) => rec, up: (rec: any) => rec,
@ -48,10 +48,10 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// wrong current version! // wrong current version!
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
currentVersion: Versions.January, currentVersion: Versions.January,
migrators: { migrators: {
// @ts-expect-error
[Versions.February]: { [Versions.February]: {
up: (rec: any) => rec, up: (rec: any) => rec,
down: (rec: any) => rec, down: (rec: any) => rec,
@ -61,6 +61,7 @@ describe('define migrations tests', () => {
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
currentVersion: Versions.February, currentVersion: Versions.February,
migrators: { migrators: {
@ -80,16 +81,16 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// can't provide only first version // can't provide only first version
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error first version without current version
firstVersion: Versions.January, firstVersion: Versions.January,
// @ts-expect-error migrators without current version
migrators: {}, migrators: {},
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// same version // same version
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.Initial, firstVersion: Versions.Initial,
currentVersion: Versions.Initial, currentVersion: Versions.Initial,
@ -99,26 +100,26 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// only first version // only first version
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
// @ts-expect-error
firstVersion: Versions.January, firstVersion: Versions.January,
// @ts-expect-error
migrators: {}, migrators: {},
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// missing only version // missing only version
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.January, firstVersion: Versions.January,
currentVersion: Versions.January, currentVersion: Versions.January,
// @ts-expect-error
migrators: {}, migrators: {},
}) })
}).toThrow() }).toThrow()
expect(() => { expect(() => {
// only version, explicit start and current // only version, explicit start and current
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.January, firstVersion: Versions.January,
currentVersion: Versions.January, currentVersion: Versions.January,
@ -133,20 +134,20 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// missing later versions // missing later versions
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.January, firstVersion: Versions.January,
currentVersion: Versions.February, currentVersion: Versions.February,
// @ts-expect-error
migrators: {}, migrators: {},
}) })
}).not.toThrow() }).not.toThrow()
expect(() => { expect(() => {
// missing later versions // missing later versions
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.Initial, firstVersion: Versions.Initial,
currentVersion: Versions.February, currentVersion: Versions.February,
// @ts-expect-error
migrators: { migrators: {
[Versions.January]: { [Versions.January]: {
up: (rec: any) => rec, up: (rec: any) => rec,
@ -158,10 +159,10 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// missing earlier versions // missing earlier versions
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.Initial, firstVersion: Versions.Initial,
currentVersion: Versions.February, currentVersion: Versions.February,
// @ts-expect-error
migrators: { migrators: {
[Versions.February]: { [Versions.February]: {
up: (rec: any) => rec, up: (rec: any) => rec,
@ -173,6 +174,7 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// got em all // got em all
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.Initial, firstVersion: Versions.Initial,
currentVersion: Versions.February, currentVersion: Versions.February,
@ -191,6 +193,7 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// got em all starting later // got em all starting later
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.January, firstVersion: Versions.January,
currentVersion: Versions.March, currentVersion: Versions.March,
@ -209,11 +212,11 @@ describe('define migrations tests', () => {
expect(() => { expect(() => {
// first migration should be first version + 1 // first migration should be first version + 1
// eslint-disable-next-line deprecation/deprecation
defineMigrations({ defineMigrations({
firstVersion: Versions.February, firstVersion: Versions.February,
currentVersion: Versions.March, currentVersion: Versions.March,
migrators: { migrators: {
// @ts-expect-error
[Versions.February]: { [Versions.February]: {
up: (rec: any) => rec, up: (rec: any) => rec,
down: (rec: any) => rec, down: (rec: any) => rec,

View file

@ -0,0 +1,166 @@
import { StoreSchema } from '../StoreSchema'
import { MigrationSequence, createMigrationSequence } from '../migrate'
describe('dependsOn', () => {
it('requires the depended on ids to be present', () => {
expect(() => {
StoreSchema.create(
{},
{
migrations: [
{
sequenceId: 'foo',
retroactive: false,
sequence: [
{
id: 'foo/1',
dependsOn: ['bar/1'],
scope: 'record',
up() {
// noop
},
},
],
},
],
}
)
}).toThrowErrorMatchingInlineSnapshot(
`"Migration 'foo/1' depends on missing migration 'bar/1'"`
)
})
it('makes sure the migrations are sorted', () => {
const foo: MigrationSequence = {
sequenceId: 'foo',
retroactive: false,
sequence: [
{
id: 'foo/1',
dependsOn: ['bar/1'],
scope: 'record',
up() {
// noop
},
},
],
}
const bar: MigrationSequence = {
sequenceId: 'bar',
retroactive: false,
sequence: [
{
id: 'bar/1',
scope: 'record',
up() {
// noop
},
},
],
}
const s = StoreSchema.create(
{},
{
migrations: [foo, bar],
}
)
const s2 = StoreSchema.create(
{},
{
migrations: [bar, foo],
}
)
expect(s.sortedMigrations.map((s) => s.id)).toMatchInlineSnapshot(`
[
"bar/1",
"foo/1",
]
`)
expect(s2.sortedMigrations).toEqual(s.sortedMigrations)
})
})
describe('standalone dependsOn', () => {
it('requires the depended on ids to be present', () => {
expect(() => {
StoreSchema.create(
{},
{
migrations: [
createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [
{
dependsOn: ['bar/1'],
},
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
],
}),
],
}
)
}).toThrowErrorMatchingInlineSnapshot(
`"Migration 'foo/1' depends on missing migration 'bar/1'"`
)
})
it('makes sure the migrations are sorted', () => {
const foo: MigrationSequence = createMigrationSequence({
sequenceId: 'foo',
retroactive: false,
sequence: [
{
dependsOn: ['bar/1'],
},
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
],
})
const bar: MigrationSequence = createMigrationSequence({
sequenceId: 'bar',
retroactive: false,
sequence: [
{
id: 'bar/1',
scope: 'record',
up() {
// noop
},
},
],
})
const s = StoreSchema.create(
{},
{
migrations: [foo, bar],
}
)
const s2 = StoreSchema.create(
{},
{
migrations: [bar, foo],
}
)
expect(s.sortedMigrations.map((s) => s.id)).toMatchInlineSnapshot(`
[
"bar/1",
"foo/1",
]
`)
expect(s2.sortedMigrations).toEqual(s.sortedMigrations)
})
})

View file

@ -0,0 +1,121 @@
import { SerializedSchemaV2, StoreSchema } from '../StoreSchema'
import { MigrationSequence } from '../migrate'
const mockSequence = ({
id,
retroactive,
versions,
}: {
id: string
retroactive: boolean
versions: number
}) =>
({
sequenceId: id,
retroactive,
sequence: new Array(versions).fill(0).map((_, i) => ({
id: `${id}/${i + 1}`,
scope: 'record',
up() {
// noop
},
})),
}) satisfies MigrationSequence
function getMigrationsBetween(
serialized: SerializedSchemaV2['sequences'],
current: MigrationSequence[]
) {
const schema = StoreSchema.create({}, { migrations: current })
const ms = schema.getMigrationsSince({ schemaVersion: 2, sequences: serialized })
if (!ms.ok) {
throw new Error('Expected migrations to be found')
}
return ms.value.map((m) => m.id)
}
describe('getMigrationsSince', () => {
it('includes migrations from new migration sequences with retroactive: true', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: true, versions: 3 })
const ids = getMigrationsBetween({}, [foo, bar])
const foos = ids.filter((id) => id.startsWith('foo'))
const bars = ids.filter((id) => id.startsWith('bar'))
expect(foos).toEqual(['foo/1', 'foo/2'])
expect(bars).toEqual(['bar/1', 'bar/2', 'bar/3'])
})
it('does not include migrations from new migration sequences with retroactive: false', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const ids = getMigrationsBetween({}, [foo, bar])
const foos = ids.filter((id) => id.startsWith('foo'))
const bars = ids.filter((id) => id.startsWith('bar'))
expect(foos).toEqual(['foo/1', 'foo/2'])
expect(bars).toEqual([])
})
it('returns the empty array if there are no overlapping sequences and new ones are retroactive: false', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const ids = getMigrationsBetween({}, [foo, bar])
expect(ids).toEqual([])
})
it('if a sequence is present both before and now, unapplied migrations will be returned', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const ids = getMigrationsBetween({ foo: 1, bar: 1 }, [foo, bar])
const foos = ids.filter((id) => id.startsWith('foo'))
const bars = ids.filter((id) => id.startsWith('bar'))
expect(foos).toEqual(['foo/2'])
expect(bars).toEqual(['bar/2', 'bar/3'])
})
it('if a sequence has not changed the empty array will be returned', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const ids = getMigrationsBetween({ foo: 2, bar: 3 }, [foo, bar])
expect(ids).toEqual([])
})
it('if a sequence starts with 0 all unapplied migrations will be returned', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const ids = getMigrationsBetween(
{
foo: 0,
bar: 0,
},
[foo, bar]
)
const foos = ids.filter((id) => id.startsWith('foo'))
const bars = ids.filter((id) => id.startsWith('bar'))
expect(foos).toEqual(['foo/1', 'foo/2'])
expect(bars).toEqual(['bar/1', 'bar/2', 'bar/3'])
})
it('if a sequence starts with 0 and has 0 new migrations, no migrations will be returned', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 0 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 0 })
const ids = getMigrationsBetween(
{
foo: 0,
bar: 0,
},
[foo, bar]
)
expect(ids).toEqual([])
})
})

View file

@ -1,4 +1,3 @@
import { MigrationFailureReason } from '../migrate'
import { SerializedStore } from '../Store' import { SerializedStore } from '../Store'
import { testSchemaV0 } from './testSchema.v0' import { testSchemaV0 } from './testSchema.v0'
import { testSchemaV1 } from './testSchema.v1' import { testSchemaV1 } from './testSchema.v1'
@ -9,23 +8,8 @@ const serializedV1Schenma = testSchemaV1.serialize()
test('serializedV0Schenma', () => { test('serializedV0Schenma', () => {
expect(serializedV0Schenma).toMatchInlineSnapshot(` expect(serializedV0Schenma).toMatchInlineSnapshot(`
{ {
"recordVersions": { "schemaVersion": 2,
"org": { "sequences": {},
"version": 0,
},
"shape": {
"subTypeKey": "type",
"subTypeVersions": {
"rectangle": 0,
},
"version": 0,
},
"user": {
"version": 0,
},
},
"schemaVersion": 1,
"storeVersion": 0,
} }
`) `)
}) })
@ -33,188 +17,18 @@ test('serializedV0Schenma', () => {
test('serializedV1Schenma', () => { test('serializedV1Schenma', () => {
expect(serializedV1Schenma).toMatchInlineSnapshot(` expect(serializedV1Schenma).toMatchInlineSnapshot(`
{ {
"recordVersions": { "schemaVersion": 2,
"shape": { "sequences": {
"subTypeKey": "type", "com.tldraw.shape": 2,
"subTypeVersions": { "com.tldraw.shape.oval": 1,
"oval": 1, "com.tldraw.shape.rectangle": 1,
"rectangle": 1, "com.tldraw.store": 1,
}, "com.tldraw.user": 2,
"version": 2,
},
"user": {
"version": 2,
},
}, },
"schemaVersion": 1,
"storeVersion": 1,
} }
`) `)
}) })
describe('migrating from v0 to v1', () => {
it('works for a user', () => {
const user = {
id: 'user-1',
typeName: 'user',
name: 'name',
}
const userResult = testSchemaV1.migratePersistedRecord(user as any, serializedV0Schenma)
if (userResult.type !== 'success') {
throw new Error('Migration failed')
}
expect(userResult.value).toEqual({
id: 'user-1',
typeName: 'user',
name: 'name',
locale: 'en',
phoneNumber: null,
})
})
it('works for a rectangle', () => {
const rectangle = {
id: 'shape-1',
typeName: 'shape',
x: 0,
y: 0,
type: 'rectangle',
props: {
width: 100,
height: 100,
},
}
const shapeResult = testSchemaV1.migratePersistedRecord(rectangle as any, serializedV0Schenma)
if (shapeResult.type !== 'success') {
throw new Error('Migration failed')
}
expect(shapeResult.value).toEqual({
id: 'shape-1',
typeName: 'shape',
x: 0,
y: 0,
rotation: 0,
parentId: null,
type: 'rectangle',
props: {
width: 100,
height: 100,
opacity: 1,
},
})
})
it('does not work for an oval because the oval didnt exist in v0', () => {
const oval = {
id: 'shape-2',
typeName: 'shape',
x: 0,
y: 0,
type: 'oval',
props: {
radius: 50,
},
}
const ovalResult = testSchemaV1.migratePersistedRecord(oval as any, serializedV0Schenma)
expect(ovalResult).toEqual({
type: 'error',
reason: MigrationFailureReason.IncompatibleSubtype,
})
})
})
describe('migrating from v1 to v0', () => {
it('works for a user', () => {
const user = {
id: 'user-1',
typeName: 'user',
name: 'name',
locale: 'en',
phoneNumber: null,
}
const userResult = testSchemaV1.migratePersistedRecord(user as any, serializedV0Schenma, 'down')
if (userResult.type !== 'success') {
console.error(userResult)
throw new Error('Migration failed')
}
expect(userResult.value).toEqual({
id: 'user-1',
typeName: 'user',
name: 'name',
})
})
it('works for a rectangle', () => {
const rectangle = {
id: 'shape-1',
typeName: 'shape',
x: 0,
y: 0,
rotation: 0,
parentId: null,
type: 'rectangle',
props: {
width: 100,
height: 100,
opacity: 1,
},
}
const shapeResult = testSchemaV1.migratePersistedRecord(
rectangle as any,
serializedV0Schenma,
'down'
)
if (shapeResult.type !== 'success') {
console.error(shapeResult)
throw new Error('Migration failed')
}
expect(shapeResult.value).toEqual({
id: 'shape-1',
typeName: 'shape',
x: 0,
y: 0,
type: 'rectangle',
props: {
width: 100,
height: 100,
},
})
})
it('does not work for an oval because the oval didnt exist in v0', () => {
const oval = {
id: 'shape-2',
typeName: 'shape',
x: 0,
y: 0,
type: 'oval',
props: {
radius: 50,
},
}
const ovalResult = testSchemaV1.migratePersistedRecord(oval as any, serializedV0Schenma, 'down')
expect(ovalResult).toEqual({
type: 'error',
reason: MigrationFailureReason.IncompatibleSubtype,
})
})
})
test('unknown types fail', () => { test('unknown types fail', () => {
expect( expect(
testSchemaV1.migratePersistedRecord( testSchemaV1.migratePersistedRecord(
@ -225,9 +39,8 @@ test('unknown types fail', () => {
serializedV0Schenma, serializedV0Schenma,
'up' 'up'
) )
).toEqual({ ).toMatchObject({
type: 'error', type: 'error',
reason: MigrationFailureReason.UnknownType,
}) })
expect( expect(
@ -239,68 +52,8 @@ test('unknown types fail', () => {
serializedV0Schenma, serializedV0Schenma,
'down' 'down'
) )
).toEqual({ ).toMatchObject({
type: 'error', type: 'error',
reason: MigrationFailureReason.UnknownType,
})
})
test('versions in the future fail', () => {
expect(
testSchemaV0.migratePersistedRecord(
{
id: 'whatevere',
typeName: 'user',
name: 'steve',
} as any,
serializedV1Schenma
)
).toEqual({
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
})
})
test('unrecogized subtypes fail', () => {
expect(
testSchemaV1.migratePersistedRecord(
{
id: 'whatevere',
typeName: 'shape',
type: 'whatever',
} as any,
serializedV0Schenma
)
).toEqual({
type: 'error',
reason: MigrationFailureReason.UnrecognizedSubtype,
})
})
test('subtype versions in the future fail', () => {
expect(
testSchemaV0.migratePersistedRecord(
{
id: 'whatevere',
typeName: 'shape',
type: 'rectangle',
} as any,
{
schemaVersion: 0,
storeVersion: 0,
recordVersions: {
shape: {
version: 0,
subTypeVersions: {
rectangle: 1,
},
},
},
}
)
).toEqual({
type: 'error',
reason: MigrationFailureReason.TargetVersionTooOld,
}) })
}) })

View file

@ -0,0 +1,265 @@
import assert from 'assert'
import { BaseRecord, RecordId } from '../BaseRecord'
import { createRecordType } from '../RecordType'
import { SerializedSchemaV2, StoreSchema } from '../StoreSchema'
import { MigrationSequence } from '../migrate'
const mockSequence = ({
id,
retroactive,
versions,
filter,
}: {
id: string
retroactive: boolean
versions: number
filter?: (r: TestRecordType) => boolean
}): MigrationSequence => ({
sequenceId: id,
retroactive,
sequence: new Array(versions).fill(0).map((_, i) => ({
id: `${id}/${i + 1}`,
scope: 'record',
filter: filter as any,
up(r) {
const record = r as TestRecordType
record.versions[id] ??= 0
record.versions[id]++
// noop
},
down(r) {
const record = r as TestRecordType
record.versions[id]--
},
})),
})
interface TestRecordType extends BaseRecord<'test', RecordId<TestRecordType>> {
versions: Record<string, number>
}
const TestRecordType = createRecordType<TestRecordType>('test', {
scope: 'document',
})
const makeSchema = (migrations: MigrationSequence[]) => {
return StoreSchema.create({ test: TestRecordType }, { migrations })
}
const makePersistedSchema = (...args: Array<[migrations: MigrationSequence, version: number]>) => {
return {
schemaVersion: 2,
sequences: Object.fromEntries(args.map(([m, v]) => [m.sequenceId, v])),
} satisfies SerializedSchemaV2
}
const makeTestRecord = (persistedSchema: SerializedSchemaV2) => {
return TestRecordType.create({
versions: Object.fromEntries(
Object.keys(persistedSchema.sequences).map((id) => [id, persistedSchema.sequences[id]])
),
})
}
test('going up from 0', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
const r = makeTestRecord(persistedSchema)
expect(r.versions).toEqual({ foo: 0, bar: 0 })
const update = schema.migratePersistedRecord(r, persistedSchema)
assert(update.type === 'success', 'the update should be successful')
// the original record did not change
expect(r.versions).toEqual({ foo: 0, bar: 0 })
// the updated record has the new versions
expect((update.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
})
test('going up with a retroactive: true and a retroactive: false', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema()
const r = makeTestRecord(persistedSchema)
expect(r.versions).toEqual({})
const update = schema.migratePersistedRecord(r, persistedSchema)
assert(update.type === 'success', 'the update should be successful')
// the original record did not change
expect(r.versions).toEqual({})
// the updated record has the new versions
expect((update.value as TestRecordType).versions).toEqual({ foo: 2 })
})
test('going down to 0s', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
const r = makeTestRecord(schema.serialize())
expect(r.versions).toEqual({ foo: 2, bar: 3 })
const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
assert(downgrade.type === 'success', 'the downgrade should be successful')
// the original record did not change
expect(r.versions).toEqual({ foo: 2, bar: 3 })
// the downgraded record has the new versions
expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 0 })
})
test('going down with a retroactive: true and a retroactive: false', () => {
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema()
const r = makeTestRecord(schema.serialize())
expect(r.versions).toEqual({ foo: 2, bar: 3 })
const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
assert(downgrade.type === 'success', 'the downgrade should be successful')
// the original record did not change
expect(r.versions).toEqual({ foo: 2, bar: 3 })
// only the foo migrations were undone
expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
})
test('going up with no changes', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema([foo, 2], [bar, 3])
const r = makeTestRecord(persistedSchema)
expect(r.versions).toEqual({ foo: 2, bar: 3 })
const update = schema.migratePersistedRecord(r, persistedSchema)
assert(update.type === 'success', 'the update should be successful')
// the returned record should be the the input record, i.e. it should not have allocated a new record
expect(r).toBe(update.value)
})
test('going down with no changes', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema([foo, 2], [bar, 3])
const r = makeTestRecord(persistedSchema)
expect(r.versions).toEqual({ foo: 2, bar: 3 })
const update = schema.migratePersistedRecord(r, persistedSchema, 'down')
assert(update.type === 'success', 'the update should be successful')
// the returned record should be the the input record, i.e. it should not have allocated a new record
expect(r).toBe(update.value)
})
test('respects filters', () => {
const foo = mockSequence({
id: 'foo',
retroactive: false,
versions: 2,
filter: (r) => (r as any).foo === true,
})
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
const schema = makeSchema([foo, bar])
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
const r = makeTestRecord(persistedSchema)
const update = schema.migratePersistedRecord(r, persistedSchema, 'up')
assert(update.type === 'success', 'the update should be successful')
// foo migrations shouldn't have been applied
expect((update.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
const r2 = { ...r, foo: true }
const update2 = schema.migratePersistedRecord(r2, persistedSchema, 'up')
assert(update2.type === 'success', 'the update should be successful')
// foo migrations should have been applied
expect((update2.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
})
test('does not go up or down if theres a store migration in the path', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
foo.sequence[1] = {
id: 'foo/2',
scope: 'store',
up() {
// noop
},
down() {
// noop
},
}
const schema = makeSchema([foo])
const v0Schema = makePersistedSchema([foo, 0])
const r0 = makeTestRecord(v0Schema)
const r3 = makeTestRecord(schema.serialize())
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
expect(update.type).toBe('error')
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
expect(update2.type).toBe('error')
// snapshot migration up should still work
const update3 = schema.migrateStoreSnapshot({
schema: v0Schema,
store: { [r0.id]: r0 },
})
assert(update3.type === 'success', 'the update should be successful')
expect((update3.value[r0.id] as TestRecordType).versions).toEqual({ foo: 2 })
})
test('does not go down if theres a migrations without the down migrator in the path', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
delete (foo.sequence[1] as any).down
const schema = makeSchema([foo])
const v0Schema = makePersistedSchema([foo, 0])
// going up still works
const r0 = makeTestRecord(v0Schema)
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
expect(update.type).toBe('success')
// going down does not
const r3 = makeTestRecord(schema.serialize())
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
expect(update2.type).toBe('error')
})
test('allows returning a new record from the migrator fn', () => {
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
foo.sequence[1] = {
id: 'foo/2',
scope: 'record',
up(r) {
const record = r as TestRecordType
return { ...record, versions: { ...record.versions, foo: 2 } }
},
down(r) {
const record = r as TestRecordType
return { ...record, versions: { ...record.versions, foo: 1 } }
},
}
const schema = makeSchema([foo])
const v0Schema = makePersistedSchema([foo, 0])
const r0 = makeTestRecord(v0Schema)
const r3 = makeTestRecord(schema.serialize())
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
assert(update.type === 'success', 'the update should be successful')
expect((update.value as TestRecordType).versions).toEqual({ foo: 3 })
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
assert(update2.type === 'success', 'the update should be successful')
expect((update2.value as TestRecordType).versions).toEqual({ foo: 0 })
})

View file

@ -1,5 +1,6 @@
import { Computed, react, RESET_VALUE, transact } from '@tldraw/state' import { Computed, react, RESET_VALUE, transact } from '@tldraw/state'
import { BaseRecord, RecordId } from '../BaseRecord' import { BaseRecord, RecordId } from '../BaseRecord'
import { createMigrationSequence } from '../migrate'
import { createRecordType } from '../RecordType' import { createRecordType } from '../RecordType'
import { CollectionDiff, RecordsDiff, Store } from '../Store' import { CollectionDiff, RecordsDiff, Store } from '../Store'
import { StoreSchema } from '../StoreSchema' import { StoreSchema } from '../StoreSchema'
@ -47,20 +48,11 @@ describe('Store', () => {
beforeEach(() => { beforeEach(() => {
store = new Store({ store = new Store({
props: {}, props: {},
schema: StoreSchema.create<LibraryType>( schema: StoreSchema.create<LibraryType>({
{ book: Book,
book: Book, author: Author,
author: Author, visit: Visit,
visit: Visit, }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
}) })
@ -762,19 +754,10 @@ describe('snapshots', () => {
beforeEach(() => { beforeEach(() => {
store = new Store({ store = new Store({
props: {}, props: {},
schema: StoreSchema.create<Book | Author>( schema: StoreSchema.create<Book | Author>({
{ book: Book,
book: Book, author: Author,
author: Author, }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
transact(() => { transact(() => {
@ -808,19 +791,10 @@ describe('snapshots', () => {
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
schema: StoreSchema.create<Book | Author>( schema: StoreSchema.create<Book | Author>({
{ book: Book,
book: Book, author: Author,
author: Author, }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
store2.loadSnapshot(snapshot1) store2.loadSnapshot(snapshot1)
@ -839,25 +813,16 @@ describe('snapshots', () => {
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
schema: StoreSchema.create<Book>( schema: StoreSchema.create<Book>({
{ book: Book,
book: Book, // no author
// no author }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
expect(() => { expect(() => {
// @ts-expect-error // @ts-expect-error
store2.loadSnapshot(snapshot1) store2.loadSnapshot(snapshot1)
}).toThrowErrorMatchingInlineSnapshot(`"Failed to migrate snapshot: unknown-type"`) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
}) })
it('throws errors when loading a snapshot with a different schema', () => { it('throws errors when loading a snapshot with a different schema', () => {
@ -865,28 +830,23 @@ describe('snapshots', () => {
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
schema: StoreSchema.create<Book | Author>( schema: StoreSchema.create<Book>({
{ book: Book,
book: Book, }),
author: Author,
},
{
snapshotMigrations: {
currentVersion: -1,
firstVersion: 0,
migrators: {},
},
}
),
}) })
expect(() => { expect(() => {
store2.loadSnapshot(snapshot1) store2.loadSnapshot(snapshot1 as any)
}).toThrowErrorMatchingInlineSnapshot(`"Failed to migrate snapshot: target-version-too-old"`) }).toThrowErrorMatchingInlineSnapshot(`"Missing definition for record type author"`)
}) })
it('migrates the snapshot', () => { it('migrates the snapshot', () => {
const snapshot1 = store.getSnapshot() const snapshot1 = store.getSnapshot()
const up = jest.fn((s: any) => {
s['book:lotr'].numPages = 42
})
expect((snapshot1.store as any)['book:lotr'].numPages).toBe(1000)
const store2 = new Store({ const store2 = new Store({
props: {}, props: {},
@ -896,16 +856,19 @@ describe('snapshots', () => {
author: Author, author: Author,
}, },
{ {
snapshotMigrations: { migrations: [
currentVersion: 1, createMigrationSequence({
firstVersion: 0, sequenceId: 'com.tldraw',
migrators: { retroactive: true,
1: { sequence: [
up: (r) => r, {
down: (r) => r, id: `com.tldraw/1`,
}, scope: 'store',
}, up,
}, },
],
}),
],
} }
), ),
}) })
@ -913,5 +876,8 @@ describe('snapshots', () => {
expect(() => { expect(() => {
store2.loadSnapshot(snapshot1) store2.loadSnapshot(snapshot1)
}).not.toThrow() }).not.toThrow()
expect(up).toHaveBeenCalledTimes(1)
expect(store2.get(Book.createId('lotr'))!.numPages).toBe(42)
}) })
}) })

View file

@ -328,19 +328,10 @@ const NUM_OPS = 200
function runTest(seed: number) { function runTest(seed: number) {
const store = new Store({ const store = new Store({
props: {}, props: {},
schema: StoreSchema.create<Book | Author>( schema: StoreSchema.create<Book | Author>({
{ book: Book,
book: Book, author: Author,
author: Author, }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
store.onBeforeDelete = (record) => { store.onBeforeDelete = (record) => {
if (record.typeName === 'author') { if (record.typeName === 'author') {

View file

@ -61,19 +61,10 @@ let store: Store<Author | Book>
beforeEach(() => { beforeEach(() => {
store = new Store({ store = new Store({
props: {}, props: {},
schema: StoreSchema.create<Author | Book>( schema: StoreSchema.create<Author | Book>({
{ author: Author,
author: Author, book: Book,
book: Book, }),
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
),
}) })
store.put([ store.put([
authors.tolkein, authors.tolkein,

View file

@ -0,0 +1,51 @@
import { Migration, MigrationId, sortMigrations } from '../migrate'
describe(sortMigrations, () => {
const m = (id: MigrationId, others?: { dependsOn?: MigrationId[] }): Migration => ({
...others,
id,
scope: 'record',
up() {
// noop
},
})
const sort = (migrations: Migration[]) => {
return sortMigrations(migrations).map((m) => m.id)
}
it('should sort migrations based on version number', () => {
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('should sort multiple migration sequences based on version number', () => {
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('should use dependsOn to resolve inter-sequence 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('should fail if a cycle is created', () => {
expect(() => {
sort([m('foo/1', { dependsOn: ['foo/1'] })])
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
expect(() => {
sort([m('foo/1', { dependsOn: ['foo/2'] }), m('foo/2')])
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
expect(() => {
sort([m('foo/1', { dependsOn: ['bar/1'] }), m('bar/1', { dependsOn: ['foo/1'] })])
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
expect(() => {
sort([m('bar/1', { dependsOn: ['foo/1'] }), m('foo/1', { dependsOn: ['bar/1'] })])
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: bar/1"`)
})
})

View file

@ -2,17 +2,13 @@ import assert from 'assert'
import { BaseRecord, RecordId } from '../BaseRecord' import { BaseRecord, RecordId } from '../BaseRecord'
import { createRecordType } from '../RecordType' import { createRecordType } from '../RecordType'
import { StoreSchema } from '../StoreSchema' import { StoreSchema } from '../StoreSchema'
import { defineMigrations } from '../migrate'
/** A user of tldraw */ /** A user of tldraw */
interface User extends BaseRecord<'user', RecordId<User>> { interface User extends BaseRecord<'user', RecordId<User>> {
name: string name: string
} }
const userMigrations = defineMigrations({})
const User = createRecordType<User>('user', { const User = createRecordType<User>('user', {
migrations: userMigrations,
validator: { validator: {
validate: (record) => { validate: (record) => {
assert( assert(
@ -42,15 +38,7 @@ interface OvalProps {
borderStyle: 'solid' | 'dashed' borderStyle: 'solid' | 'dashed'
} }
const shapeTypeMigrations = defineMigrations({
subTypeKey: 'type',
subTypeMigrations: {
rectangle: defineMigrations({}),
},
})
const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', { const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
migrations: shapeTypeMigrations,
validator: { validator: {
validate: (record) => { validate: (record) => {
assert( assert(
@ -77,7 +65,6 @@ interface Org extends BaseRecord<'org', RecordId<Org>> {
} }
const Org = createRecordType<Org>('org', { const Org = createRecordType<Org>('org', {
migrations: defineMigrations({}),
validator: { validator: {
validate: (record) => { validate: (record) => {
assert( assert(
@ -89,13 +76,8 @@ const Org = createRecordType<Org>('org', {
scope: 'document', scope: 'document',
}) })
export const testSchemaV0 = StoreSchema.create( export const testSchemaV0 = StoreSchema.create({
{ user: User,
user: User, shape: Shape,
shape: Shape, org: Org,
org: Org, })
},
{
snapshotMigrations: defineMigrations({}),
}
)

View file

@ -3,12 +3,12 @@ import { BaseRecord, RecordId } from '../BaseRecord'
import { createRecordType } from '../RecordType' import { createRecordType } from '../RecordType'
import { SerializedStore } from '../Store' import { SerializedStore } from '../Store'
import { StoreSchema } from '../StoreSchema' import { StoreSchema } from '../StoreSchema'
import { defineMigrations } from '../migrate' import { createMigrationIds, createMigrationSequence } from '../migrate'
const UserVersion = { const UserVersion = createMigrationIds('com.tldraw.user', {
AddLocale: 1, AddLocale: 1,
AddPhoneNumber: 2, AddPhoneNumber: 2,
} as const } as const)
/** A user of tldraw */ /** A user of tldraw */
interface User extends BaseRecord<'user', RecordId<User>> { interface User extends BaseRecord<'user', RecordId<User>> {
@ -17,36 +17,36 @@ interface User extends BaseRecord<'user', RecordId<User>> {
phoneNumber: string | null phoneNumber: string | null
} }
const userMigrations = defineMigrations({ const userMigrations = createMigrationSequence({
currentVersion: UserVersion.AddPhoneNumber, sequenceId: 'com.tldraw.user',
migrators: { retroactive: true,
[UserVersion.AddLocale]: { sequence: [
up: (record) => ({ {
...record, id: UserVersion.AddLocale,
locale: 'en', scope: 'record',
}), filter: (r) => r.typeName === 'user',
down: (record) => { up: (record: any) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars record.locale = 'en'
const { locale, ...rest } = record },
return rest down: (record: any) => {
delete record.locale
}, },
}, },
[UserVersion.AddPhoneNumber]: { {
up: (record) => ({ id: UserVersion.AddPhoneNumber,
...record, scope: 'record',
phoneNumber: null, filter: (r) => r.typeName === 'user',
}), up: (record: any) => {
down: (record) => { record.phoneNumber = null
// eslint-disable-next-line @typescript-eslint/no-unused-vars },
const { phoneNumber, ...rest } = record down: (record: any) => {
return rest delete record.phoneNumber
}, },
}, },
}, ],
}) })
const User = createRecordType<User>('user', { const User = createRecordType<User>('user', {
migrations: userMigrations,
validator: { validator: {
validate: (record) => { validate: (record) => {
assert(record && typeof record === 'object') assert(record && typeof record === 'object')
@ -66,18 +66,18 @@ const User = createRecordType<User>('user', {
name: 'New User', name: 'New User',
})) }))
const ShapeVersion = { const ShapeVersion = createMigrationIds('com.tldraw.shape', {
AddRotation: 1, AddRotation: 1,
AddParent: 2, AddParent: 2,
} as const } as const)
const RectangleVersion = { const RectangleVersion = createMigrationIds('com.tldraw.shape.rectangle', {
AddOpacity: 1, AddOpacity: 1,
} as const } as const)
const OvalVersion = { const OvalVersion = createMigrationIds('com.tldraw.shape.oval', {
AddBorderStyle: 1, AddBorderStyle: 1,
} as const } as const)
type ShapeId = RecordId<Shape<object>> type ShapeId = RecordId<Shape<object>>
@ -101,81 +101,72 @@ interface OvalProps {
borderStyle: 'solid' | 'dashed' borderStyle: 'solid' | 'dashed'
} }
const shapeTypeMigrations = defineMigrations({ const rootShapeMigrations = createMigrationSequence({
currentVersion: ShapeVersion.AddParent, sequenceId: 'com.tldraw.shape',
migrators: { retroactive: true,
[ShapeVersion.AddRotation]: { sequence: [
up: (record) => ({ {
...record, id: ShapeVersion.AddRotation,
rotation: 0, scope: 'record',
}), filter: (r) => r.typeName === 'shape',
down: (record) => { up: (record: any) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars record.rotation = 0
const { rotation, ...rest } = record },
return rest down: (record: any) => {
delete record.rotation
}, },
}, },
[ShapeVersion.AddParent]: { {
up: (record) => ({ id: ShapeVersion.AddParent,
...record, scope: 'record',
parentId: null, filter: (r) => r.typeName === 'shape',
}), up: (record: any) => {
down: (record) => { record.parentId = null
// eslint-disable-next-line @typescript-eslint/no-unused-vars },
const { parentId, ...rest } = record down: (record: any) => {
return rest delete record.parentId
}, },
}, },
}, ],
subTypeKey: 'type', })
subTypeMigrations: {
rectangle: defineMigrations({ const rectangleMigrations = createMigrationSequence({
currentVersion: RectangleVersion.AddOpacity, sequenceId: 'com.tldraw.shape.rectangle',
migrators: { retroactive: true,
[RectangleVersion.AddOpacity]: { sequence: [
up: (record) => ({ {
...record, id: RectangleVersion.AddOpacity,
props: { scope: 'record',
...record.props, filter: (r) => r.typeName === 'shape' && (r as Shape<RectangleProps>).type === 'rectangle',
opacity: 1, up: (record: any) => {
}, record.props.opacity = 1
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
down: ({ props: { opacity, ...others }, ...record }) => ({
...record,
props: {
...others,
},
}),
},
}, },
}), down: (record: any) => {
oval: defineMigrations({ delete record.props.opacity
currentVersion: OvalVersion.AddBorderStyle,
migrators: {
[OvalVersion.AddBorderStyle]: {
up: (record) => ({
...record,
props: {
...record.props,
borderStyle: 'solid',
},
}),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
down: ({ props: { borderStyle, ...others }, ...record }) => ({
...record,
props: {
...others,
},
}),
},
}, },
}), },
}, ],
})
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', { const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
migrations: shapeTypeMigrations,
validator: { validator: {
validate: (record) => { validate: (record) => {
assert(record && typeof record === 'object') assert(record && typeof record === 'object')
@ -195,14 +186,17 @@ const Shape = createRecordType<Shape<RectangleProps | OvalProps>>('shape', {
parentId: null, parentId: null,
})) }))
const StoreVersions = { const StoreVersions = createMigrationIds('com.tldraw.store', {
RemoveOrg: 1, RemoveOrg: 1,
} })
const snapshotMigrations = defineMigrations({ const snapshotMigrations = createMigrationSequence({
currentVersion: StoreVersions.RemoveOrg, sequenceId: 'com.tldraw.store',
migrators: { retroactive: true,
[StoreVersions.RemoveOrg]: { sequence: [
{
id: StoreVersions.RemoveOrg,
scope: 'store',
up: (store: SerializedStore<any>) => { up: (store: SerializedStore<any>) => {
return Object.fromEntries(Object.entries(store).filter(([_, r]) => r.typeName !== 'org')) return Object.fromEntries(Object.entries(store).filter(([_, r]) => r.typeName !== 'org'))
}, },
@ -211,7 +205,7 @@ const snapshotMigrations = defineMigrations({
return store return store
}, },
}, },
}, ],
}) })
export const testSchemaV1 = StoreSchema.create<User | Shape<any>>( export const testSchemaV1 = StoreSchema.create<User | Shape<any>>(
@ -220,6 +214,12 @@ export const testSchemaV1 = StoreSchema.create<User | Shape<any>>(
shape: Shape, shape: Shape,
}, },
{ {
snapshotMigrations, migrations: [
snapshotMigrations,
rootShapeMigrations,
rectangleMigrations,
ovalMigrations,
userMigrations,
],
} }
) )

View file

@ -0,0 +1,79 @@
import { SerializedSchemaV1, upgradeSchema } from '../StoreSchema'
describe('upgradeSchema', () => {
it('should upgrade a schema from v1 to v2, assuming its working with tldraw data', () => {
const v1: SerializedSchemaV1 = {
schemaVersion: 1,
storeVersion: 4,
recordVersions: {
asset: {
version: 1,
subTypeKey: 'type',
subTypeVersions: { image: 2, video: 2, bookmark: 0 },
},
camera: { version: 1 },
document: { version: 2 },
instance: { version: 22 },
instance_page_state: { version: 5 },
page: { version: 1 },
shape: {
version: 3,
subTypeKey: 'type',
subTypeVersions: {
group: 0,
text: 1,
bookmark: 1,
draw: 1,
geo: 7,
note: 4,
line: 1,
frame: 0,
arrow: 1,
highlight: 0,
embed: 4,
image: 2,
video: 1,
},
},
instance_presence: { version: 5 },
pointer: { version: 1 },
},
}
expect(upgradeSchema(v1)).toMatchInlineSnapshot(`
{
"ok": true,
"value": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.asset": 1,
"com.tldraw.asset.bookmark": 0,
"com.tldraw.asset.image": 2,
"com.tldraw.asset.video": 2,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 22,
"com.tldraw.instance_page_state": 5,
"com.tldraw.instance_presence": 5,
"com.tldraw.page": 1,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 3,
"com.tldraw.shape.arrow": 1,
"com.tldraw.shape.bookmark": 1,
"com.tldraw.shape.draw": 1,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.geo": 7,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.highlight": 0,
"com.tldraw.shape.image": 2,
"com.tldraw.shape.line": 1,
"com.tldraw.shape.note": 4,
"com.tldraw.shape.text": 1,
"com.tldraw.shape.video": 1,
},
},
}
`)
})
})

View file

@ -45,19 +45,10 @@ const Author = createRecordType<Author>('author', {
isPseudonym: false, isPseudonym: false,
})) }))
const schema = StoreSchema.create<Book | Author>( const schema = StoreSchema.create<Book | Author>({
{ book: Book,
book: Book, author: Author,
author: Author, })
},
{
snapshotMigrations: {
currentVersion: 0,
firstVersion: 0,
migrators: {},
},
}
)
describe('Store with validation', () => { describe('Store with validation', () => {
let store: Store<Book | Author> let store: Store<Book | Author>

View file

@ -0,0 +1,165 @@
import { validateMigrations } from '../migrate'
describe(validateMigrations, () => {
it('should throw if a migration id is invalid', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo.1' as any,
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.1'"`
)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo/one' as any,
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(`"Invalid migration id: 'foo/one'"`)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo/1' as any,
scope: 'record',
up() {
// noop
},
},
{
id: 'foo.2' as any,
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.2'"`
)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo/1' as any,
scope: 'record',
up() {
// noop
},
},
{
id: 'foo/two' as any,
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(`"Invalid migration id: 'foo/two'"`)
})
it('should throw if the sequenceId is invalid', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [],
sequenceId: 'foo/bar',
})
).toThrowErrorMatchingInlineSnapshot(`"sequenceId cannot contain a '/', got foo/bar"`)
expect(() =>
validateMigrations({
retroactive: false,
sequence: [],
sequenceId: '',
})
).toThrowErrorMatchingInlineSnapshot(`"sequenceId must be a non-empty string"`)
})
it('should throw if the version numbers do not start at 1', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo/2',
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"Expected the first migrationId to be 'foo/1' but got 'foo/2'"`
)
})
it('should throw if the version numbers do not increase monotonically', () => {
expect(() =>
validateMigrations({
retroactive: false,
sequence: [
{
id: 'foo/1',
scope: 'record',
up() {
// noop
},
},
{
id: 'foo/2',
scope: 'record',
up() {
// noop
},
},
{
id: 'foo/4',
scope: 'record',
up() {
// noop
},
},
],
sequenceId: 'foo',
})
).toThrowErrorMatchingInlineSnapshot(
`"Migration id numbers must increase in increments of 1, expected foo/3 but got 'foo/4'"`
)
})
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,7 @@
"@radix-ui/react-slider": "^1.1.0", "@radix-ui/react-slider": "^1.1.0",
"@radix-ui/react-toast": "^1.1.1", "@radix-ui/react-toast": "^1.1.1",
"@tldraw/editor": "workspace:*", "@tldraw/editor": "workspace:*",
"@tldraw/store": "workspace:*",
"canvas-size": "^1.2.6", "canvas-size": "^1.2.6",
"classnames": "^2.3.2", "classnames": "^2.3.2",
"hotkeys-js": "^3.11.2", "hotkeys-js": "^3.11.2",

View file

@ -3,6 +3,7 @@ import {
ErrorScreen, ErrorScreen,
Expand, Expand,
LoadingScreen, LoadingScreen,
MigrationSequence,
StoreSnapshot, StoreSnapshot,
TLEditorComponents, TLEditorComponents,
TLOnMountHandler, TLOnMountHandler,
@ -55,6 +56,7 @@ export type TldrawProps = Expand<
} }
| { | {
store?: undefined store?: undefined
migrations?: readonly MigrationSequence[]
persistenceKey?: string persistenceKey?: string
sessionId?: string sessionId?: string
defaultName?: string defaultName?: string

View file

@ -32,6 +32,7 @@ export async function preloadFont(id: string, font: TLTypeFace) {
featureSettings, featureSettings,
stretch, stretch,
unicodeRange, unicodeRange,
// @ts-expect-error why is this here
variant, variant,
} }

View file

@ -6,6 +6,8 @@ import {
RecordId, RecordId,
Result, Result,
SerializedSchema, SerializedSchema,
SerializedSchemaV1,
SerializedSchemaV2,
SerializedStore, SerializedStore,
T, T,
TLAsset, TLAsset,
@ -40,19 +42,29 @@ export interface TldrawFile {
records: UnknownRecord[] records: UnknownRecord[]
} }
const schemaV1 = T.object<SerializedSchemaV1>({
schemaVersion: T.literal(1),
storeVersion: T.positiveInteger,
recordVersions: T.dict(
T.string,
T.object({
version: T.positiveInteger,
subTypeVersions: T.dict(T.string, T.positiveInteger).optional(),
subTypeKey: T.string.optional(),
})
),
})
const schemaV2 = T.object<SerializedSchemaV2>({
schemaVersion: T.literal(2),
sequences: T.dict(T.string, T.positiveInteger),
})
const tldrawFileValidator: T.Validator<TldrawFile> = T.object({ const tldrawFileValidator: T.Validator<TldrawFile> = T.object({
tldrawFileFormatVersion: T.nonZeroInteger, tldrawFileFormatVersion: T.nonZeroInteger,
schema: T.object({ schema: T.union('schemaVersion', {
schemaVersion: T.positiveInteger, 1: schemaV1,
storeVersion: T.positiveInteger, 2: schemaV2,
recordVersions: T.dict(
T.string,
T.object({
version: T.positiveInteger,
subTypeVersions: T.dict(T.string, T.positiveInteger).optional(),
subTypeKey: T.string.optional(),
})
),
}), }),
records: T.arrayOf( records: T.arrayOf(
T.object({ T.object({

View file

@ -15,10 +15,6 @@ import { GeoShapeUtil } from '../lib/shapes/geo/GeoShapeUtil'
import { renderTldrawComponent } from './testutils/renderTldrawComponent' import { renderTldrawComponent } from './testutils/renderTldrawComponent'
function checkAllShapes(editor: Editor, shapes: string[]) { function checkAllShapes(editor: Editor, shapes: string[]) {
expect(Object.keys(editor!.store.schema.types.shape.migrations.subTypeMigrations!)).toStrictEqual(
shapes
)
expect(Object.keys(editor!.shapeUtils)).toStrictEqual(shapes) expect(Object.keys(editor!.shapeUtils)).toStrictEqual(shapes)
} }

View file

@ -10,6 +10,9 @@
"references": [ "references": [
{ {
"path": "../editor" "path": "../editor"
},
{
"path": "../store"
} }
] ]
} }

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,57 @@
import { Migration, MigrationId, Store, UnknownRecord } from '@tldraw/store'
import { structuredClone } from '@tldraw/utils'
import { createTLSchema } from '../createTLSchema'
export const testSchema = createTLSchema()
// mock all migrator fns
for (const migration of testSchema.sortedMigrations) {
;(migration as any).up = jest.fn(migration.up as any)
if (typeof migration.down === 'function') {
;(migration as any).down = jest.fn(migration.down as any)
}
}
function getEmptySnapshot() {
const store = new Store({
schema: testSchema,
props: null as any,
})
store.ensureStoreIsUsable()
return store.getSnapshot()
}
export function snapshotUp(migrationId: MigrationId, ...records: UnknownRecord[]) {
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
if (!migration) {
throw new Error(`Migration ${migrationId} not found`)
}
const snapshot = getEmptySnapshot()
for (const record of records) {
snapshot.store[record.id as any] = structuredClone(record as any)
}
const result = migration.up(snapshot.store as any)
return result ?? snapshot.store
}
export function getTestMigration(migrationId: MigrationId) {
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
if (!migration) {
throw new Error(`Migration ${migrationId} not found`)
}
return {
id: migrationId,
up: (stuff: any) => {
const result = structuredClone(stuff)
return migration.up(result) ?? result
},
down: (stuff: any) => {
if (typeof migration.down !== 'function') {
throw new Error(`Migration ${migrationId} does not have a down function`)
}
const result = structuredClone(stuff)
return migration.down(result) ?? result
},
}
}

View file

@ -1,6 +1,7 @@
import { defineMigrations } from '@tldraw/store' import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { createAssetValidator, TLBaseAsset } from './TLBaseAsset' import { TLAsset } from '../records/TLAsset'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/** /**
* An asset used for URL bookmarks, used by the TLBookmarkShape. * An asset used for URL bookmarks, used by the TLBookmarkShape.
@ -27,23 +28,28 @@ export const bookmarkAssetValidator: T.Validator<TLBookmarkAsset> = createAssetV
}) })
) )
const Versions = { const Versions = createMigrationIds('com.tldraw.asset.bookmark', {
MakeUrlsValid: 1, MakeUrlsValid: 1,
} as const } as const)
export { Versions as bookmarkAssetVersions }
/** @internal */ /** @internal */
export const bookmarkAssetMigrations = defineMigrations({ export const bookmarkAssetMigrations = createRecordMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequenceId: 'com.tldraw.asset.bookmark',
migrators: { recordType: 'asset',
[Versions.MakeUrlsValid]: { filter: (asset) => (asset as TLAsset).type === 'bookmark',
up: (asset) => { sequence: [
const src = asset.props.src {
if (src && !T.srcUrl.isValid(src)) { id: Versions.MakeUrlsValid,
return { ...asset, props: { ...asset.props, src: '' } } up: (asset: any) => {
if (!T.srcUrl.isValid(asset.props.src)) {
asset.props.src = ''
} }
return asset
}, },
down: (asset) => asset, down: (_asset) => {
// noop
},
}, },
}, ],
}) })

View file

@ -1,6 +1,7 @@
import { defineMigrations } from '@tldraw/store' import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { createAssetValidator, TLBaseAsset } from './TLBaseAsset' import { TLAsset } from '../records/TLAsset'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/** /**
* An asset for images such as PNGs and JPEGs, used by the TLImageShape. * An asset for images such as PNGs and JPEGs, used by the TLImageShape.
@ -31,54 +32,54 @@ export const imageAssetValidator: T.Validator<TLImageAsset> = createAssetValidat
}) })
) )
const Versions = { const Versions = createMigrationIds('com.tldraw.asset.image', {
AddIsAnimated: 1, AddIsAnimated: 1,
RenameWidthHeight: 2, RenameWidthHeight: 2,
MakeUrlsValid: 3, MakeUrlsValid: 3,
} as const } as const)
export { Versions as imageAssetVersions }
/** @internal */ /** @internal */
export const imageAssetMigrations = defineMigrations({ export const imageAssetMigrations = createRecordMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequenceId: 'com.tldraw.asset.image',
migrators: { recordType: 'asset',
[Versions.AddIsAnimated]: { filter: (asset) => (asset as TLAsset).type === 'image',
up: (asset) => { sequence: [
return { {
...asset, id: Versions.AddIsAnimated,
props: { up: (asset: any) => {
...asset.props, asset.props.isAnimated = false
isAnimated: false,
},
}
}, },
down: (asset) => { down: (asset: any) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars delete asset.props.isAnimated
const { isAnimated, ...rest } = asset.props
return {
...asset,
props: rest,
}
}, },
}, },
[Versions.RenameWidthHeight]: { {
up: (asset) => { id: Versions.RenameWidthHeight,
const { width, height, ...others } = asset.props up: (asset: any) => {
return { ...asset, props: { w: width, h: height, ...others } } asset.props.w = asset.props.width
asset.props.h = asset.props.height
delete asset.props.width
delete asset.props.height
}, },
down: (asset) => { down: (asset: any) => {
const { w, h, ...others } = asset.props asset.props.width = asset.props.w
return { ...asset, props: { width: w, height: h, ...others } } asset.props.height = asset.props.h
delete asset.props.w
delete asset.props.h
}, },
}, },
[Versions.MakeUrlsValid]: { {
up: (asset: TLImageAsset) => { id: Versions.MakeUrlsValid,
const src = asset.props.src up: (asset: any) => {
if (src && !T.srcUrl.isValid(src)) { if (!T.srcUrl.isValid(asset.props.src)) {
return { ...asset, props: { ...asset.props, src: '' } } asset.props.src = ''
} }
return asset
}, },
down: (asset) => asset, down: (_asset) => {
// noop
},
}, },
}, ],
}) })

View file

@ -1,6 +1,7 @@
import { defineMigrations } from '@tldraw/store' import { createMigrationIds, createRecordMigrationSequence } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { createAssetValidator, TLBaseAsset } from './TLBaseAsset' import { TLAsset } from '../records/TLAsset'
import { TLBaseAsset, createAssetValidator } from './TLBaseAsset'
/** /**
* An asset used for videos, used by the TLVideoShape. * An asset used for videos, used by the TLVideoShape.
@ -31,54 +32,54 @@ export const videoAssetValidator: T.Validator<TLVideoAsset> = createAssetValidat
}) })
) )
const Versions = { const Versions = createMigrationIds('com.tldraw.asset.video', {
AddIsAnimated: 1, AddIsAnimated: 1,
RenameWidthHeight: 2, RenameWidthHeight: 2,
MakeUrlsValid: 3, MakeUrlsValid: 3,
} as const } as const)
export { Versions as videoAssetVersions }
/** @internal */ /** @internal */
export const videoAssetMigrations = defineMigrations({ export const videoAssetMigrations = createRecordMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequenceId: 'com.tldraw.asset.video',
migrators: { recordType: 'asset',
[Versions.AddIsAnimated]: { filter: (asset) => (asset as TLAsset).type === 'video',
up: (asset) => { sequence: [
return { {
...asset, id: Versions.AddIsAnimated,
props: { up: (asset: any) => {
...asset.props, asset.props.isAnimated = false
isAnimated: false,
},
}
}, },
down: (asset) => { down: (asset: any) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars delete asset.props.isAnimated
const { isAnimated, ...rest } = asset.props
return {
...asset,
props: rest,
}
}, },
}, },
[Versions.RenameWidthHeight]: { {
up: (asset) => { id: Versions.RenameWidthHeight,
const { width, height, ...others } = asset.props up: (asset: any) => {
return { ...asset, props: { w: width, h: height, ...others } } asset.props.w = asset.props.width
asset.props.h = asset.props.height
delete asset.props.width
delete asset.props.height
}, },
down: (asset) => { down: (asset: any) => {
const { w, h, ...others } = asset.props asset.props.width = asset.props.w
return { ...asset, props: { width: w, height: h, ...others } } asset.props.height = asset.props.h
delete asset.props.w
delete asset.props.h
}, },
}, },
[Versions.MakeUrlsValid]: { {
up: (asset: TLVideoAsset) => { id: Versions.MakeUrlsValid,
const src = asset.props.src up: (asset: any) => {
if (src && !T.srcUrl.isValid(src)) { if (!T.srcUrl.isValid(asset.props.src)) {
return { ...asset, props: { ...asset.props, src: '' } } asset.props.src = ''
} }
return asset
}, },
down: (asset) => asset, down: (_asset) => {
// noop
},
}, },
}, ],
}) })

View file

@ -6,7 +6,10 @@ import { InstancePageStateRecordType } from './records/TLPageState'
import { TLPOINTER_ID } from './records/TLPointer' import { TLPOINTER_ID } from './records/TLPointer'
import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPresence' import { InstancePresenceRecordType, TLInstancePresence } from './records/TLPresence'
/** @public */ /**
* Creates a derivation that represents the current presence state of the current user.
* @public
*/
export const createPresenceStateDerivation = export const createPresenceStateDerivation =
( (
$user: Signal<{ id: string; color: string; name: string }>, $user: Signal<{ id: string; color: string; name: string }>,

View file

@ -1,16 +1,26 @@
import { Migrations, StoreSchema } from '@tldraw/store' import { LegacyMigrations, MigrationSequence, StoreSchema } from '@tldraw/store'
import { objectMapValues } from '@tldraw/utils' import { objectMapValues } from '@tldraw/utils'
import { TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore' import { TLStoreProps, createIntegrityChecker, onValidationFailure } from './TLStore'
import { AssetRecordType } from './records/TLAsset' import { bookmarkAssetMigrations } from './assets/TLBookmarkAsset'
import { CameraRecordType } from './records/TLCamera' import { imageAssetMigrations } from './assets/TLImageAsset'
import { DocumentRecordType } from './records/TLDocument' import { videoAssetMigrations } from './assets/TLVideoAsset'
import { createInstanceRecordType } from './records/TLInstance' import { AssetRecordType, assetMigrations } from './records/TLAsset'
import { PageRecordType } from './records/TLPage' import { CameraRecordType, cameraMigrations } from './records/TLCamera'
import { InstancePageStateRecordType } from './records/TLPageState' import { DocumentRecordType, documentMigrations } from './records/TLDocument'
import { PointerRecordType } from './records/TLPointer' import { createInstanceRecordType, instanceMigrations } from './records/TLInstance'
import { InstancePresenceRecordType } from './records/TLPresence' import { PageRecordType, pageMigrations } from './records/TLPage'
import { InstancePageStateRecordType, instancePageStateMigrations } from './records/TLPageState'
import { PointerRecordType, pointerMigrations } from './records/TLPointer'
import { InstancePresenceRecordType, instancePresenceMigrations } from './records/TLPresence'
import { TLRecord } from './records/TLRecord' import { TLRecord } from './records/TLRecord'
import { TLDefaultShape, createShapeRecordType, getShapePropKeysByStyle } from './records/TLShape' import {
TLDefaultShape,
TLShapePropsMigrations,
createShapeRecordType,
getShapePropKeysByStyle,
processShapeMigrations,
rootShapeMigrations,
} from './records/TLShape'
import { arrowShapeMigrations, arrowShapeProps } from './shapes/TLArrowShape' import { arrowShapeMigrations, arrowShapeProps } from './shapes/TLArrowShape'
import { bookmarkShapeMigrations, bookmarkShapeProps } from './shapes/TLBookmarkShape' import { bookmarkShapeMigrations, bookmarkShapeProps } from './shapes/TLBookmarkShape'
import { drawShapeMigrations, drawShapeProps } from './shapes/TLDrawShape' import { drawShapeMigrations, drawShapeProps } from './shapes/TLDrawShape'
@ -34,7 +44,7 @@ type AnyValidator = {
/** @public */ /** @public */
export type SchemaShapeInfo = { export type SchemaShapeInfo = {
migrations?: Migrations migrations?: LegacyMigrations | TLShapePropsMigrations | MigrationSequence
props?: Record<string, AnyValidator> props?: Record<string, AnyValidator>
meta?: Record<string, AnyValidator> meta?: Record<string, AnyValidator>
} }
@ -66,8 +76,10 @@ const defaultShapes: { [T in TLDefaultShape['type']]: SchemaShapeInfo } = {
* @public */ * @public */
export function createTLSchema({ export function createTLSchema({
shapes = defaultShapes, shapes = defaultShapes,
migrations,
}: { }: {
shapes?: Record<string, SchemaShapeInfo> shapes?: Record<string, SchemaShapeInfo>
migrations?: readonly MigrationSequence[]
} = {}): TLSchema { } = {}): TLSchema {
const stylesById = new Map<string, StyleProp<unknown>>() const stylesById = new Map<string, StyleProp<unknown>>()
for (const shape of objectMapValues(shapes)) { for (const shape of objectMapValues(shapes)) {
@ -90,14 +102,33 @@ export function createTLSchema({
instance: InstanceRecordType, instance: InstanceRecordType,
instance_page_state: InstancePageStateRecordType, instance_page_state: InstancePageStateRecordType,
page: PageRecordType, page: PageRecordType,
shape: ShapeRecordType,
instance_presence: InstancePresenceRecordType, instance_presence: InstancePresenceRecordType,
pointer: PointerRecordType, pointer: PointerRecordType,
shape: ShapeRecordType,
}, },
{ {
snapshotMigrations: storeMigrations, migrations: [
storeMigrations,
assetMigrations,
cameraMigrations,
documentMigrations,
instanceMigrations,
instancePageStateMigrations,
pageMigrations,
instancePresenceMigrations,
pointerMigrations,
rootShapeMigrations,
bookmarkAssetMigrations,
imageAssetMigrations,
videoAssetMigrations,
...processShapeMigrations(shapes),
...(migrations ?? []),
],
onValidationFailure, onValidationFailure,
createIntegrityChecker: createIntegrityChecker, createIntegrityChecker,
} }
) )
} }

View file

@ -52,6 +52,7 @@ export { InstancePresenceRecordType, type TLInstancePresence } from './records/T
export { type TLRecord } from './records/TLRecord' export { type TLRecord } from './records/TLRecord'
export { export {
createShapeId, createShapeId,
createShapePropsMigrationSequence,
getShapePropKeysByStyle, getShapePropKeysByStyle,
isShape, isShape,
isShapeId, isShapeId,
@ -63,6 +64,7 @@ export {
type TLShapePartial, type TLShapePartial,
type TLShapeProp, type TLShapeProp,
type TLShapeProps, type TLShapeProps,
type TLShapePropsMigrations,
type TLUnknownShape, type TLUnknownShape,
} from './records/TLShape' } from './records/TLShape'
export { export {

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,14 @@
import { createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { TLBaseAsset } from '../assets/TLBaseAsset' import { TLBaseAsset } from '../assets/TLBaseAsset'
import { import { bookmarkAssetValidator, TLBookmarkAsset } from '../assets/TLBookmarkAsset'
bookmarkAssetMigrations, import { imageAssetValidator, TLImageAsset } from '../assets/TLImageAsset'
bookmarkAssetValidator, import { TLVideoAsset, videoAssetValidator } from '../assets/TLVideoAsset'
TLBookmarkAsset,
} from '../assets/TLBookmarkAsset'
import { imageAssetMigrations, imageAssetValidator, TLImageAsset } from '../assets/TLImageAsset'
import { TLVideoAsset, videoAssetMigrations, videoAssetValidator } from '../assets/TLVideoAsset'
import { TLShape } from './TLShape' import { TLShape } from './TLShape'
/** @public */ /** @public */
@ -24,34 +25,22 @@ export const assetValidator: T.Validator<TLAsset> = T.model(
) )
/** @internal */ /** @internal */
export const assetVersions = { export const assetVersions = createMigrationIds('com.tldraw.asset', {
AddMeta: 1, AddMeta: 1,
} } as const)
/** @internal */ /** @internal */
export const assetMigrations = defineMigrations({ export const assetMigrations = createRecordMigrationSequence({
subTypeKey: 'type', sequenceId: 'com.tldraw.asset',
subTypeMigrations: { recordType: 'asset',
image: imageAssetMigrations, sequence: [
video: videoAssetMigrations, {
bookmark: bookmarkAssetMigrations, id: assetVersions.AddMeta,
},
currentVersion: assetVersions.AddMeta,
migrators: {
[assetVersions.AddMeta]: {
up: (record) => { up: (record) => {
return { ;(record as any).meta = {}
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
@ -66,7 +55,6 @@ export type TLAssetPartial<T extends TLAsset = TLAsset> = T extends T
/** @public */ /** @public */
export const AssetRecordType = createRecordType<TLAsset>('asset', { export const AssetRecordType = createRecordType<TLAsset>('asset', {
migrations: assetMigrations,
validator: assetValidator, validator: assetValidator,
scope: 'document', scope: 'document',
}).withDefaultProperties(() => ({ }).withDefaultProperties(() => ({

View file

@ -1,4 +1,10 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
@ -35,34 +41,27 @@ export const cameraValidator: T.Validator<TLCamera> = T.model(
) )
/** @internal */ /** @internal */
export const cameraVersions = { export const cameraVersions = createMigrationIds('com.tldraw.camera', {
AddMeta: 1, AddMeta: 1,
} })
/** @internal */ /** @internal */
export const cameraMigrations = defineMigrations({ export const cameraMigrations = createRecordMigrationSequence({
currentVersion: cameraVersions.AddMeta, sequenceId: 'com.tldraw.camera',
migrators: { recordType: 'camera',
[cameraVersions.AddMeta]: { sequence: [
{
id: cameraVersions.AddMeta,
up: (record) => { up: (record) => {
return { ;(record as any).meta = {}
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
export const CameraRecordType = createRecordType<TLCamera>('camera', { export const CameraRecordType = createRecordType<TLCamera>('camera', {
validator: cameraValidator, validator: cameraValidator,
migrations: cameraMigrations,
scope: 'session', scope: 'session',
}).withDefaultProperties( }).withDefaultProperties(
(): Omit<TLCamera, 'id' | 'typeName'> => ({ (): Omit<TLCamera, 'id' | 'typeName'> => ({

View file

@ -1,4 +1,10 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
@ -26,42 +32,36 @@ export const documentValidator: T.Validator<TLDocument> = T.model(
) )
/** @internal */ /** @internal */
export const documentVersions = { export const documentVersions = createMigrationIds('com.tldraw.document', {
AddName: 1, AddName: 1,
AddMeta: 2, AddMeta: 2,
} as const } as const)
/** @internal */ /** @internal */
export const documentMigrations = defineMigrations({ export const documentMigrations = createRecordMigrationSequence({
currentVersion: documentVersions.AddMeta, sequenceId: 'com.tldraw.document',
migrators: { recordType: 'document',
[documentVersions.AddName]: { sequence: [
up: (document: TLDocument) => { {
return { ...document, name: '' } id: documentVersions.AddName,
up: (document) => {
;(document as any).name = ''
}, },
down: ({ name: _, ...document }: TLDocument) => { down: (document) => {
return document delete (document as any).name
}, },
}, },
[documentVersions.AddMeta]: { {
id: documentVersions.AddMeta,
up: (record) => { up: (record) => {
return { ;(record as any).meta = {}
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
export const DocumentRecordType = createRecordType<TLDocument>('document', { export const DocumentRecordType = createRecordType<TLDocument>('document', {
migrations: documentMigrations,
validator: documentValidator, validator: documentValidator,
scope: 'document', scope: 'document',
}).withDefaultProperties( }).withDefaultProperties(

View file

@ -1,4 +1,10 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types' import { BoxModel, boxModelValidator } from '../misc/geometry-types'
@ -121,7 +127,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
) )
return createRecordType<TLInstance>('instance', { return createRecordType<TLInstance>('instance', {
migrations: instanceMigrations,
validator: instanceTypeValidator, validator: instanceTypeValidator,
scope: 'session', scope: 'session',
}).withDefaultProperties( }).withDefaultProperties(
@ -162,7 +167,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
} }
/** @internal */ /** @internal */
export const instanceVersions = { export const instanceVersions = createMigrationIds('com.tldraw.instance', {
AddTransparentExportBgs: 1, AddTransparentExportBgs: 1,
RemoveDialog: 2, RemoveDialog: 2,
AddToolLockMode: 3, AddToolLockMode: 3,
@ -187,37 +192,36 @@ export const instanceVersions = {
AddScribbles: 22, AddScribbles: 22,
AddInset: 23, AddInset: 23,
AddDuplicateProps: 24, AddDuplicateProps: 24,
} as const } as const)
// TODO: rewrite these to use mutation
/** @public */ /** @public */
export const instanceMigrations = defineMigrations({ export const instanceMigrations = createRecordMigrationSequence({
currentVersion: instanceVersions.AddDuplicateProps, sequenceId: 'com.tldraw.instance',
migrators: { recordType: 'instance',
[instanceVersions.AddTransparentExportBgs]: { sequence: [
up: (instance: TLInstance) => { {
id: instanceVersions.AddTransparentExportBgs,
up: (instance) => {
return { ...instance, exportBackground: true } return { ...instance, exportBackground: true }
}, },
down: ({ exportBackground: _, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.RemoveDialog]: { {
id: instanceVersions.RemoveDialog,
up: ({ dialog: _, ...instance }: any) => { up: ({ dialog: _, ...instance }: any) => {
return instance return instance
}, },
down: (instance: TLInstance) => {
return { ...instance, dialog: null }
},
}, },
[instanceVersions.AddToolLockMode]: {
up: (instance: TLInstance) => { {
id: instanceVersions.AddToolLockMode,
up: (instance) => {
return { ...instance, isToolLocked: false } return { ...instance, isToolLocked: false }
}, },
down: ({ isToolLocked: _, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.RemoveExtraPropsForNextShape]: { {
id: instanceVersions.RemoveExtraPropsForNextShape,
up: ({ propsForNextShape, ...instance }: any) => { up: ({ propsForNextShape, ...instance }: any) => {
return { return {
...instance, ...instance,
@ -242,12 +246,9 @@ export const instanceMigrations = defineMigrations({
), ),
} }
}, },
down: (instance: TLInstance) => {
// we can't restore these, so do nothing :/
return instance
},
}, },
[instanceVersions.AddLabelColor]: { {
id: instanceVersions.AddLabelColor,
up: ({ propsForNextShape, ...instance }: any) => { up: ({ propsForNextShape, ...instance }: any) => {
return { return {
...instance, ...instance,
@ -257,25 +258,15 @@ export const instanceMigrations = defineMigrations({
}, },
} }
}, },
down: (instance) => {
const { labelColor: _, ...rest } = instance.propsForNextShape
return {
...instance,
propsForNextShape: {
...rest,
},
}
},
}, },
[instanceVersions.AddFollowingUserId]: { {
up: (instance: TLInstance) => { id: instanceVersions.AddFollowingUserId,
up: (instance) => {
return { ...instance, followingUserId: null } return { ...instance, followingUserId: null }
}, },
down: ({ followingUserId: _, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.RemoveAlignJustify]: { {
id: instanceVersions.RemoveAlignJustify,
up: (instance: any) => { up: (instance: any) => {
let newAlign = instance.propsForNextShape.align let newAlign = instance.propsForNextShape.align
if (newAlign === 'justify') { if (newAlign === 'justify') {
@ -290,20 +281,16 @@ export const instanceMigrations = defineMigrations({
}, },
} }
}, },
down: (instance: TLInstance) => {
return { ...instance }
},
}, },
[instanceVersions.AddZoom]: { {
up: (instance: TLInstance) => { id: instanceVersions.AddZoom,
up: (instance) => {
return { ...instance, zoomBrush: null } return { ...instance, zoomBrush: null }
}, },
down: ({ zoomBrush: _, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.AddVerticalAlign]: { {
up: (instance) => { id: instanceVersions.AddVerticalAlign,
up: (instance: any) => {
return { return {
...instance, ...instance,
propsForNextShape: { propsForNextShape: {
@ -312,141 +299,73 @@ export const instanceMigrations = defineMigrations({
}, },
} }
}, },
down: (instance) => {
const { verticalAlign: _, ...propsForNextShape } = instance.propsForNextShape
return {
...instance,
propsForNextShape,
}
},
}, },
[instanceVersions.AddScribbleDelay]: { {
up: (instance) => { id: instanceVersions.AddScribbleDelay,
up: (instance: any) => {
if (instance.scribble !== null) { if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } } return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
} }
return { ...instance } return { ...instance }
}, },
down: (instance) => {
if (instance.scribble !== null) {
const { delay: _delay, ...rest } = instance.scribble
return { ...instance, scribble: rest }
}
return { ...instance }
},
}, },
[instanceVersions.RemoveUserId]: { {
id: instanceVersions.RemoveUserId,
up: ({ userId: _, ...instance }: any) => { up: ({ userId: _, ...instance }: any) => {
return instance return instance
}, },
down: (instance: TLInstance) => {
return { ...instance, userId: 'user:none' }
},
}, },
[instanceVersions.AddIsPenModeAndIsGridMode]: { {
up: (instance: TLInstance) => { id: instanceVersions.AddIsPenModeAndIsGridMode,
up: (instance) => {
return { ...instance, isPenMode: false, isGridMode: false } return { ...instance, isPenMode: false, isGridMode: false }
}, },
down: ({ isPenMode: _, isGridMode: __, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.HoistOpacity]: { {
id: instanceVersions.HoistOpacity,
up: ({ propsForNextShape: { opacity, ...propsForNextShape }, ...instance }: any) => { up: ({ propsForNextShape: { opacity, ...propsForNextShape }, ...instance }: any) => {
return { ...instance, opacityForNextShape: Number(opacity ?? '1'), propsForNextShape } return { ...instance, opacityForNextShape: Number(opacity ?? '1'), propsForNextShape }
}, },
down: ({ opacityForNextShape: opacity, ...instance }: any) => {
return {
...instance,
propsForNextShape: {
...instance.propsForNextShape,
opacity:
opacity < 0.175
? '0.1'
: opacity < 0.375
? '0.25'
: opacity < 0.625
? '0.5'
: opacity < 0.875
? '0.75'
: '1',
},
}
},
}, },
[instanceVersions.AddChat]: { {
up: (instance: TLInstance) => { id: instanceVersions.AddChat,
up: (instance) => {
return { ...instance, chatMessage: '', isChatting: false } return { ...instance, chatMessage: '', isChatting: false }
}, },
down: ({ chatMessage: _, isChatting: __, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.AddHighlightedUserIds]: { {
up: (instance: TLInstance) => { id: instanceVersions.AddHighlightedUserIds,
up: (instance) => {
return { ...instance, highlightedUserIds: [] } return { ...instance, highlightedUserIds: [] }
}, },
down: ({ highlightedUserIds: _, ...instance }: TLInstance) => {
return instance
},
}, },
[instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape]: { {
up: ({ propsForNextShape: _, ...instance }) => { id: instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape,
up: ({ propsForNextShape: _, ...instance }: any) => {
return { ...instance, stylesForNextShape: {} } return { ...instance, stylesForNextShape: {} }
}, },
down: ({ stylesForNextShape: _, ...instance }: TLInstance) => {
return {
...instance,
propsForNextShape: {
color: 'black',
labelColor: 'black',
dash: 'draw',
fill: 'none',
size: 'm',
icon: 'file',
font: 'draw',
align: 'middle',
verticalAlign: 'middle',
geo: 'rectangle',
arrowheadStart: 'none',
arrowheadEnd: 'arrow',
spline: 'line',
},
}
},
}, },
[instanceVersions.AddMeta]: { {
id: instanceVersions.AddMeta,
up: (record) => { up: (record) => {
return { return {
...record, ...record,
meta: {}, meta: {},
} }
}, },
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
}, },
[instanceVersions.RemoveCursorColor]: { {
up: (record) => { id: instanceVersions.RemoveCursorColor,
up: (record: any) => {
const { color: _, ...cursor } = record.cursor const { color: _, ...cursor } = record.cursor
return { return {
...record, ...record,
cursor, cursor,
} }
}, },
down: (record) => {
return {
...record,
cursor: {
...record.cursor,
color: 'black',
},
}
},
}, },
[instanceVersions.AddLonelyProperties]: { {
id: instanceVersions.AddLonelyProperties,
up: (record) => { up: (record) => {
return { return {
...record, ...record,
@ -459,86 +378,63 @@ export const instanceMigrations = defineMigrations({
isReadOnly: false, isReadOnly: false,
} }
}, },
down: ({
canMoveCamera: _canMoveCamera,
isFocused: _isFocused,
devicePixelRatio: _devicePixelRatio,
isCoarsePointer: _isCoarsePointer,
openMenus: _openMenus,
isChangingStyle: _isChangingStyle,
isReadOnly: _isReadOnly,
...record
}) => {
return {
...record,
}
},
}, },
[instanceVersions.ReadOnlyReadonly]: { {
up: ({ isReadOnly: _isReadOnly, ...record }) => { id: instanceVersions.ReadOnlyReadonly,
up: ({ isReadOnly: _isReadOnly, ...record }: any) => {
return { return {
...record, ...record,
isReadonly: _isReadOnly, isReadonly: _isReadOnly,
} }
}, },
down: ({ isReadonly: _isReadonly, ...record }) => {
return {
...record,
isReadOnly: _isReadonly,
}
},
}, },
[instanceVersions.AddHoveringCanvas]: { {
id: instanceVersions.AddHoveringCanvas,
up: (record) => { up: (record) => {
return { return {
...record, ...record,
isHoveringCanvas: null, isHoveringCanvas: null,
} }
}, },
down: ({ isHoveringCanvas: _, ...record }) => {
return {
...record,
}
},
}, },
[instanceVersions.AddScribbles]: { {
up: ({ scribble: _, ...record }) => { id: instanceVersions.AddScribbles,
up: ({ scribble: _, ...record }: any) => {
return { return {
...record, ...record,
scribbles: [], scribbles: [],
} }
}, },
down: ({ scribbles: _, ...record }) => {
return { ...record, scribble: null }
},
}, },
[instanceVersions.AddInset]: { {
id: instanceVersions.AddInset,
up: (record) => { up: (record) => {
return { return {
...record, ...record,
insets: [false, false, false, false], insets: [false, false, false, false],
} }
}, },
down: ({ insets: _, ...record }) => { down: ({ insets: _, ...record }: any) => {
return { return {
...record, ...record,
} }
}, },
}, },
[instanceVersions.AddDuplicateProps]: { {
id: instanceVersions.AddDuplicateProps,
up: (record) => { up: (record) => {
return { return {
...record, ...record,
duplicateProps: null, duplicateProps: null,
} }
}, },
down: ({ duplicateProps: _, ...record }) => { down: ({ duplicateProps: _, ...record }: any) => {
return { return {
...record, ...record,
} }
}, },
}, },
}, ],
}) })
/** @public */ /** @public */

View file

@ -1,4 +1,10 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { IndexKey, JsonObject } from '@tldraw/utils' import { IndexKey, JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
@ -33,34 +39,27 @@ export const pageValidator: T.Validator<TLPage> = T.model(
) )
/** @internal */ /** @internal */
export const pageVersions = { export const pageVersions = createMigrationIds('com.tldraw.page', {
AddMeta: 1, AddMeta: 1,
} })
/** @internal */ /** @internal */
export const pageMigrations = defineMigrations({ export const pageMigrations = createRecordMigrationSequence({
currentVersion: pageVersions.AddMeta, sequenceId: 'com.tldraw.page',
migrators: { recordType: 'page',
[pageVersions.AddMeta]: { sequence: [
up: (record) => { {
return { id: pageVersions.AddMeta,
...record, up: (record: any) => {
meta: {}, record.meta = {}
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
export const PageRecordType = createRecordType<TLPage>('page', { export const PageRecordType = createRecordType<TLPage>('page', {
validator: pageValidator, validator: pageValidator,
migrations: pageMigrations,
scope: 'document', scope: 'document',
}).withDefaultProperties(() => ({ }).withDefaultProperties(() => ({
meta: {}, meta: {},

View file

@ -1,10 +1,14 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
import { shapeIdValidator } from '../shapes/TLBaseShape' import { shapeIdValidator } from '../shapes/TLBaseShape'
import { CameraRecordType } from './TLCamera'
import { TLINSTANCE_ID } from './TLInstance'
import { pageIdValidator, TLPage } from './TLPage' import { pageIdValidator, TLPage } from './TLPage'
import { TLShapeId } from './TLShape' import { TLShapeId } from './TLShape'
@ -47,155 +51,91 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
) )
/** @internal */ /** @internal */
export const instancePageStateVersions = { export const instancePageStateVersions = createMigrationIds('com.tldraw.instance_page_state', {
AddCroppingId: 1, AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2, RemoveInstanceIdAndCameraId: 2,
AddMeta: 3, AddMeta: 3,
RenameProperties: 4, RenameProperties: 4,
RenamePropertiesAgain: 5, RenamePropertiesAgain: 5,
} as const } as const)
/** @public */ /** @public */
export const instancePageStateMigrations = defineMigrations({ export const instancePageStateMigrations = createRecordMigrationSequence({
currentVersion: instancePageStateVersions.RenamePropertiesAgain, sequenceId: 'com.tldraw.instance_page_state',
migrators: { recordType: 'instance_page_state',
[instancePageStateVersions.AddCroppingId]: { sequence: [
up(instance) { {
return { ...instance, croppingShapeId: null } id: instancePageStateVersions.AddCroppingId,
}, up(instance: any) {
down({ croppingShapeId: _croppingShapeId, ...instance }) { instance.croppingShapeId = null
return instance
}, },
}, },
[instancePageStateVersions.RemoveInstanceIdAndCameraId]: { {
up({ instanceId: _, cameraId: __, ...instance }) { id: instancePageStateVersions.RemoveInstanceIdAndCameraId,
return instance up(instance: any) {
}, delete instance.instanceId
down(instance) { delete instance.cameraId
// this should never be called since we bump the schema version
return {
...instance,
instanceId: TLINSTANCE_ID,
cameraId: CameraRecordType.createId('void'),
}
}, },
}, },
[instancePageStateVersions.AddMeta]: { {
up: (record) => { id: instancePageStateVersions.AddMeta,
return { up: (record: any) => {
...record, record.meta = {}
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
[instancePageStateVersions.RenameProperties]: { {
id: instancePageStateVersions.RenameProperties,
// this migration is cursed: it was written wrong and doesn't do anything. // this migration is cursed: it was written wrong and doesn't do anything.
// rather than replace it, I've added another migration below that fixes it. // rather than replace it, I've added another migration below that fixes it.
up: (record) => { up: (_record) => {
const { // noop
selectedShapeIds,
hintingShapeIds,
erasingShapeIds,
hoveredShapeId,
editingShapeId,
croppingShapeId,
focusedGroupId,
...rest
} = record
return {
selectedShapeIds: selectedShapeIds,
hintingShapeIds: hintingShapeIds,
erasingShapeIds: erasingShapeIds,
hoveredShapeId: hoveredShapeId,
editingShapeId: editingShapeId,
croppingShapeId: croppingShapeId,
focusedGroupId: focusedGroupId,
...rest,
}
}, },
down: (record) => { down: (_record) => {
const { // noop
selectedShapeIds,
hintingShapeIds,
erasingShapeIds,
hoveredShapeId,
editingShapeId,
croppingShapeId,
focusedGroupId,
...rest
} = record
return {
selectedShapeIds: selectedShapeIds,
hintingShapeIds: hintingShapeIds,
erasingShapeIds: erasingShapeIds,
hoveredShapeId: hoveredShapeId,
editingShapeId: editingShapeId,
croppingShapeId: croppingShapeId,
focusedGroupId: focusedGroupId,
...rest,
}
}, },
}, },
[instancePageStateVersions.RenamePropertiesAgain]: { {
up: (record) => { id: instancePageStateVersions.RenamePropertiesAgain,
const { up: (record: any) => {
selectedIds, record.selectedShapeIds = record.selectedIds
hintingIds, delete record.selectedIds
erasingIds, record.hintingShapeIds = record.hintingIds
hoveredId, delete record.hintingIds
editingId, record.erasingShapeIds = record.erasingIds
croppingShapeId, delete record.erasingIds
croppingId, record.hoveredShapeId = record.hoveredId
focusLayerId, delete record.hoveredId
...rest record.editingShapeId = record.editingId
} = record delete record.editingId
return { record.croppingShapeId = record.croppingShapeId ?? record.croppingId ?? null
...rest, delete record.croppingId
selectedShapeIds: selectedIds, record.focusedGroupId = record.focusLayerId
hintingShapeIds: hintingIds, delete record.focusLayerId
erasingShapeIds: erasingIds,
hoveredShapeId: hoveredId,
editingShapeId: editingId,
croppingShapeId: croppingShapeId ?? croppingId ?? null,
focusedGroupId: focusLayerId,
}
}, },
down: (record) => { down: (record: any) => {
const { record.selectedIds = record.selectedShapeIds
selectedShapeIds, delete record.selectedShapeIds
hintingShapeIds, record.hintingIds = record.hintingShapeIds
erasingShapeIds, delete record.hintingShapeIds
hoveredShapeId, record.erasingIds = record.erasingShapeIds
editingShapeId, delete record.erasingShapeIds
croppingShapeId, record.hoveredId = record.hoveredShapeId
focusedGroupId, delete record.hoveredShapeId
...rest record.editingId = record.editingShapeId
} = record delete record.editingShapeId
return { record.croppingId = record.croppingShapeId
...rest, delete record.croppingShapeId
selectedIds: selectedShapeIds, record.focusLayerId = record.focusedGroupId
hintingIds: hintingShapeIds, delete record.focusedGroupId
erasingIds: erasingShapeIds,
hoveredId: hoveredShapeId,
editingId: editingShapeId,
croppingId: croppingShapeId,
focusLayerId: focusedGroupId,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
export const InstancePageStateRecordType = createRecordType<TLInstancePageState>( export const InstancePageStateRecordType = createRecordType<TLInstancePageState>(
'instance_page_state', 'instance_page_state',
{ {
migrations: instancePageStateMigrations,
validator: instancePageStateValidator, validator: instancePageStateValidator,
scope: 'session', scope: 'session',
} }

View file

@ -1,4 +1,10 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
@ -32,34 +38,27 @@ export const pointerValidator: T.Validator<TLPointer> = T.model(
) )
/** @internal */ /** @internal */
export const pointerVersions = { export const pointerVersions = createMigrationIds('com.tldraw.pointer', {
AddMeta: 1, AddMeta: 1,
} })
/** @internal */ /** @internal */
export const pointerMigrations = defineMigrations({ export const pointerMigrations = createRecordMigrationSequence({
currentVersion: pointerVersions.AddMeta, sequenceId: 'com.tldraw.pointer',
migrators: { recordType: 'pointer',
[pointerVersions.AddMeta]: { sequence: [
up: (record) => { {
return { id: pointerVersions.AddMeta,
...record, up: (record: any) => {
meta: {}, record.meta = {}
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
}, },
}, },
}, ],
}) })
/** @public */ /** @public */
export const PointerRecordType = createRecordType<TLPointer>('pointer', { export const PointerRecordType = createRecordType<TLPointer>('pointer', {
validator: pointerValidator, validator: pointerValidator,
migrations: pointerMigrations,
scope: 'session', scope: 'session',
}).withDefaultProperties( }).withDefaultProperties(
(): Omit<TLPointer, 'id' | 'typeName'> => ({ (): Omit<TLPointer, 'id' | 'typeName'> => ({

View file

@ -1,11 +1,16 @@
import { BaseRecord, createRecordType, defineMigrations, RecordId } from '@tldraw/store' import {
BaseRecord,
createMigrationIds,
createRecordMigrationSequence,
createRecordType,
RecordId,
} from '@tldraw/store'
import { JsonObject } from '@tldraw/utils' import { JsonObject } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types' import { BoxModel, boxModelValidator } from '../misc/geometry-types'
import { idValidator } from '../misc/id-validator' import { idValidator } from '../misc/id-validator'
import { cursorTypeValidator, TLCursor } from '../misc/TLCursor' import { cursorTypeValidator, TLCursor } from '../misc/TLCursor'
import { scribbleValidator, TLScribble } from '../misc/TLScribble' import { scribbleValidator, TLScribble } from '../misc/TLScribble'
import { TLINSTANCE_ID } from './TLInstance'
import { TLPageId } from './TLPage' import { TLPageId } from './TLPage'
import { TLShapeId } from './TLShape' import { TLShapeId } from './TLShape'
@ -68,85 +73,57 @@ export const instancePresenceValidator: T.Validator<TLInstancePresence> = T.mode
) )
/** @internal */ /** @internal */
export const instancePresenceVersions = { export const instancePresenceVersions = createMigrationIds('com.tldraw.instance_presence', {
AddScribbleDelay: 1, AddScribbleDelay: 1,
RemoveInstanceId: 2, RemoveInstanceId: 2,
AddChatMessage: 3, AddChatMessage: 3,
AddMeta: 4, AddMeta: 4,
RenameSelectedShapeIds: 5, RenameSelectedShapeIds: 5,
} as const } as const)
export const instancePresenceMigrations = defineMigrations({ export const instancePresenceMigrations = createRecordMigrationSequence({
currentVersion: instancePresenceVersions.RenameSelectedShapeIds, sequenceId: 'com.tldraw.instance_presence',
migrators: { recordType: 'instance_presence',
[instancePresenceVersions.AddScribbleDelay]: { sequence: [
up: (instance) => { {
id: instancePresenceVersions.AddScribbleDelay,
up: (instance: any) => {
if (instance.scribble !== null) { if (instance.scribble !== null) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } } instance.scribble.delay = 0
}
return { ...instance }
},
down: (instance) => {
if (instance.scribble !== null) {
const { delay: _delay, ...rest } = instance.scribble
return { ...instance, scribble: rest }
}
return { ...instance }
},
},
[instancePresenceVersions.RemoveInstanceId]: {
up: ({ instanceId: _, ...instance }) => {
return instance
},
down: (instance) => {
return { ...instance, instanceId: TLINSTANCE_ID }
},
},
[instancePresenceVersions.AddChatMessage]: {
up: (instance) => {
return { ...instance, chatMessage: '' }
},
down: ({ chatMessage: _, ...instance }) => {
return instance
},
},
[instancePresenceVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
} }
}, },
}, },
[instancePresenceVersions.RenameSelectedShapeIds]: { {
up: (record) => { id: instancePresenceVersions.RemoveInstanceId,
const { selectedShapeIds, ...rest } = record up: (instance: any) => {
return { delete instance.instanceId
selectedShapeIds: selectedShapeIds,
...rest,
}
},
down: (record) => {
const { selectedShapeIds, ...rest } = record
return {
selectedShapeIds: selectedShapeIds,
...rest,
}
}, },
}, },
}, {
id: instancePresenceVersions.AddChatMessage,
up: (instance: any) => {
instance.chatMessage = ''
},
},
{
id: instancePresenceVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
{
id: instancePresenceVersions.RenameSelectedShapeIds,
up: (_record) => {
// noop, whoopsie
},
},
],
}) })
/** @public */ /** @public */
export const InstancePresenceRecordType = createRecordType<TLInstancePresence>( export const InstancePresenceRecordType = createRecordType<TLInstancePresence>(
'instance_presence', 'instance_presence',
{ {
migrations: instancePresenceMigrations,
validator: instancePresenceValidator, validator: instancePresenceValidator,
scope: 'presence', scope: 'presence',
} }

View file

@ -1,10 +1,20 @@
import { createRecordType, defineMigrations, RecordId, UnknownRecord } from '@tldraw/store' import {
import { mapObjectMapValues } from '@tldraw/utils' Migration,
MigrationId,
MigrationSequence,
RecordId,
UnknownRecord,
createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
createRecordType,
} from '@tldraw/store'
import { assert, mapObjectMapValues } from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { nanoid } from 'nanoid' import { nanoid } from 'nanoid'
import { SchemaShapeInfo } from '../createTLSchema' import { SchemaShapeInfo } from '../createTLSchema'
import { TLArrowShape } from '../shapes/TLArrowShape' import { TLArrowShape } from '../shapes/TLArrowShape'
import { createShapeValidator, TLBaseShape } from '../shapes/TLBaseShape' import { TLBaseShape, createShapeValidator } from '../shapes/TLBaseShape'
import { TLBookmarkShape } from '../shapes/TLBookmarkShape' import { TLBookmarkShape } from '../shapes/TLBookmarkShape'
import { TLDrawShape } from '../shapes/TLDrawShape' import { TLDrawShape } from '../shapes/TLDrawShape'
import { TLEmbedShape } from '../shapes/TLEmbedShape' import { TLEmbedShape } from '../shapes/TLEmbedShape'
@ -83,88 +93,66 @@ export type TLShapeProp = keyof TLShapeProps
export type TLParentId = TLPageId | TLShapeId export type TLParentId = TLPageId | TLShapeId
/** @internal */ /** @internal */
export const rootShapeVersions = { export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
AddIsLocked: 1, AddIsLocked: 1,
HoistOpacity: 2, HoistOpacity: 2,
AddMeta: 3, AddMeta: 3,
AddWhite: 4, AddWhite: 4,
} as const } as const)
/** @internal */ /** @internal */
export const rootShapeMigrations = defineMigrations({ export const rootShapeMigrations = createRecordMigrationSequence({
currentVersion: rootShapeVersions.AddWhite, sequenceId: 'com.tldraw.shape',
migrators: { recordType: 'shape',
[rootShapeVersions.AddIsLocked]: { sequence: [
up: (record) => { {
return { id: rootShapeVersions.AddIsLocked,
...record, up: (record: any) => {
isLocked: false, record.isLocked = false
}
}, },
down: (record) => { down: (record: any) => {
const { isLocked: _, ...rest } = record delete record.isLocked
return { },
...rest, },
{
id: rootShapeVersions.HoistOpacity,
up: (record: any) => {
record.opacity = Number(record.props.opacity ?? '1')
delete record.props.opacity
},
down: (record: any) => {
const opacity = record.opacity
delete record.opacity
record.props.opacity =
opacity < 0.175
? '0.1'
: opacity < 0.375
? '0.25'
: opacity < 0.625
? '0.5'
: opacity < 0.875
? '0.75'
: '1'
},
},
{
id: rootShapeVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
{
id: rootShapeVersions.AddWhite,
up: (_record) => {
// noop
},
down: (record: any) => {
if (record.props.color === 'white') {
record.props.color = 'black'
} }
}, },
}, },
[rootShapeVersions.HoistOpacity]: { ],
up: ({ props: { opacity, ...props }, ...record }) => {
return {
...record,
opacity: Number(opacity ?? '1'),
props,
}
},
down: ({ opacity, ...record }) => {
return {
...record,
props: {
...record.props,
opacity:
opacity < 0.175
? '0.1'
: opacity < 0.375
? '0.25'
: opacity < 0.625
? '0.5'
: opacity < 0.875
? '0.75'
: '1',
},
}
},
},
[rootShapeVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
[rootShapeVersions.AddWhite]: {
up: (record) => {
return {
...record,
}
},
down: (record) => {
return {
...record,
props: {
...record.props,
color: record.props.color === 'white' ? 'black' : record.props.color,
},
}
},
},
},
}) })
/** @public */ /** @public */
@ -200,16 +188,142 @@ export function getShapePropKeysByStyle(props: Record<string, T.Validatable<any>
return propKeysByStyle return propKeysByStyle
} }
export const NO_DOWN_MIGRATION = 'none' as const
// If a down migration was deployed more than a couple of months ago it should be safe to retire it.
// We only really need them to smooth over the transition between versions, and some folks do keep
// browser tabs open for months without refreshing, but at a certain point that kind of behavior is
// on them. Plus anyway recently chrome has started to actually kill tabs that are open for too long rather
// than just suspending them, so if other browsers follow suit maybe it's less of a concern.
export const RETIRED_DOWN_MIGRATION = 'retired' as const
/**
* @public
*/
export type TLShapePropsMigrations = {
sequence: Array<
| { readonly dependsOn: readonly MigrationId[] }
| {
readonly id: MigrationId
readonly dependsOn?: MigrationId[]
readonly up: (props: any) => any
readonly down?:
| typeof NO_DOWN_MIGRATION
| typeof RETIRED_DOWN_MIGRATION
| ((props: any) => any)
}
>
}
/**
* @public
*/
export function createShapePropsMigrationSequence(
migrations: TLShapePropsMigrations
): TLShapePropsMigrations {
return migrations
}
/**
* @public
*/
export function createShapePropsMigrationIds<S extends string, T extends Record<string, number>>(
shapeType: S,
ids: T
): { [k in keyof T]: `com.tldraw.shape.${S}/${T[k]}` } {
return mapObjectMapValues(ids, (_k, v) => `com.tldraw.shape.${shapeType}/${v}`) as any
}
export function processShapeMigrations(shapes: Record<string, SchemaShapeInfo>) {
const result: MigrationSequence[] = []
for (const [shapeType, { migrations }] of Object.entries(shapes)) {
const sequenceId = `com.tldraw.shape.${shapeType}`
if (!migrations) {
// provide empty migrations sequence to allow for future migrations
result.push(
createMigrationSequence({
sequenceId,
retroactive: false,
sequence: [],
})
)
} else if ('sequenceId' in migrations) {
assert(
sequenceId === migrations.sequenceId,
`sequenceId mismatch for ${shapeType} shape migrations. Expected '${sequenceId}', got '${migrations.sequenceId}'`
)
result.push(migrations)
} else if ('sequence' in migrations) {
result.push(
createMigrationSequence({
sequenceId,
retroactive: false,
sequence: migrations.sequence.map((m) =>
'id' in m
? {
id: m.id,
scope: 'record',
filter: (r) => r.typeName === 'shape' && (r as TLShape).type === shapeType,
dependsOn: m.dependsOn,
up: (record: any) => {
const result = m.up(record.props)
if (result) {
record.props = result
}
},
down:
typeof m.down === 'function'
? (record: any) => {
const result = (m.down as (props: any) => any)(record.props)
if (result) {
record.props = result
}
}
: undefined,
}
: m
),
})
)
} else {
// legacy migrations, will be removed in the future
result.push(
createMigrationSequence({
sequenceId,
retroactive: false,
sequence: Object.keys(migrations.migrators)
.map((k) => Number(k))
.sort((a: number, b: number) => a - b)
.map(
(version): Migration => ({
id: `${sequenceId}/${version}`,
scope: 'record',
filter: (r) => r.typeName === 'shape' && (r as TLShape).type === shapeType,
up: (record: any) => {
const result = migrations.migrators[version].up(record)
if (result) {
return result
}
},
down: (record: any) => {
const result = migrations.migrators[version].down(record)
if (result) {
return result
}
},
})
),
})
)
}
}
return result
}
/** @internal */ /** @internal */
export function createShapeRecordType(shapes: Record<string, SchemaShapeInfo>) { export function createShapeRecordType(shapes: Record<string, SchemaShapeInfo>) {
return createRecordType<TLShape>('shape', { return createRecordType<TLShape>('shape', {
migrations: defineMigrations({
currentVersion: rootShapeMigrations.currentVersion,
firstVersion: rootShapeMigrations.firstVersion,
migrators: rootShapeMigrations.migrators,
subTypeKey: 'type',
subTypeMigrations: mapObjectMapValues(shapes, (_, v) => v.migrations ?? defineMigrations({})),
}),
scope: 'document', scope: 'document',
validator: T.model( validator: T.model(
'shape', 'shape',

View file

@ -1,6 +1,10 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { vecModelValidator } from '../misc/geometry-types' import { vecModelValidator } from '../misc/geometry-types'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { StyleProp } from '../styles/StyleProp' import { StyleProp } from '../styles/StyleProp'
import { DefaultColorStyle, DefaultLabelColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle, DefaultLabelColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle } from '../styles/TLDashStyle' import { DefaultDashStyle } from '../styles/TLDashStyle'
@ -78,105 +82,57 @@ export type TLArrowShapeProps = ShapePropsType<typeof arrowShapeProps>
/** @public */ /** @public */
export type TLArrowShape = TLBaseShape<'arrow', TLArrowShapeProps> export type TLArrowShape = TLBaseShape<'arrow', TLArrowShapeProps>
export const ArrowMigrationVersions = { export const arrowShapeVersions = createShapePropsMigrationIds('arrow', {
AddLabelColor: 1, AddLabelColor: 1,
AddIsPrecise: 2, AddIsPrecise: 2,
AddLabelPosition: 3, AddLabelPosition: 3,
} as const })
/** @internal */ /** @internal */
export const arrowShapeMigrations = defineMigrations({ export const arrowShapeMigrations = createShapePropsMigrationSequence({
currentVersion: ArrowMigrationVersions.AddLabelPosition, sequence: [
migrators: { {
[ArrowMigrationVersions.AddLabelColor]: { id: arrowShapeVersions.AddLabelColor,
up: (record) => { up: (props) => {
return { props.labelColor = 'black'
...record, },
props: { down: RETIRED_DOWN_MIGRATION,
...record.props, },
labelColor: 'black',
}, {
id: arrowShapeVersions.AddIsPrecise,
up: ({ start, end }) => {
if (start.type === 'binding') {
start.isPrecise = !(start.normalizedAnchor.x === 0.5 && start.normalizedAnchor.y === 0.5)
}
if (end.type === 'binding') {
end.isPrecise = !(end.normalizedAnchor.x === 0.5 && end.normalizedAnchor.y === 0.5)
} }
}, },
down: (record) => { down: ({ start, end }) => {
const { labelColor: _, ...props } = record.props if (start.type === 'binding') {
return { if (!start.isPrecise) {
...record, start.normalizedAnchor = { x: 0.5, y: 0.5 }
props, }
delete start.isPrecise
}
if (end.type === 'binding') {
if (!end.isPrecise) {
end.normalizedAnchor = { x: 0.5, y: 0.5 }
}
delete end.isPrecise
} }
}, },
}, },
[ArrowMigrationVersions.AddIsPrecise]: { {
up: (record) => { id: arrowShapeVersions.AddLabelPosition,
const { start, end } = record.props up: (props) => {
return { props.labelPosition = 0.5
...record,
props: {
...record.props,
start:
(start as TLArrowShapeTerminal).type === 'binding'
? {
...start,
isPrecise: !(
start.normalizedAnchor.x === 0.5 && start.normalizedAnchor.y === 0.5
),
}
: start,
end:
(end as TLArrowShapeTerminal).type === 'binding'
? {
...end,
isPrecise: !(end.normalizedAnchor.x === 0.5 && end.normalizedAnchor.y === 0.5),
}
: end,
},
}
}, },
down: (record: any) => { down: (props) => {
const { start, end } = record.props delete props.labelPosition
const nStart = { ...start }
const nEnd = { ...end }
if (nStart.type === 'binding') {
if (!nStart.isPrecise) {
nStart.normalizedAnchor = { x: 0.5, y: 0.5 }
}
delete nStart.isPrecise
}
if (nEnd.type === 'binding') {
if (!nEnd.isPrecise) {
nEnd.normalizedAnchor = { x: 0.5, y: 0.5 }
}
delete nEnd.isPrecise
}
return {
...record,
props: {
...record.props,
start: nStart,
end: nEnd,
},
}
}, },
}, },
],
[ArrowMigrationVersions.AddLabelPosition]: {
up: (record) => {
return {
...record,
props: {
...record.props,
labelPosition: 0.5,
},
}
},
down: (record) => {
const { labelPosition: _, ...props } = record.props
return {
...record,
props,
}
},
},
},
}) })

View file

@ -1,6 +1,10 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset' import { assetIdValidator } from '../assets/TLBaseAsset'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
/** @public */ /** @public */
@ -17,39 +21,35 @@ export type TLBookmarkShapeProps = ShapePropsType<typeof bookmarkShapeProps>
/** @public */ /** @public */
export type TLBookmarkShape = TLBaseShape<'bookmark', TLBookmarkShapeProps> export type TLBookmarkShape = TLBaseShape<'bookmark', TLBookmarkShapeProps>
const Versions = { const Versions = createShapePropsMigrationIds('bookmark', {
NullAssetId: 1, NullAssetId: 1,
MakeUrlsValid: 2, MakeUrlsValid: 2,
} as const })
export { Versions as bookmarkShapeVersions }
/** @internal */ /** @internal */
export const bookmarkShapeMigrations = defineMigrations({ export const bookmarkShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequence: [
migrators: { {
[Versions.NullAssetId]: { id: Versions.NullAssetId,
up: (shape: TLBookmarkShape) => { up: (props) => {
if (shape.props.assetId === undefined) { if (props.assetId === undefined) {
return { ...shape, props: { ...shape.props, assetId: null } } as typeof shape props.assetId = null
} }
return shape
}, },
down: (shape: TLBookmarkShape) => { down: RETIRED_DOWN_MIGRATION,
if (shape.props.assetId === null) { },
const { assetId: _, ...props } = shape.props {
return { ...shape, props } as typeof shape id: Versions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
} }
return shape },
down: (_props) => {
// noop
}, },
}, },
[Versions.MakeUrlsValid]: { ],
up: (shape) => {
const url = shape.props.url
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) {
return { ...shape, props: { ...shape.props, url: '' } }
}
return shape
},
down: (shape) => shape,
},
},
}) })

View file

@ -1,6 +1,10 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { vecModelValidator } from '../misc/geometry-types' import { vecModelValidator } from '../misc/geometry-types'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle } from '../styles/TLDashStyle' import { DefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultFillStyle } from '../styles/TLFillStyle' import { DefaultFillStyle } from '../styles/TLFillStyle'
@ -33,31 +37,28 @@ export type TLDrawShapeProps = ShapePropsType<typeof drawShapeProps>
/** @public */ /** @public */
export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps> export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps>
const Versions = { const Versions = createShapePropsMigrationIds('draw', {
AddInPen: 1, AddInPen: 1,
} as const })
export { Versions as drawShapeVersions }
/** @internal */ /** @internal */
export const drawShapeMigrations = defineMigrations({ export const drawShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.AddInPen, sequence: [
migrators: { {
[Versions.AddInPen]: { id: Versions.AddInPen,
up: (shape) => { up: (props) => {
// Rather than checking to see whether the shape is a pen at runtime, // Rather than checking to see whether the shape is a pen at runtime,
// from now on we're going to use the type of device reported to us // from now on we're going to use the type of device reported to us
// as well as the pressure data received; but for existing shapes we // as well as the pressure data received; but for existing shapes we
// need to check the pressure data to see if it's a pen or not. // need to check the pressure data to see if it's a pen or not.
const { points } = shape.props.segments[0] const { points } = props.segments[0]
if (points.length === 0) { if (points.length === 0) {
return { props.isPen = false
...shape, return
props: {
...shape.props,
isPen: false,
},
}
} }
let isPen = !(points[0].z === 0 || points[0].z === 0.5) let isPen = !(points[0].z === 0 || points[0].z === 0.5)
@ -66,24 +67,9 @@ export const drawShapeMigrations = defineMigrations({
// Double check if we have a second point (we probably should) // Double check if we have a second point (we probably should)
isPen = isPen && !(points[1].z === 0 || points[1].z === 0.5) isPen = isPen && !(points[1].z === 0 || points[1].z === 0.5)
} }
props.isPen = isPen
return {
...shape,
props: {
...shape.props,
isPen,
},
}
},
down: (shape) => {
const { isPen: _isPen, ...propsWithOutIsPen } = shape.props
return {
...shape,
props: {
...propsWithOutIsPen,
},
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
}, ],
}) })

View file

@ -1,5 +1,9 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
// Only allow multiplayer embeds. If we add additional routes later for example '/help' this won't match // Only allow multiplayer embeds. If we add additional routes later for example '/help' this won't match
@ -612,128 +616,65 @@ export type EmbedDefinition = {
readonly fromEmbedUrl: (url: string) => string | undefined readonly fromEmbedUrl: (url: string) => string | undefined
} }
const Versions = { const Versions = createShapePropsMigrationIds('embed', {
GenOriginalUrlInEmbed: 1, GenOriginalUrlInEmbed: 1,
RemoveDoesResize: 2, RemoveDoesResize: 2,
RemoveTmpOldUrl: 3, RemoveTmpOldUrl: 3,
RemovePermissionOverrides: 4, RemovePermissionOverrides: 4,
} as const })
export { Versions as embedShapeVersions }
/** @internal */ /** @internal */
export const embedShapeMigrations = defineMigrations({ export const embedShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.RemovePermissionOverrides, sequence: [
migrators: { {
[Versions.GenOriginalUrlInEmbed]: { id: Versions.GenOriginalUrlInEmbed,
// add tmpOldUrl property // add tmpOldUrl property
up: (shape) => { up: (props) => {
const url = shape.props.url try {
const host = new URL(url).host.replace('www.', '') const url = props.url
let originalUrl
for (const localEmbedDef of EMBED_DEFINITIONS) {
if ((localEmbedDef as EmbedDefinition).hostnames.includes(host)) {
try {
originalUrl = localEmbedDef.fromEmbedUrl(url)
} catch (err) {
console.warn(err)
}
}
}
return {
...shape,
props: {
...shape.props,
tmpOldUrl: shape.props.url,
url: originalUrl ?? '',
},
}
},
// remove tmpOldUrl property
down: (shape) => {
let newUrl = shape.props.tmpOldUrl
if (!newUrl || newUrl === '') {
const url = shape.props.url
const host = new URL(url).host.replace('www.', '') const host = new URL(url).host.replace('www.', '')
let originalUrl
for (const localEmbedDef of EMBED_DEFINITIONS) { for (const localEmbedDef of EMBED_DEFINITIONS) {
if ((localEmbedDef as EmbedDefinition).hostnames.includes(host)) { if ((localEmbedDef as EmbedDefinition).hostnames.includes(host)) {
try { try {
newUrl = localEmbedDef.toEmbedUrl(url) originalUrl = localEmbedDef.fromEmbedUrl(url)
} catch (err) { } catch (err) {
console.warn(err) console.warn(err)
} }
} }
} }
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars props.tmpOldUrl = props.url
const { tmpOldUrl, ...props } = shape.props props.url = originalUrl ?? ''
return { } catch (e) {
...shape, props.url = ''
props: { props.tmpOldUrl = props.url
...props,
url: newUrl ?? '',
},
} }
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.RemoveDoesResize]: { {
up: (shape) => { id: Versions.RemoveDoesResize,
const { doesResize: _, ...props } = shape.props up: (props) => {
return { delete props.doesResize
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
doesResize: true,
},
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.RemoveTmpOldUrl]: { {
up: (shape) => { id: Versions.RemoveTmpOldUrl,
const { tmpOldUrl: _, ...props } = shape.props up: (props) => {
return { delete props.tmpOldUrl
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
},
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.RemovePermissionOverrides]: { {
up: (shape) => { id: Versions.RemovePermissionOverrides,
const { overridePermissions: _, ...props } = shape.props up: (props) => {
return { delete props.overridePermissions
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
},
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
}, ],
}) })

View file

@ -1,5 +1,5 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { createShapePropsMigrationSequence } from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
/** @public */ /** @public */
@ -15,4 +15,6 @@ type TLFrameShapeProps = ShapePropsType<typeof frameShapeProps>
export type TLFrameShape = TLBaseShape<'frame', TLFrameShapeProps> export type TLFrameShape = TLBaseShape<'frame', TLFrameShapeProps>
/** @internal */ /** @internal */
export const frameShapeMigrations = defineMigrations({}) export const frameShapeMigrations = createShapePropsMigrationSequence({
sequence: [],
})

View file

@ -1,5 +1,9 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { StyleProp } from '../styles/StyleProp' import { StyleProp } from '../styles/StyleProp'
import { DefaultColorStyle, DefaultLabelColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle, DefaultLabelColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle } from '../styles/TLDashStyle' import { DefaultDashStyle } from '../styles/TLDashStyle'
@ -66,7 +70,7 @@ export type TLGeoShapeProps = ShapePropsType<typeof geoShapeProps>
/** @public */ /** @public */
export type TLGeoShape = TLBaseShape<'geo', TLGeoShapeProps> export type TLGeoShape = TLBaseShape<'geo', TLGeoShapeProps>
const Versions = { const geoShapeVersions = createShapePropsMigrationIds('geo', {
AddUrlProp: 1, AddUrlProp: 1,
AddLabelColor: 2, AddLabelColor: 2,
RemoveJustify: 3, RemoveJustify: 3,
@ -75,96 +79,55 @@ const Versions = {
MigrateLegacyAlign: 6, MigrateLegacyAlign: 6,
AddCloud: 7, AddCloud: 7,
MakeUrlsValid: 8, MakeUrlsValid: 8,
} as const })
export { Versions as GeoShapeVersions } export { geoShapeVersions as geoShapeVersions }
/** @internal */ /** @internal */
export const geoShapeMigrations = defineMigrations({ export const geoShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequence: [
migrators: { {
[Versions.AddUrlProp]: { id: geoShapeVersions.AddUrlProp,
up: (shape) => { up: (props) => {
return { ...shape, props: { ...shape.props, url: '' } } props.url = ''
},
down: (shape) => {
const { url: _, ...props } = shape.props
return { ...shape, props }
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.AddLabelColor]: { {
up: (record) => { id: geoShapeVersions.AddLabelColor,
return { up: (props) => {
...record, props.labelColor = 'black'
props: {
...record.props,
labelColor: 'black',
},
}
},
down: (record) => {
const { labelColor: _, ...props } = record.props
return {
...record,
props,
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.RemoveJustify]: { {
up: (shape) => { id: geoShapeVersions.RemoveJustify,
let newAlign = shape.props.align up: (props) => {
if (newAlign === 'justify') { if (props.align === 'justify') {
newAlign = 'start' props.align = 'start'
}
return {
...shape,
props: {
...shape.props,
align: newAlign,
},
} }
}, },
down: (shape) => { down: RETIRED_DOWN_MIGRATION,
return { ...shape }
},
}, },
[Versions.AddCheckBox]: { {
up: (shape) => { id: geoShapeVersions.AddCheckBox,
return { ...shape } up: (_props) => {
}, // noop
down: (shape) => {
return {
...shape,
props: {
...shape.props,
geo: shape.props.geo === 'check-box' ? 'rectangle' : shape.props.geo,
},
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.AddVerticalAlign]: { {
up: (shape) => { id: geoShapeVersions.AddVerticalAlign,
return { up: (props) => {
...shape, props.verticalAlign = 'middle'
props: {
...shape.props,
verticalAlign: 'middle',
},
}
},
down: (shape) => {
const { verticalAlign: _, ...props } = shape.props
return {
...shape,
props,
}
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.MigrateLegacyAlign]: { {
up: (shape) => { id: geoShapeVersions.MigrateLegacyAlign,
up: (props) => {
let newAlign: TLDefaultHorizontalAlignStyle let newAlign: TLDefaultHorizontalAlignStyle
switch (shape.props.align) { switch (props.align) {
case 'start': case 'start':
newAlign = 'start-legacy' newAlign = 'start-legacy'
break break
@ -175,63 +138,27 @@ export const geoShapeMigrations = defineMigrations({
newAlign = 'middle-legacy' newAlign = 'middle-legacy'
break break
} }
return { props.align = newAlign
...shape, },
props: { down: RETIRED_DOWN_MIGRATION,
...shape.props, },
align: newAlign, {
}, id: geoShapeVersions.AddCloud,
up: (_props) => {
// noop
},
down: RETIRED_DOWN_MIGRATION,
},
{
id: geoShapeVersions.MakeUrlsValid,
up: (props) => {
if (!T.linkUrl.isValid(props.url)) {
props.url = ''
} }
}, },
down: (shape) => { down: (_props) => {
let oldAlign: TLDefaultHorizontalAlignStyle // noop
switch (shape.props.align) {
case 'start-legacy':
oldAlign = 'start'
break
case 'end-legacy':
oldAlign = 'end'
break
case 'middle-legacy':
oldAlign = 'middle'
break
default:
oldAlign = shape.props.align
}
return {
...shape,
props: {
...shape.props,
align: oldAlign,
},
}
}, },
}, },
[Versions.AddCloud]: { ],
up: (shape) => {
return shape
},
down: (shape) => {
if (shape.props.geo === 'cloud') {
return {
...shape,
props: {
...shape.props,
geo: 'rectangle',
},
}
}
},
},
[Versions.MakeUrlsValid]: {
up: (shape) => {
const url = shape.props.url
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) {
return { ...shape, props: { ...shape.props, url: '' } }
}
return shape
},
down: (shape) => shape,
},
},
}) })

View file

@ -1,4 +1,4 @@
import { defineMigrations } from '@tldraw/store' import { createShapePropsMigrationSequence } from '../records/TLShape'
import { ShapeProps, TLBaseShape } from './TLBaseShape' import { ShapeProps, TLBaseShape } from './TLBaseShape'
/** @public */ /** @public */
@ -11,4 +11,4 @@ export type TLGroupShape = TLBaseShape<'group', TLGroupShapeProps>
export const groupShapeProps: ShapeProps<TLGroupShape> = {} export const groupShapeProps: ShapeProps<TLGroupShape> = {}
/** @internal */ /** @internal */
export const groupShapeMigrations = defineMigrations({}) export const groupShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })

View file

@ -1,5 +1,5 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { createShapePropsMigrationSequence } from '../records/TLShape'
import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultSizeStyle } from '../styles/TLSizeStyle' import { DefaultSizeStyle } from '../styles/TLSizeStyle'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
@ -21,4 +21,4 @@ export type TLHighlightShapeProps = ShapePropsType<typeof highlightShapeProps>
export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps> export type TLHighlightShape = TLBaseShape<'highlight', TLHighlightShapeProps>
/** @internal */ /** @internal */
export const highlightShapeMigrations = defineMigrations({}) export const highlightShapeMigrations = createShapePropsMigrationSequence({ sequence: [] })

View file

@ -1,7 +1,11 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset' import { assetIdValidator } from '../assets/TLBaseAsset'
import { vecModelValidator } from '../misc/geometry-types' import { vecModelValidator } from '../misc/geometry-types'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
/** @public */ /** @public */
@ -28,43 +32,43 @@ export type TLImageShapeProps = ShapePropsType<typeof imageShapeProps>
/** @public */ /** @public */
export type TLImageShape = TLBaseShape<'image', TLImageShapeProps> export type TLImageShape = TLBaseShape<'image', TLImageShapeProps>
const Versions = { const Versions = createShapePropsMigrationIds('image', {
AddUrlProp: 1, AddUrlProp: 1,
AddCropProp: 2, AddCropProp: 2,
MakeUrlsValid: 3, MakeUrlsValid: 3,
} as const })
export { Versions as imageShapeVersions }
/** @internal */ /** @internal */
export const imageShapeMigrations = defineMigrations({ export const imageShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequence: [
migrators: { {
[Versions.AddUrlProp]: { id: Versions.AddUrlProp,
up: (shape) => { up: (props) => {
return { ...shape, props: { ...shape.props, url: '' } } props.url = ''
}, },
down: (shape) => { down: RETIRED_DOWN_MIGRATION,
const { url: _, ...props } = shape.props },
return { ...shape, props } {
id: Versions.AddCropProp,
up: (props) => {
props.crop = null
},
down: (props) => {
delete props.crop
}, },
}, },
[Versions.AddCropProp]: { {
up: (shape) => { id: Versions.MakeUrlsValid,
return { ...shape, props: { ...shape.props, crop: null } } up: (props) => {
}, if (!T.linkUrl.isValid(props.url)) {
down: (shape) => { props.url = ''
const { crop: _, ...props } = shape.props
return { ...shape, props }
},
},
[Versions.MakeUrlsValid]: {
up: (shape) => {
const url = shape.props.url
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) {
return { ...shape, props: { ...shape.props, url: '' } }
} }
return shape
}, },
down: (shape) => shape, down: (_props) => {
// noop
},
}, },
}, ],
}) })

View file

@ -1,12 +1,10 @@
import { defineMigrations } from '@tldraw/store' import { IndexKey, getIndices, objectMapFromEntries, sortByIndex } from '@tldraw/utils'
import {
IndexKey,
getIndices,
objectMapFromEntries,
sortByIndex,
structuredClone,
} from '@tldraw/utils'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { StyleProp } from '../styles/StyleProp' import { StyleProp } from '../styles/StyleProp'
import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle } from '../styles/TLDashStyle' import { DefaultDashStyle } from '../styles/TLDashStyle'
@ -45,161 +43,120 @@ export type TLLineShapeProps = ShapePropsType<typeof lineShapeProps>
export type TLLineShape = TLBaseShape<'line', TLLineShapeProps> export type TLLineShape = TLBaseShape<'line', TLLineShapeProps>
/** @internal */ /** @internal */
export const lineShapeVersions = { export const lineShapeVersions = createShapePropsMigrationIds('line', {
AddSnapHandles: 1, AddSnapHandles: 1,
RemoveExtraHandleProps: 2, RemoveExtraHandleProps: 2,
HandlesToPoints: 3, HandlesToPoints: 3,
PointIndexIds: 4, PointIndexIds: 4,
} as const })
/** @internal */ /** @internal */
export const lineShapeMigrations = defineMigrations({ export const lineShapeMigrations = createShapePropsMigrationSequence({
currentVersion: lineShapeVersions.PointIndexIds, sequence: [
migrators: { {
[lineShapeVersions.AddSnapHandles]: { id: lineShapeVersions.AddSnapHandles,
up: (record: any) => { up: (props) => {
const handles = structuredClone(record.props.handles as Record<string, any>) for (const handle of Object.values(props.handles)) {
for (const id in handles) { ;(handle as any).canSnap = true
handles[id].canSnap = true
} }
return { ...record, props: { ...record.props, handles } }
},
down: (record: any) => {
const handles = structuredClone(record.props.handles as Record<string, any>)
for (const id in handles) {
delete handles[id].canSnap
}
return { ...record, props: { ...record.props, handles } }
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[lineShapeVersions.RemoveExtraHandleProps]: { {
up: (record: any) => { id: lineShapeVersions.RemoveExtraHandleProps,
return { up: (props) => {
...record, props.handles = objectMapFromEntries(
props: { Object.values(props.handles).map((handle: any) => [
...record.props, handle.index,
handles: objectMapFromEntries( {
Object.values(record.props.handles).map((handle: any) => [ x: handle.x,
handle.index, y: handle.y,
{ },
x: handle.x, ])
y: handle.y, )
},
])
),
},
}
}, },
down: (record: any) => { down: (props) => {
const handles = Object.entries(record.props.handles) const handles = Object.entries(props.handles)
.map(([index, handle]: any) => ({ index, ...handle })) .map(([index, handle]: any) => ({ index, ...handle }))
.sort(sortByIndex) .sort(sortByIndex)
props.handles = Object.fromEntries(
return { handles.map((handle, i) => {
...record, const id =
props: { i === 0 ? 'start' : i === handles.length - 1 ? 'end' : `handle:${handle.index}`
...record.props, return [
handles: Object.fromEntries( id,
handles.map((handle, i) => { {
const id = id,
i === 0 ? 'start' : i === handles.length - 1 ? 'end' : `handle:${handle.index}` type: 'vertex',
return [ canBind: false,
id, canSnap: true,
{ index: handle.index,
id, x: handle.x,
type: 'vertex', y: handle.y,
canBind: false, },
canSnap: true, ]
index: handle.index, })
x: handle.x, )
y: handle.y,
},
]
})
),
},
}
}, },
}, },
[lineShapeVersions.HandlesToPoints]: { {
up: (record: any) => { id: lineShapeVersions.HandlesToPoints,
const { handles, ...props } = record.props up: (props) => {
const sortedHandles = (
const sortedHandles = (Object.entries(handles) as [IndexKey, { x: number; y: number }][]) Object.entries(props.handles) as [IndexKey, { x: number; y: number }][]
)
.map(([index, { x, y }]) => ({ x, y, index })) .map(([index, { x, y }]) => ({ x, y, index }))
.sort(sortByIndex) .sort(sortByIndex)
return { props.points = sortedHandles.map(({ x, y }) => ({ x, y }))
...record, delete props.handles
props: {
...props,
points: sortedHandles.map(({ x, y }) => ({ x, y })),
},
}
}, },
down: (record: any) => { down: (props) => {
const { points, ...props } = record.props const indices = getIndices(props.points.length)
const indices = getIndices(points.length)
return { props.handles = Object.fromEntries(
...record, props.points.map((handle: { x: number; y: number }, i: number) => {
props: { const index = indices[i]
...props, return [
handles: Object.fromEntries( index,
points.map((handle: { x: number; y: number }, i: number) => { {
const index = indices[i] x: handle.x,
return [ y: handle.y,
index, },
{ ]
x: handle.x, })
y: handle.y, )
},
] delete props.points
})
),
},
}
}, },
}, },
[lineShapeVersions.PointIndexIds]: { {
up: (record: any) => { id: lineShapeVersions.PointIndexIds,
const { points, ...props } = record.props up: (props) => {
const indices = getIndices(points.length) const indices = getIndices(props.points.length)
return { props.points = Object.fromEntries(
...record, props.points.map((point: { x: number; y: number }, i: number) => {
props: { const id = indices[i]
...props, return [
points: Object.fromEntries( id,
points.map((point: { x: number; y: number }, i: number) => { {
const id = indices[i] id: id,
return [ index: id,
id, x: point.x,
{ y: point.y,
id: id, },
index: id, ]
x: point.x, })
y: point.y, )
},
]
})
),
},
}
}, },
down: (record: any) => { down: (props) => {
const sortedHandles = ( const sortedHandles = (
Object.values(record.props.points) as { x: number; y: number; index: IndexKey }[] Object.values(props.points) as { x: number; y: number; index: IndexKey }[]
).sort(sortByIndex) ).sort(sortByIndex)
return { props.points = sortedHandles.map(({ x, y }) => ({ x, y }))
...record,
props: {
...record.props,
points: sortedHandles.map(({ x, y }) => ({ x, y })),
},
}
}, },
}, },
}, ],
}) })

View file

@ -1,11 +1,12 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultFontStyle } from '../styles/TLFontStyle' import { DefaultFontStyle } from '../styles/TLFontStyle'
import { import { DefaultHorizontalAlignStyle } from '../styles/TLHorizontalAlignStyle'
DefaultHorizontalAlignStyle,
TLDefaultHorizontalAlignStyle,
} from '../styles/TLHorizontalAlignStyle'
import { DefaultSizeStyle } from '../styles/TLSizeStyle' import { DefaultSizeStyle } from '../styles/TLSizeStyle'
import { DefaultVerticalAlignStyle } from '../styles/TLVerticalAlignStyle' import { DefaultVerticalAlignStyle } from '../styles/TLVerticalAlignStyle'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
@ -14,8 +15,8 @@ import { ShapePropsType, TLBaseShape } from './TLBaseShape'
export const noteShapeProps = { export const noteShapeProps = {
color: DefaultColorStyle, color: DefaultColorStyle,
size: DefaultSizeStyle, size: DefaultSizeStyle,
fontSizeAdjustment: T.positiveNumber,
font: DefaultFontStyle, font: DefaultFontStyle,
fontSizeAdjustment: T.positiveNumber,
align: DefaultHorizontalAlignStyle, align: DefaultHorizontalAlignStyle,
verticalAlign: DefaultVerticalAlignStyle, verticalAlign: DefaultVerticalAlignStyle,
growY: T.positiveNumber, growY: T.positiveNumber,
@ -29,131 +30,79 @@ export type TLNoteShapeProps = ShapePropsType<typeof noteShapeProps>
/** @public */ /** @public */
export type TLNoteShape = TLBaseShape<'note', TLNoteShapeProps> export type TLNoteShape = TLBaseShape<'note', TLNoteShapeProps>
export const noteShapeVersions = { const Versions = createShapePropsMigrationIds('note', {
AddUrlProp: 1, AddUrlProp: 1,
RemoveJustify: 2, RemoveJustify: 2,
MigrateLegacyAlign: 3, MigrateLegacyAlign: 3,
AddVerticalAlign: 4, AddVerticalAlign: 4,
MakeUrlsValid: 5, MakeUrlsValid: 5,
AddFontSizeAdjustment: 6, AddFontSizeAdjustment: 6,
} as const })
export { Versions as noteShapeVersions }
/** @internal */ /** @internal */
export const noteShapeMigrations = defineMigrations({ export const noteShapeMigrations = createShapePropsMigrationSequence({
currentVersion: noteShapeVersions.AddFontSizeAdjustment, sequence: [
migrators: { {
[noteShapeVersions.AddUrlProp]: { id: Versions.AddUrlProp,
up: (shape) => { up: (props) => {
return { ...shape, props: { ...shape.props, url: '' } } props.url = ''
},
down: (shape) => {
const { url: _, ...props } = shape.props
return { ...shape, props }
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[noteShapeVersions.RemoveJustify]: { {
up: (shape) => { id: Versions.RemoveJustify,
let newAlign = shape.props.align up: (props) => {
if (newAlign === 'justify') { if (props.align === 'justify') {
newAlign = 'start' props.align = 'start'
}
return {
...shape,
props: {
...shape.props,
align: newAlign,
},
} }
}, },
down: (shape) => { down: RETIRED_DOWN_MIGRATION,
return { ...shape }
},
}, },
{
[noteShapeVersions.MigrateLegacyAlign]: { id: Versions.MigrateLegacyAlign,
up: (shape) => { up: (props) => {
let newAlign: TLDefaultHorizontalAlignStyle switch (props.align) {
switch (shape.props.align) {
case 'start': case 'start':
newAlign = 'start-legacy' props.align = 'start-legacy'
break return
case 'end': case 'end':
newAlign = 'end-legacy' props.align = 'end-legacy'
break return
default: default:
newAlign = 'middle-legacy' props.align = 'middle-legacy'
break return
}
return {
...shape,
props: {
...shape.props,
align: newAlign,
},
} }
}, },
down: (shape) => { down: RETIRED_DOWN_MIGRATION,
let oldAlign: TLDefaultHorizontalAlignStyle },
switch (shape.props.align) { {
case 'start-legacy': id: Versions.AddVerticalAlign,
oldAlign = 'start' up: (props) => {
break props.verticalAlign = 'middle'
case 'end-legacy': },
oldAlign = 'end' down: RETIRED_DOWN_MIGRATION,
break },
case 'middle-legacy': {
oldAlign = 'middle' id: Versions.MakeUrlsValid,
break up: (props) => {
default: if (!T.linkUrl.isValid(props.url)) {
oldAlign = shape.props.align props.url = ''
}
return {
...shape,
props: {
...shape.props,
align: oldAlign,
},
} }
}, },
down: (_props) => {
// noop
},
}, },
[noteShapeVersions.AddVerticalAlign]: { {
up: (shape) => { id: Versions.AddFontSizeAdjustment,
return { up: (props) => {
...shape, props.fontSizeAdjustment = 0
props: {
...shape.props,
verticalAlign: 'middle',
},
}
}, },
down: (shape) => { down: (props) => {
const { verticalAlign: _, ...props } = shape.props delete props.fontSizeAdjustment
return {
...shape,
props,
}
}, },
}, },
[noteShapeVersions.MakeUrlsValid]: { ],
up: (shape) => {
const url = shape.props.url
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) {
return { ...shape, props: { ...shape.props, url: '' } }
}
return shape
},
down: (shape) => shape,
},
[noteShapeVersions.AddFontSizeAdjustment]: {
up: (shape) => {
return { ...shape, props: { ...shape.props, fontSizeAdjustment: 0 } }
},
down: (shape) => {
const { fontSizeAdjustment: _, ...props } = shape.props
return { ...shape, props }
},
},
},
}) })

View file

@ -1,5 +1,9 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { DefaultColorStyle } from '../styles/TLColorStyle' import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultFontStyle } from '../styles/TLFontStyle' import { DefaultFontStyle } from '../styles/TLFontStyle'
import { DefaultHorizontalAlignStyle } from '../styles/TLHorizontalAlignStyle' import { DefaultHorizontalAlignStyle } from '../styles/TLHorizontalAlignStyle'
@ -24,32 +28,23 @@ export type TLTextShapeProps = ShapePropsType<typeof textShapeProps>
/** @public */ /** @public */
export type TLTextShape = TLBaseShape<'text', TLTextShapeProps> export type TLTextShape = TLBaseShape<'text', TLTextShapeProps>
const Versions = { const Versions = createShapePropsMigrationIds('text', {
RemoveJustify: 1, RemoveJustify: 1,
} as const })
export { Versions as textShapeVersions }
/** @internal */ /** @internal */
export const textShapeMigrations = defineMigrations({ export const textShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.RemoveJustify, sequence: [
migrators: { {
[Versions.RemoveJustify]: { id: Versions.RemoveJustify,
up: (shape) => { up: (props) => {
let newAlign = shape.props.align if (props.align === 'justify') {
if (newAlign === 'justify') { props.align = 'start'
newAlign = 'start'
}
return {
...shape,
props: {
...shape.props,
align: newAlign,
},
} }
}, },
down: (shape) => { down: RETIRED_DOWN_MIGRATION,
return { ...shape }
},
}, },
}, ],
}) })

View file

@ -1,6 +1,10 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate' import { T } from '@tldraw/validate'
import { assetIdValidator } from '../assets/TLBaseAsset' import { assetIdValidator } from '../assets/TLBaseAsset'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape' import { ShapePropsType, TLBaseShape } from './TLBaseShape'
/** @public */ /** @public */
@ -19,33 +23,33 @@ export type TLVideoShapeProps = ShapePropsType<typeof videoShapeProps>
/** @public */ /** @public */
export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps> export type TLVideoShape = TLBaseShape<'video', TLVideoShapeProps>
const Versions = { const Versions = createShapePropsMigrationIds('video', {
AddUrlProp: 1, AddUrlProp: 1,
MakeUrlsValid: 2, MakeUrlsValid: 2,
} as const })
export { Versions as videoShapeVersions }
/** @internal */ /** @internal */
export const videoShapeMigrations = defineMigrations({ export const videoShapeMigrations = createShapePropsMigrationSequence({
currentVersion: Versions.MakeUrlsValid, sequence: [
migrators: { {
[Versions.AddUrlProp]: { id: Versions.AddUrlProp,
up: (shape) => { up: (props) => {
return { ...shape, props: { ...shape.props, url: '' } } props.url = ''
},
down: (shape) => {
const { url: _, ...props } = shape.props
return { ...shape, props }
}, },
down: RETIRED_DOWN_MIGRATION,
}, },
[Versions.MakeUrlsValid]: { {
up: (shape) => { id: Versions.MakeUrlsValid,
const url = shape.props.url up: (props) => {
if (url !== '' && !T.linkUrl.isValid(shape.props.url)) { if (!T.linkUrl.isValid(props.url)) {
return { ...shape, props: { ...shape.props, url: '' } } props.url = ''
} }
return shape
}, },
down: (shape) => shape, down: (_props) => {
// noop
},
}, },
}, ],
}) })

Some files were not shown because too many files have changed in this diff Show more