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>
pull/3471/head
David Sheldrick 2024-04-15 13:53:42 +01:00 zatwierdzone przez GitHub
rodzic 63f20d1834
commit 4f70a4f4e8
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
112 zmienionych plików z 109320 dodań i 106484 usunięć

2
.gitignore vendored
Wyświetl plik

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

Plik diff jest za duży Load Diff

Plik diff jest za duży Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -1,9 +1,75 @@
---
title: Collaboration
status: published
author: steveruizok
author: ds300
date: 3/22/2023
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.

Wyświetl plik

@ -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.
### 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.
```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`).
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.
### Undo and redo

Wyświetl plik

@ -17,7 +17,7 @@ Persistence in tldraw means storing information about the editor's state to a da
## 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
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.
## Snapshots
## Document Snapshots
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.
> 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!
@ -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).
## 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.

Wyświetl plik

@ -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.
...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.

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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)
export const cardShapeMigrations = defineMigrations({
currentVersion: 1,
migrators: {
1: {
// for example, removing a property from the shape
up(shape) {
const migratedUpShape = { ...shape }
delete migratedUpShape._somePropertyToRemove
return migratedUpShape
export const cardShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: versions.AddSomeProperty,
up(props) {
// it is safe to mutate the props object here
props.someProperty = 'some value'
},
down(shape) {
const migratedDownShape = { ...shape }
migratedDownShape._somePropertyToRemove = 'some value'
return migratedDownShape
down(props) {
delete props.someProperty
},
},
},
],
})

Wyświetl plik

@ -1,5 +1,14 @@
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 initialSnapshot from './snapshot.json'
@ -7,7 +16,9 @@ import initialSnapshot from './snapshot.json'
export default function TldrawImageExample() {
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 [showBackground, setShowBackground] = useState(true)
const [isDarkMode, setIsDarkMode] = useState(false)

Wyświetl plik

@ -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>
)
}

Wyświetl plik

@ -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.

Wyświetl plik

@ -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

Wyświetl plik

@ -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>
)
}),
}

Wyświetl plik

@ -84,7 +84,7 @@ deletes that shape.
[1]
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.

Wyświetl plik

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

Wyświetl plik

@ -1,6 +1,8 @@
import { Tldraw } from 'tldraw'
import { TLStoreSnapshot, Tldraw } from 'tldraw'
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!

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,11 +1,5 @@
import { Signal, computed, transact } from '@tldraw/state'
import {
RecordsDiff,
UnknownRecord,
defineMigrations,
migrate,
squashRecordDiffs,
} from '@tldraw/store'
import { RecordsDiff, UnknownRecord, squashRecordDiffs } from '@tldraw/store'
import {
CameraRecordType,
InstancePageStateRecordType,
@ -22,6 +16,7 @@ import {
getFromSessionStorage,
objectMapFromEntries,
setInSessionStorage,
structuredClone,
} from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { uniqueId } from '../utils/uniqueId'
@ -79,7 +74,18 @@ const Versions = {
Initial: 0,
} 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.
@ -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 {
if (!state || typeof state !== 'object') {
console.warn('Invalid instance state')
@ -137,27 +139,17 @@ function migrateAndValidateSessionStateSnapshot(state: unknown): TLSessionStateS
console.warn('No version in instance state')
return null
}
const result = migrate<TLSessionStateSnapshot>({
value: state,
fromVersion: state.version,
toVersion: CURRENT_SESSION_STATE_SNAPSHOT_VERSION,
migrations: sessionStateSnapshotMigrations,
})
if (result.type === 'error') {
console.warn(result.reason)
return null
if (state.version !== CURRENT_SESSION_STATE_SNAPSHOT_VERSION) {
state = structuredClone(state)
migrate(state)
}
const value = { ...result.value, version: CURRENT_SESSION_STATE_SNAPSHOT_VERSION }
try {
sessionStateSnapshotValidator.validate(value)
return sessionStateSnapshotValidator.validate(state)
} catch (e) {
console.warn(e)
return null
}
return value
}
/**

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -217,24 +217,6 @@ export class Editor extends EventEmitter<TLEventMap> {
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 _styleProps = {} as Record<string, Map<StyleProp<unknown>, string>>
const allStylesById = new Map<string, StyleProp<unknown>>()

Wyświetl plik

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

Wyświetl plik

@ -69,7 +69,7 @@ test('the client connects on instantiation, announcing its schema', async () =>
expect(channel.postMessage).toHaveBeenCalledTimes(1)
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 () => {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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
export function logComputedGetterWarning() {

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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
}

Wyświetl plik

@ -1,26 +1,27 @@
import { UnknownRecord, isRecord } from './BaseRecord'
import { SerializedSchema } from './StoreSchema'
import { assert, objectMapEntries } from '@tldraw/utils'
import { UnknownRecord } from './BaseRecord'
import { SerializedStore } from './Store'
type EMPTY_SYMBOL = symbol
let didWarn = false
/** @public */
export function defineMigrations<
FirstVersion extends number | EMPTY_SYMBOL = EMPTY_SYMBOL,
CurrentVersion extends Exclude<number, 0> | EMPTY_SYMBOL = EMPTY_SYMBOL,
>(opts: {
firstVersion?: CurrentVersion extends number ? FirstVersion : never
currentVersion?: CurrentVersion
migrators?: CurrentVersion extends number
? 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
/**
* @public
* @deprecated use `createShapePropsMigrationSequence` instead. See [the docs](https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations) for how to migrate.
*/
export function defineMigrations(opts: {
firstVersion?: number
currentVersion?: number
migrators?: Record<number, LegacyMigration>
subTypeKey?: string
subTypeMigrations?: Record<string, BaseMigrationsInfo>
}): Migrations {
subTypeMigrations?: Record<string, LegacyBaseMigrationsInfo>
}): LegacyMigrations {
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
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 */
export type Migration<Before = any, After = any> = {
export type LegacyMigration<Before = any, After = any> = {
up: (oldState: Before) => After
down: (newState: After) => Before
}
interface BaseMigrationsInfo {
firstVersion: number
currentVersion: number
migrators: { [version: number]: Migration }
/** @public */
export type MigrationId = `${string}/${number}`
export type StandaloneDependsOn = {
readonly dependsOn: readonly MigrationId[]
}
/** @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
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 */
@ -72,246 +287,3 @@ export enum MigrationFailureReason {
MigrationError = 'migration-error',
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

Wyświetl plik

@ -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)
})
})

Wyświetl plik

@ -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'])
})
})

Wyświetl plik

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

Wyświetl plik

@ -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)
})
})

Wyświetl plik

@ -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([])
})
})

Wyświetl plik

@ -1,4 +1,3 @@
import { MigrationFailureReason } from '../migrate'
import { SerializedStore } from '../Store'
import { testSchemaV0 } from './testSchema.v0'
import { testSchemaV1 } from './testSchema.v1'
@ -9,23 +8,8 @@ const serializedV1Schenma = testSchemaV1.serialize()
test('serializedV0Schenma', () => {
expect(serializedV0Schenma).toMatchInlineSnapshot(`
{
"recordVersions": {
"org": {
"version": 0,
},
"shape": {
"subTypeKey": "type",
"subTypeVersions": {
"rectangle": 0,
},
"version": 0,
},
"user": {
"version": 0,
},
},
"schemaVersion": 1,
"storeVersion": 0,
"schemaVersion": 2,
"sequences": {},
}
`)
})
@ -33,188 +17,18 @@ test('serializedV0Schenma', () => {
test('serializedV1Schenma', () => {
expect(serializedV1Schenma).toMatchInlineSnapshot(`
{
"recordVersions": {
"shape": {
"subTypeKey": "type",
"subTypeVersions": {
"oval": 1,
"rectangle": 1,
},
"version": 2,
},
"user": {
"version": 2,
},
"schemaVersion": 2,
"sequences": {
"com.tldraw.shape": 2,
"com.tldraw.shape.oval": 1,
"com.tldraw.shape.rectangle": 1,
"com.tldraw.store": 1,
"com.tldraw.user": 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', () => {
expect(
testSchemaV1.migratePersistedRecord(
@ -225,9 +39,8 @@ test('unknown types fail', () => {
serializedV0Schenma,
'up'
)
).toEqual({
).toMatchObject({
type: 'error',
reason: MigrationFailureReason.UnknownType,
})
expect(
@ -239,68 +52,8 @@ test('unknown types fail', () => {
serializedV0Schenma,
'down'
)
).toEqual({
).toMatchObject({
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,
})
})

Wyświetl plik

@ -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 })
})

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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"`)
})
})

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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,
},
},
}
`)
})
})

Wyświetl plik

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

Wyświetl plik

@ -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'"`
)
})
})

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -6,6 +6,8 @@ import {
RecordId,
Result,
SerializedSchema,
SerializedSchemaV1,
SerializedSchemaV2,
SerializedStore,
T,
TLAsset,
@ -40,19 +42,29 @@ export interface TldrawFile {
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({
tldrawFileFormatVersion: T.nonZeroInteger,
schema: T.object({
schemaVersion: T.positiveInteger,
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(),
})
),
schema: T.union('schemaVersion', {
1: schemaV1,
2: schemaV2,
}),
records: T.arrayOf(
T.object({

Wyświetl plik

@ -15,10 +15,6 @@ import { GeoShapeUtil } from '../lib/shapes/geo/GeoShapeUtil'
import { renderTldrawComponent } from './testutils/renderTldrawComponent'
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)
}

Wyświetl plik

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

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -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
},
}
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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 { T } from '@tldraw/validate'
import { BoxModel, boxModelValidator } from '../misc/geometry-types'
@ -121,7 +127,6 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
)
return createRecordType<TLInstance>('instance', {
migrations: instanceMigrations,
validator: instanceTypeValidator,
scope: 'session',
}).withDefaultProperties(
@ -162,7 +167,7 @@ export function createInstanceRecordType(stylesById: Map<string, StyleProp<unkno
}
/** @internal */
export const instanceVersions = {
export const instanceVersions = createMigrationIds('com.tldraw.instance', {
AddTransparentExportBgs: 1,
RemoveDialog: 2,
AddToolLockMode: 3,
@ -187,37 +192,36 @@ export const instanceVersions = {
AddScribbles: 22,
AddInset: 23,
AddDuplicateProps: 24,
} as const
} as const)
// TODO: rewrite these to use mutation
/** @public */
export const instanceMigrations = defineMigrations({
currentVersion: instanceVersions.AddDuplicateProps,
migrators: {
[instanceVersions.AddTransparentExportBgs]: {
up: (instance: TLInstance) => {
export const instanceMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.instance',
recordType: 'instance',
sequence: [
{
id: instanceVersions.AddTransparentExportBgs,
up: (instance) => {
return { ...instance, exportBackground: true }
},
down: ({ exportBackground: _, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.RemoveDialog]: {
{
id: instanceVersions.RemoveDialog,
up: ({ dialog: _, ...instance }: any) => {
return instance
},
down: (instance: TLInstance) => {
return { ...instance, dialog: null }
},
},
[instanceVersions.AddToolLockMode]: {
up: (instance: TLInstance) => {
{
id: instanceVersions.AddToolLockMode,
up: (instance) => {
return { ...instance, isToolLocked: false }
},
down: ({ isToolLocked: _, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.RemoveExtraPropsForNextShape]: {
{
id: instanceVersions.RemoveExtraPropsForNextShape,
up: ({ propsForNextShape, ...instance }: any) => {
return {
...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) => {
return {
...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 }
},
down: ({ followingUserId: _, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.RemoveAlignJustify]: {
{
id: instanceVersions.RemoveAlignJustify,
up: (instance: any) => {
let newAlign = instance.propsForNextShape.align
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 }
},
down: ({ zoomBrush: _, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.AddVerticalAlign]: {
up: (instance) => {
{
id: instanceVersions.AddVerticalAlign,
up: (instance: any) => {
return {
...instance,
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) {
return { ...instance, scribble: { ...instance.scribble, delay: 0 } }
}
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) => {
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 }
},
down: ({ isPenMode: _, isGridMode: __, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.HoistOpacity]: {
{
id: instanceVersions.HoistOpacity,
up: ({ propsForNextShape: { opacity, ...propsForNextShape }, ...instance }: any) => {
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 }
},
down: ({ chatMessage: _, isChatting: __, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.AddHighlightedUserIds]: {
up: (instance: TLInstance) => {
{
id: instanceVersions.AddHighlightedUserIds,
up: (instance) => {
return { ...instance, highlightedUserIds: [] }
},
down: ({ highlightedUserIds: _, ...instance }: TLInstance) => {
return instance
},
},
[instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape]: {
up: ({ propsForNextShape: _, ...instance }) => {
{
id: instanceVersions.ReplacePropsForNextShapeWithStylesForNextShape,
up: ({ propsForNextShape: _, ...instance }: any) => {
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) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
},
},
[instanceVersions.RemoveCursorColor]: {
up: (record) => {
{
id: instanceVersions.RemoveCursorColor,
up: (record: any) => {
const { color: _, ...cursor } = record.cursor
return {
...record,
cursor,
}
},
down: (record) => {
return {
...record,
cursor: {
...record.cursor,
color: 'black',
},
}
},
},
[instanceVersions.AddLonelyProperties]: {
{
id: instanceVersions.AddLonelyProperties,
up: (record) => {
return {
...record,
@ -459,86 +378,63 @@ export const instanceMigrations = defineMigrations({
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 {
...record,
isReadonly: _isReadOnly,
}
},
down: ({ isReadonly: _isReadonly, ...record }) => {
return {
...record,
isReadOnly: _isReadonly,
}
},
},
[instanceVersions.AddHoveringCanvas]: {
{
id: instanceVersions.AddHoveringCanvas,
up: (record) => {
return {
...record,
isHoveringCanvas: null,
}
},
down: ({ isHoveringCanvas: _, ...record }) => {
return {
...record,
}
},
},
[instanceVersions.AddScribbles]: {
up: ({ scribble: _, ...record }) => {
{
id: instanceVersions.AddScribbles,
up: ({ scribble: _, ...record }: any) => {
return {
...record,
scribbles: [],
}
},
down: ({ scribbles: _, ...record }) => {
return { ...record, scribble: null }
},
},
[instanceVersions.AddInset]: {
{
id: instanceVersions.AddInset,
up: (record) => {
return {
...record,
insets: [false, false, false, false],
}
},
down: ({ insets: _, ...record }) => {
down: ({ insets: _, ...record }: any) => {
return {
...record,
}
},
},
[instanceVersions.AddDuplicateProps]: {
{
id: instanceVersions.AddDuplicateProps,
up: (record) => {
return {
...record,
duplicateProps: null,
}
},
down: ({ duplicateProps: _, ...record }) => {
down: ({ duplicateProps: _, ...record }: any) => {
return {
...record,
}
},
},
},
],
})
/** @public */

Wyświetl plik

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

Wyświetl plik

@ -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 { T } from '@tldraw/validate'
import { idValidator } from '../misc/id-validator'
import { shapeIdValidator } from '../shapes/TLBaseShape'
import { CameraRecordType } from './TLCamera'
import { TLINSTANCE_ID } from './TLInstance'
import { pageIdValidator, TLPage } from './TLPage'
import { TLShapeId } from './TLShape'
@ -47,155 +51,91 @@ export const instancePageStateValidator: T.Validator<TLInstancePageState> = T.mo
)
/** @internal */
export const instancePageStateVersions = {
export const instancePageStateVersions = createMigrationIds('com.tldraw.instance_page_state', {
AddCroppingId: 1,
RemoveInstanceIdAndCameraId: 2,
AddMeta: 3,
RenameProperties: 4,
RenamePropertiesAgain: 5,
} as const
} as const)
/** @public */
export const instancePageStateMigrations = defineMigrations({
currentVersion: instancePageStateVersions.RenamePropertiesAgain,
migrators: {
[instancePageStateVersions.AddCroppingId]: {
up(instance) {
return { ...instance, croppingShapeId: null }
},
down({ croppingShapeId: _croppingShapeId, ...instance }) {
return instance
export const instancePageStateMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.instance_page_state',
recordType: 'instance_page_state',
sequence: [
{
id: instancePageStateVersions.AddCroppingId,
up(instance: any) {
instance.croppingShapeId = null
},
},
[instancePageStateVersions.RemoveInstanceIdAndCameraId]: {
up({ instanceId: _, cameraId: __, ...instance }) {
return instance
},
down(instance) {
// this should never be called since we bump the schema version
return {
...instance,
instanceId: TLINSTANCE_ID,
cameraId: CameraRecordType.createId('void'),
}
{
id: instancePageStateVersions.RemoveInstanceIdAndCameraId,
up(instance: any) {
delete instance.instanceId
delete instance.cameraId
},
},
[instancePageStateVersions.AddMeta]: {
up: (record) => {
return {
...record,
meta: {},
}
},
down: ({ meta: _, ...record }) => {
return {
...record,
}
{
id: instancePageStateVersions.AddMeta,
up: (record: any) => {
record.meta = {}
},
},
[instancePageStateVersions.RenameProperties]: {
{
id: instancePageStateVersions.RenameProperties,
// 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.
up: (record) => {
const {
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,
}
up: (_record) => {
// noop
},
down: (record) => {
const {
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) => {
// noop
},
},
[instancePageStateVersions.RenamePropertiesAgain]: {
up: (record) => {
const {
selectedIds,
hintingIds,
erasingIds,
hoveredId,
editingId,
croppingShapeId,
croppingId,
focusLayerId,
...rest
} = record
return {
...rest,
selectedShapeIds: selectedIds,
hintingShapeIds: hintingIds,
erasingShapeIds: erasingIds,
hoveredShapeId: hoveredId,
editingShapeId: editingId,
croppingShapeId: croppingShapeId ?? croppingId ?? null,
focusedGroupId: focusLayerId,
}
{
id: instancePageStateVersions.RenamePropertiesAgain,
up: (record: any) => {
record.selectedShapeIds = record.selectedIds
delete record.selectedIds
record.hintingShapeIds = record.hintingIds
delete record.hintingIds
record.erasingShapeIds = record.erasingIds
delete record.erasingIds
record.hoveredShapeId = record.hoveredId
delete record.hoveredId
record.editingShapeId = record.editingId
delete record.editingId
record.croppingShapeId = record.croppingShapeId ?? record.croppingId ?? null
delete record.croppingId
record.focusedGroupId = record.focusLayerId
delete record.focusLayerId
},
down: (record) => {
const {
selectedShapeIds,
hintingShapeIds,
erasingShapeIds,
hoveredShapeId,
editingShapeId,
croppingShapeId,
focusedGroupId,
...rest
} = record
return {
...rest,
selectedIds: selectedShapeIds,
hintingIds: hintingShapeIds,
erasingIds: erasingShapeIds,
hoveredId: hoveredShapeId,
editingId: editingShapeId,
croppingId: croppingShapeId,
focusLayerId: focusedGroupId,
}
down: (record: any) => {
record.selectedIds = record.selectedShapeIds
delete record.selectedShapeIds
record.hintingIds = record.hintingShapeIds
delete record.hintingShapeIds
record.erasingIds = record.erasingShapeIds
delete record.erasingShapeIds
record.hoveredId = record.hoveredShapeId
delete record.hoveredShapeId
record.editingId = record.editingShapeId
delete record.editingShapeId
record.croppingId = record.croppingShapeId
delete record.croppingShapeId
record.focusLayerId = record.focusedGroupId
delete record.focusedGroupId
},
},
},
],
})
/** @public */
export const InstancePageStateRecordType = createRecordType<TLInstancePageState>(
'instance_page_state',
{
migrations: instancePageStateMigrations,
validator: instancePageStateValidator,
scope: 'session',
}

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,10 +1,20 @@
import { createRecordType, defineMigrations, RecordId, UnknownRecord } from '@tldraw/store'
import { mapObjectMapValues } from '@tldraw/utils'
import {
Migration,
MigrationId,
MigrationSequence,
RecordId,
UnknownRecord,
createMigrationIds,
createMigrationSequence,
createRecordMigrationSequence,
createRecordType,
} from '@tldraw/store'
import { assert, mapObjectMapValues } from '@tldraw/utils'
import { T } from '@tldraw/validate'
import { nanoid } from 'nanoid'
import { SchemaShapeInfo } from '../createTLSchema'
import { TLArrowShape } from '../shapes/TLArrowShape'
import { createShapeValidator, TLBaseShape } from '../shapes/TLBaseShape'
import { TLBaseShape, createShapeValidator } from '../shapes/TLBaseShape'
import { TLBookmarkShape } from '../shapes/TLBookmarkShape'
import { TLDrawShape } from '../shapes/TLDrawShape'
import { TLEmbedShape } from '../shapes/TLEmbedShape'
@ -83,88 +93,66 @@ export type TLShapeProp = keyof TLShapeProps
export type TLParentId = TLPageId | TLShapeId
/** @internal */
export const rootShapeVersions = {
export const rootShapeVersions = createMigrationIds('com.tldraw.shape', {
AddIsLocked: 1,
HoistOpacity: 2,
AddMeta: 3,
AddWhite: 4,
} as const
} as const)
/** @internal */
export const rootShapeMigrations = defineMigrations({
currentVersion: rootShapeVersions.AddWhite,
migrators: {
[rootShapeVersions.AddIsLocked]: {
up: (record) => {
return {
...record,
isLocked: false,
}
export const rootShapeMigrations = createRecordMigrationSequence({
sequenceId: 'com.tldraw.shape',
recordType: 'shape',
sequence: [
{
id: rootShapeVersions.AddIsLocked,
up: (record: any) => {
record.isLocked = false
},
down: (record) => {
const { isLocked: _, ...rest } = record
return {
...rest,
down: (record: any) => {
delete record.isLocked
},
},
{
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 */
@ -200,16 +188,142 @@ export function getShapePropKeysByStyle(props: Record<string, T.Validatable<any>
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 */
export function createShapeRecordType(shapes: Record<string, SchemaShapeInfo>) {
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',
validator: T.model(
'shape',

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -1,6 +1,10 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate'
import { vecModelValidator } from '../misc/geometry-types'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { DefaultColorStyle } from '../styles/TLColorStyle'
import { DefaultDashStyle } from '../styles/TLDashStyle'
import { DefaultFillStyle } from '../styles/TLFillStyle'
@ -33,31 +37,28 @@ export type TLDrawShapeProps = ShapePropsType<typeof drawShapeProps>
/** @public */
export type TLDrawShape = TLBaseShape<'draw', TLDrawShapeProps>
const Versions = {
const Versions = createShapePropsMigrationIds('draw', {
AddInPen: 1,
} as const
})
export { Versions as drawShapeVersions }
/** @internal */
export const drawShapeMigrations = defineMigrations({
currentVersion: Versions.AddInPen,
migrators: {
[Versions.AddInPen]: {
up: (shape) => {
export const drawShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.AddInPen,
up: (props) => {
// 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
// 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.
const { points } = shape.props.segments[0]
const { points } = props.segments[0]
if (points.length === 0) {
return {
...shape,
props: {
...shape.props,
isPen: false,
},
}
props.isPen = false
return
}
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)
isPen = isPen && !(points[1].z === 0 || points[1].z === 0.5)
}
return {
...shape,
props: {
...shape.props,
isPen,
},
}
},
down: (shape) => {
const { isPen: _isPen, ...propsWithOutIsPen } = shape.props
return {
...shape,
props: {
...propsWithOutIsPen,
},
}
props.isPen = isPen
},
down: RETIRED_DOWN_MIGRATION,
},
},
],
})

Wyświetl plik

@ -1,5 +1,9 @@
import { defineMigrations } from '@tldraw/store'
import { T } from '@tldraw/validate'
import {
RETIRED_DOWN_MIGRATION,
createShapePropsMigrationIds,
createShapePropsMigrationSequence,
} from '../records/TLShape'
import { ShapePropsType, TLBaseShape } from './TLBaseShape'
// 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
}
const Versions = {
const Versions = createShapePropsMigrationIds('embed', {
GenOriginalUrlInEmbed: 1,
RemoveDoesResize: 2,
RemoveTmpOldUrl: 3,
RemovePermissionOverrides: 4,
} as const
})
export { Versions as embedShapeVersions }
/** @internal */
export const embedShapeMigrations = defineMigrations({
currentVersion: Versions.RemovePermissionOverrides,
migrators: {
[Versions.GenOriginalUrlInEmbed]: {
export const embedShapeMigrations = createShapePropsMigrationSequence({
sequence: [
{
id: Versions.GenOriginalUrlInEmbed,
// add tmpOldUrl property
up: (shape) => {
const url = shape.props.url
const host = new URL(url).host.replace('www.', '')
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
up: (props) => {
try {
const url = props.url
const host = new URL(url).host.replace('www.', '')
let originalUrl
for (const localEmbedDef of EMBED_DEFINITIONS) {
if ((localEmbedDef as EmbedDefinition).hostnames.includes(host)) {
try {
newUrl = localEmbedDef.toEmbedUrl(url)
originalUrl = localEmbedDef.fromEmbedUrl(url)
} catch (err) {
console.warn(err)
}
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { tmpOldUrl, ...props } = shape.props
return {
...shape,
props: {
...props,
url: newUrl ?? '',
},
props.tmpOldUrl = props.url
props.url = originalUrl ?? ''
} catch (e) {
props.url = ''
props.tmpOldUrl = props.url
}
},
down: RETIRED_DOWN_MIGRATION,
},
[Versions.RemoveDoesResize]: {
up: (shape) => {
const { doesResize: _, ...props } = shape.props
return {
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
doesResize: true,
},
}
{
id: Versions.RemoveDoesResize,
up: (props) => {
delete props.doesResize
},
down: RETIRED_DOWN_MIGRATION,
},
[Versions.RemoveTmpOldUrl]: {
up: (shape) => {
const { tmpOldUrl: _, ...props } = shape.props
return {
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
},
}
{
id: Versions.RemoveTmpOldUrl,
up: (props) => {
delete props.tmpOldUrl
},
down: RETIRED_DOWN_MIGRATION,
},
[Versions.RemovePermissionOverrides]: {
up: (shape) => {
const { overridePermissions: _, ...props } = shape.props
return {
...shape,
props: {
...props,
},
}
},
down: (shape) => {
return {
...shape,
props: {
...shape.props,
},
}
{
id: Versions.RemovePermissionOverrides,
up: (props) => {
delete props.overridePermissions
},
down: RETIRED_DOWN_MIGRATION,
},
},
],
})

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

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