New migrations again (#3220)
Describe what your pull request does. If appropriate, add GIFs or images showing the before and after. ### Change Type - [x] `sdk` — Changes the tldraw SDK - [x] `galaxy brain` — Architectural changes ### Test Plan 1. Add a step-by-step description of how to test your PR here. 2. - [ ] Unit Tests - [ ] End to end tests ### Release Notes #### BREAKING CHANGES - The `Migrations` type is now called `LegacyMigrations`. - The serialized schema format (e.g. returned by `StoreSchema.serialize()` and `Store.getSnapshot()`) has changed. You don't need to do anything about it unless you were reading data directly from the schema for some reason. In which case it'd be best to avoid that in the future! We have no plans to change the schema format again (this time was traumatic enough) but you never know. - `compareRecordVersions` and the `RecordVersion` type have both disappeared. There is no replacement. These were public by mistake anyway, so hopefully nobody had been using it. - `compareSchemas` is a bit less useful now. Our migrations system has become a little fuzzy to allow for simpler UX when adding/removing custom extensions and 3rd party dependencies, and as a result we can no longer compare serialized schemas in any rigorous manner. You can rely on this function to return `0` if the schemas are the same. Otherwise it will return `-1` if the schema on the right _seems_ to be newer than the schema on the left, but it cannot guarantee that in situations where migration sequences have been removed over time (e.g. if you remove one of the builtin tldraw shapes). Generally speaking, the best way to check schema compatibility now is to call `store.schema.getMigrationsSince(persistedSchema)`. This will throw an error if there is no upgrade path from the `persistedSchema` to the current version. - `defineMigrations` has been deprecated and will be removed in a future release. For upgrade instructions see https://tldraw.dev/docs/persistence#Updating-legacy-shape-migrations-defineMigrations - `migrate` has been removed. Nobody should have been using this but if you were you'll need to find an alternative. For migrating tldraw data, you should stick to using `schema.migrateStoreSnapshot` and, if you are building a nuanced sync engine that supports some amount of backwards compatibility, also feel free to use `schema.migratePersistedRecord`. - the `Migration` type has changed. If you need the old one for some reason it has been renamed to `LegacyMigration`. It will be removed in a future release. - the `Migrations` type has been renamed to `LegacyMigrations` and will be removed in a future release. - the `SerializedSchema` type has been augmented. If you need the old version specifically you can use `SerializedSchemaV1` --------- Co-authored-by: Steve Ruiz <steveruizok@gmail.com>
This commit is contained in:
parent
63f20d1834
commit
4f70a4f4e8
112 changed files with 109320 additions and 106484 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -7,6 +7,8 @@ yarn-error.log*
|
|||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
.rooms
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-cjs
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
12
apps/examples/src/examples/meta-migrations/README.md
Normal file
12
apps/examples/src/examples/meta-migrations/README.md
Normal file
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
title: Meta Migrations
|
||||
component: ./MetaMigrations.tsx
|
||||
category: data/assets
|
||||
priority: 6
|
||||
---
|
||||
|
||||
Create custom migrations for `meta` properties.
|
||||
|
||||
---
|
||||
|
||||
You can add arbitrary data migrations for tldraw snapshot data. This is mainly useful for updating the `meta` property of a record as your data types evolve.
|
57
apps/examples/src/examples/meta-migrations/snapshot.ts
Normal file
57
apps/examples/src/examples/meta-migrations/snapshot.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { TLStoreSnapshot } from 'tldraw'
|
||||
|
||||
export const snapshot = {
|
||||
store: {
|
||||
'document:document': {
|
||||
gridSize: 10,
|
||||
name: '',
|
||||
meta: {},
|
||||
id: 'document:document',
|
||||
typeName: 'document',
|
||||
},
|
||||
'page:red': {
|
||||
meta: {
|
||||
backgroundTheme: 'red',
|
||||
},
|
||||
id: 'page:red',
|
||||
name: 'Red',
|
||||
index: 'a1',
|
||||
typeName: 'page',
|
||||
},
|
||||
'page:green': {
|
||||
meta: {
|
||||
backgroundTheme: 'green',
|
||||
},
|
||||
id: 'page:green',
|
||||
name: 'Green',
|
||||
index: 'a2',
|
||||
typeName: 'page',
|
||||
},
|
||||
'page:blue': {
|
||||
meta: {
|
||||
backgroundTheme: 'blue',
|
||||
},
|
||||
id: 'page:blue',
|
||||
name: 'Blue',
|
||||
index: 'a3',
|
||||
typeName: 'page',
|
||||
},
|
||||
'page:purple': {
|
||||
meta: {
|
||||
backgroundTheme: 'purple',
|
||||
},
|
||||
id: 'page:purple',
|
||||
name: 'Purple',
|
||||
index: 'a0',
|
||||
typeName: 'page',
|
||||
},
|
||||
},
|
||||
schema: {
|
||||
schemaVersion: 2,
|
||||
sequences: {
|
||||
'com.tldraw.store': 4,
|
||||
'com.tldraw.document': 2,
|
||||
'com.tldraw.page': 1,
|
||||
},
|
||||
},
|
||||
} as TLStoreSnapshot
|
41
apps/examples/src/examples/meta-migrations/ui-overrides.tsx
Normal file
41
apps/examples/src/examples/meta-migrations/ui-overrides.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { useLayoutEffect } from 'react'
|
||||
import { TLComponents, track, useEditor } from 'tldraw'
|
||||
import { PageMetaV2 } from './MetaMigrations'
|
||||
|
||||
export const components: TLComponents = {
|
||||
TopPanel: track(() => {
|
||||
const editor = useEditor()
|
||||
const currentPage = editor.getCurrentPage()
|
||||
const meta: PageMetaV2 = currentPage.meta
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const elem = document.querySelector('.tl-background') as HTMLElement
|
||||
if (!elem) return
|
||||
elem.style.backgroundColor = meta.backgroundTheme ?? 'unset'
|
||||
}, [meta.backgroundTheme])
|
||||
|
||||
return (
|
||||
<span style={{ pointerEvents: 'all', padding: '5px 15px', margin: 10, fontSize: 18 }}>
|
||||
bg:
|
||||
<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>
|
||||
)
|
||||
}),
|
||||
}
|
|
@ -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.
|
||||
|
||||
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
|
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -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__',
|
||||
|
|
|
@ -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",
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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>>()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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 })
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"metadata": {
|
||||
"toolPackage": "@microsoft/api-extractor",
|
||||
"toolVersion": "7.41.0",
|
||||
"toolVersion": "7.43.1",
|
||||
"schemaVersion": 1011,
|
||||
"oldestForwardsCompatibleVersion": 1001,
|
||||
"tsdocConfig": {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"metadata": {
|
||||
"toolPackage": "@microsoft/api-extractor",
|
||||
"toolVersion": "7.41.0",
|
||||
"toolVersion": "7.43.1",
|
||||
"schemaVersion": 1011,
|
||||
"oldestForwardsCompatibleVersion": 1001,
|
||||
"tsdocConfig": {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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)
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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'
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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])
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
})
|
75
packages/store/src/lib/test/createMigrations.test.ts
Normal file
75
packages/store/src/lib/test/createMigrations.test.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { createMigrationSequence } from '../migrate'
|
||||
|
||||
describe(createMigrationSequence, () => {
|
||||
it('allows dependsOn to be deferred', () => {
|
||||
expect(
|
||||
createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [{ dependsOn: ['bar/1'] }],
|
||||
}).sequence.length
|
||||
).toBe(0)
|
||||
|
||||
const result = createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{ dependsOn: ['bar/1'] },
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.sequence.length).toBe(1)
|
||||
expect(result.sequence[0].dependsOn?.length).toBeFalsy()
|
||||
|
||||
const result2 = createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{ dependsOn: ['bar/1'] },
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result2.sequence.length).toBe(1)
|
||||
expect(result2.sequence[0].dependsOn).toEqual(['bar/1'])
|
||||
|
||||
const result3 = createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{ dependsOn: ['bar/1'] },
|
||||
{
|
||||
id: 'foo/2',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
expect(result3.sequence.length).toBe(2)
|
||||
expect(result3.sequence[0].dependsOn?.length).toBeFalsy()
|
||||
expect(result3.sequence[1].dependsOn).toEqual(['bar/1'])
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
|
|
166
packages/store/src/lib/test/dependsOn.test.ts
Normal file
166
packages/store/src/lib/test/dependsOn.test.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import { StoreSchema } from '../StoreSchema'
|
||||
import { MigrationSequence, createMigrationSequence } from '../migrate'
|
||||
|
||||
describe('dependsOn', () => {
|
||||
it('requires the depended on ids to be present', () => {
|
||||
expect(() => {
|
||||
StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [
|
||||
{
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1',
|
||||
dependsOn: ['bar/1'],
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Migration 'foo/1' depends on missing migration 'bar/1'"`
|
||||
)
|
||||
})
|
||||
|
||||
it('makes sure the migrations are sorted', () => {
|
||||
const foo: MigrationSequence = {
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1',
|
||||
dependsOn: ['bar/1'],
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const bar: MigrationSequence = {
|
||||
sequenceId: 'bar',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'bar/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
const s = StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [foo, bar],
|
||||
}
|
||||
)
|
||||
const s2 = StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [bar, foo],
|
||||
}
|
||||
)
|
||||
|
||||
expect(s.sortedMigrations.map((s) => s.id)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"bar/1",
|
||||
"foo/1",
|
||||
]
|
||||
`)
|
||||
expect(s2.sortedMigrations).toEqual(s.sortedMigrations)
|
||||
})
|
||||
})
|
||||
|
||||
describe('standalone dependsOn', () => {
|
||||
it('requires the depended on ids to be present', () => {
|
||||
expect(() => {
|
||||
StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [
|
||||
createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
dependsOn: ['bar/1'],
|
||||
},
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
}
|
||||
)
|
||||
}).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Migration 'foo/1' depends on missing migration 'bar/1'"`
|
||||
)
|
||||
})
|
||||
|
||||
it('makes sure the migrations are sorted', () => {
|
||||
const foo: MigrationSequence = createMigrationSequence({
|
||||
sequenceId: 'foo',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
dependsOn: ['bar/1'],
|
||||
},
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const bar: MigrationSequence = createMigrationSequence({
|
||||
sequenceId: 'bar',
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'bar/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const s = StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [foo, bar],
|
||||
}
|
||||
)
|
||||
const s2 = StoreSchema.create(
|
||||
{},
|
||||
{
|
||||
migrations: [bar, foo],
|
||||
}
|
||||
)
|
||||
|
||||
expect(s.sortedMigrations.map((s) => s.id)).toMatchInlineSnapshot(`
|
||||
[
|
||||
"bar/1",
|
||||
"foo/1",
|
||||
]
|
||||
`)
|
||||
expect(s2.sortedMigrations).toEqual(s.sortedMigrations)
|
||||
})
|
||||
})
|
121
packages/store/src/lib/test/getMigrationsSince.test.ts
Normal file
121
packages/store/src/lib/test/getMigrationsSince.test.ts
Normal file
|
@ -0,0 +1,121 @@
|
|||
import { SerializedSchemaV2, StoreSchema } from '../StoreSchema'
|
||||
import { MigrationSequence } from '../migrate'
|
||||
|
||||
const mockSequence = ({
|
||||
id,
|
||||
retroactive,
|
||||
versions,
|
||||
}: {
|
||||
id: string
|
||||
retroactive: boolean
|
||||
versions: number
|
||||
}) =>
|
||||
({
|
||||
sequenceId: id,
|
||||
retroactive,
|
||||
sequence: new Array(versions).fill(0).map((_, i) => ({
|
||||
id: `${id}/${i + 1}`,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
})),
|
||||
}) satisfies MigrationSequence
|
||||
|
||||
function getMigrationsBetween(
|
||||
serialized: SerializedSchemaV2['sequences'],
|
||||
current: MigrationSequence[]
|
||||
) {
|
||||
const schema = StoreSchema.create({}, { migrations: current })
|
||||
const ms = schema.getMigrationsSince({ schemaVersion: 2, sequences: serialized })
|
||||
if (!ms.ok) {
|
||||
throw new Error('Expected migrations to be found')
|
||||
}
|
||||
return ms.value.map((m) => m.id)
|
||||
}
|
||||
|
||||
describe('getMigrationsSince', () => {
|
||||
it('includes migrations from new migration sequences with retroactive: true', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: true, versions: 3 })
|
||||
|
||||
const ids = getMigrationsBetween({}, [foo, bar])
|
||||
const foos = ids.filter((id) => id.startsWith('foo'))
|
||||
const bars = ids.filter((id) => id.startsWith('bar'))
|
||||
|
||||
expect(foos).toEqual(['foo/1', 'foo/2'])
|
||||
expect(bars).toEqual(['bar/1', 'bar/2', 'bar/3'])
|
||||
})
|
||||
|
||||
it('does not include migrations from new migration sequences with retroactive: false', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
|
||||
const ids = getMigrationsBetween({}, [foo, bar])
|
||||
const foos = ids.filter((id) => id.startsWith('foo'))
|
||||
const bars = ids.filter((id) => id.startsWith('bar'))
|
||||
|
||||
expect(foos).toEqual(['foo/1', 'foo/2'])
|
||||
expect(bars).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the empty array if there are no overlapping sequences and new ones are retroactive: false', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
|
||||
const ids = getMigrationsBetween({}, [foo, bar])
|
||||
expect(ids).toEqual([])
|
||||
})
|
||||
|
||||
it('if a sequence is present both before and now, unapplied migrations will be returned', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const ids = getMigrationsBetween({ foo: 1, bar: 1 }, [foo, bar])
|
||||
|
||||
const foos = ids.filter((id) => id.startsWith('foo'))
|
||||
const bars = ids.filter((id) => id.startsWith('bar'))
|
||||
|
||||
expect(foos).toEqual(['foo/2'])
|
||||
expect(bars).toEqual(['bar/2', 'bar/3'])
|
||||
})
|
||||
|
||||
it('if a sequence has not changed the empty array will be returned', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const ids = getMigrationsBetween({ foo: 2, bar: 3 }, [foo, bar])
|
||||
|
||||
expect(ids).toEqual([])
|
||||
})
|
||||
|
||||
it('if a sequence starts with 0 all unapplied migrations will be returned', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
|
||||
const ids = getMigrationsBetween(
|
||||
{
|
||||
foo: 0,
|
||||
bar: 0,
|
||||
},
|
||||
[foo, bar]
|
||||
)
|
||||
const foos = ids.filter((id) => id.startsWith('foo'))
|
||||
const bars = ids.filter((id) => id.startsWith('bar'))
|
||||
|
||||
expect(foos).toEqual(['foo/1', 'foo/2'])
|
||||
expect(bars).toEqual(['bar/1', 'bar/2', 'bar/3'])
|
||||
})
|
||||
|
||||
it('if a sequence starts with 0 and has 0 new migrations, no migrations will be returned', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 0 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 0 })
|
||||
|
||||
const ids = getMigrationsBetween(
|
||||
{
|
||||
foo: 0,
|
||||
bar: 0,
|
||||
},
|
||||
[foo, bar]
|
||||
)
|
||||
expect(ids).toEqual([])
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
|
|
265
packages/store/src/lib/test/migratePersistedRecord.test.ts
Normal file
265
packages/store/src/lib/test/migratePersistedRecord.test.ts
Normal file
|
@ -0,0 +1,265 @@
|
|||
import assert from 'assert'
|
||||
import { BaseRecord, RecordId } from '../BaseRecord'
|
||||
import { createRecordType } from '../RecordType'
|
||||
import { SerializedSchemaV2, StoreSchema } from '../StoreSchema'
|
||||
import { MigrationSequence } from '../migrate'
|
||||
|
||||
const mockSequence = ({
|
||||
id,
|
||||
retroactive,
|
||||
versions,
|
||||
filter,
|
||||
}: {
|
||||
id: string
|
||||
retroactive: boolean
|
||||
versions: number
|
||||
filter?: (r: TestRecordType) => boolean
|
||||
}): MigrationSequence => ({
|
||||
sequenceId: id,
|
||||
retroactive,
|
||||
sequence: new Array(versions).fill(0).map((_, i) => ({
|
||||
id: `${id}/${i + 1}`,
|
||||
scope: 'record',
|
||||
filter: filter as any,
|
||||
up(r) {
|
||||
const record = r as TestRecordType
|
||||
record.versions[id] ??= 0
|
||||
record.versions[id]++
|
||||
// noop
|
||||
},
|
||||
down(r) {
|
||||
const record = r as TestRecordType
|
||||
record.versions[id]--
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
interface TestRecordType extends BaseRecord<'test', RecordId<TestRecordType>> {
|
||||
versions: Record<string, number>
|
||||
}
|
||||
const TestRecordType = createRecordType<TestRecordType>('test', {
|
||||
scope: 'document',
|
||||
})
|
||||
|
||||
const makeSchema = (migrations: MigrationSequence[]) => {
|
||||
return StoreSchema.create({ test: TestRecordType }, { migrations })
|
||||
}
|
||||
|
||||
const makePersistedSchema = (...args: Array<[migrations: MigrationSequence, version: number]>) => {
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
sequences: Object.fromEntries(args.map(([m, v]) => [m.sequenceId, v])),
|
||||
} satisfies SerializedSchemaV2
|
||||
}
|
||||
|
||||
const makeTestRecord = (persistedSchema: SerializedSchemaV2) => {
|
||||
return TestRecordType.create({
|
||||
versions: Object.fromEntries(
|
||||
Object.keys(persistedSchema.sequences).map((id) => [id, persistedSchema.sequences[id]])
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
test('going up from 0', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
|
||||
|
||||
const r = makeTestRecord(persistedSchema)
|
||||
expect(r.versions).toEqual({ foo: 0, bar: 0 })
|
||||
const update = schema.migratePersistedRecord(r, persistedSchema)
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
|
||||
// the original record did not change
|
||||
expect(r.versions).toEqual({ foo: 0, bar: 0 })
|
||||
|
||||
// the updated record has the new versions
|
||||
expect((update.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
|
||||
})
|
||||
|
||||
test('going up with a retroactive: true and a retroactive: false', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema()
|
||||
|
||||
const r = makeTestRecord(persistedSchema)
|
||||
expect(r.versions).toEqual({})
|
||||
const update = schema.migratePersistedRecord(r, persistedSchema)
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
|
||||
// the original record did not change
|
||||
expect(r.versions).toEqual({})
|
||||
|
||||
// the updated record has the new versions
|
||||
expect((update.value as TestRecordType).versions).toEqual({ foo: 2 })
|
||||
})
|
||||
|
||||
test('going down to 0s', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
|
||||
|
||||
const r = makeTestRecord(schema.serialize())
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
|
||||
assert(downgrade.type === 'success', 'the downgrade should be successful')
|
||||
|
||||
// the original record did not change
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
|
||||
// the downgraded record has the new versions
|
||||
expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 0 })
|
||||
})
|
||||
|
||||
test('going down with a retroactive: true and a retroactive: false', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: true, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema()
|
||||
|
||||
const r = makeTestRecord(schema.serialize())
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
const downgrade = schema.migratePersistedRecord(r, persistedSchema, 'down')
|
||||
assert(downgrade.type === 'success', 'the downgrade should be successful')
|
||||
|
||||
// the original record did not change
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
|
||||
// only the foo migrations were undone
|
||||
expect((downgrade.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
|
||||
})
|
||||
|
||||
test('going up with no changes', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema([foo, 2], [bar, 3])
|
||||
|
||||
const r = makeTestRecord(persistedSchema)
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
const update = schema.migratePersistedRecord(r, persistedSchema)
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
|
||||
// the returned record should be the the input record, i.e. it should not have allocated a new record
|
||||
expect(r).toBe(update.value)
|
||||
})
|
||||
|
||||
test('going down with no changes', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 2 })
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema([foo, 2], [bar, 3])
|
||||
|
||||
const r = makeTestRecord(persistedSchema)
|
||||
expect(r.versions).toEqual({ foo: 2, bar: 3 })
|
||||
const update = schema.migratePersistedRecord(r, persistedSchema, 'down')
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
|
||||
// the returned record should be the the input record, i.e. it should not have allocated a new record
|
||||
expect(r).toBe(update.value)
|
||||
})
|
||||
|
||||
test('respects filters', () => {
|
||||
const foo = mockSequence({
|
||||
id: 'foo',
|
||||
retroactive: false,
|
||||
versions: 2,
|
||||
filter: (r) => (r as any).foo === true,
|
||||
})
|
||||
const bar = mockSequence({ id: 'bar', retroactive: false, versions: 3 })
|
||||
const schema = makeSchema([foo, bar])
|
||||
const persistedSchema = makePersistedSchema([foo, 0], [bar, 0])
|
||||
|
||||
const r = makeTestRecord(persistedSchema)
|
||||
const update = schema.migratePersistedRecord(r, persistedSchema, 'up')
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
|
||||
// foo migrations shouldn't have been applied
|
||||
expect((update.value as TestRecordType).versions).toEqual({ foo: 0, bar: 3 })
|
||||
|
||||
const r2 = { ...r, foo: true }
|
||||
const update2 = schema.migratePersistedRecord(r2, persistedSchema, 'up')
|
||||
assert(update2.type === 'success', 'the update should be successful')
|
||||
|
||||
// foo migrations should have been applied
|
||||
expect((update2.value as TestRecordType).versions).toEqual({ foo: 2, bar: 3 })
|
||||
})
|
||||
|
||||
test('does not go up or down if theres a store migration in the path', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
|
||||
foo.sequence[1] = {
|
||||
id: 'foo/2',
|
||||
scope: 'store',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
down() {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
const schema = makeSchema([foo])
|
||||
const v0Schema = makePersistedSchema([foo, 0])
|
||||
|
||||
const r0 = makeTestRecord(v0Schema)
|
||||
const r3 = makeTestRecord(schema.serialize())
|
||||
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
|
||||
expect(update.type).toBe('error')
|
||||
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
|
||||
expect(update2.type).toBe('error')
|
||||
|
||||
// snapshot migration up should still work
|
||||
const update3 = schema.migrateStoreSnapshot({
|
||||
schema: v0Schema,
|
||||
store: { [r0.id]: r0 },
|
||||
})
|
||||
|
||||
assert(update3.type === 'success', 'the update should be successful')
|
||||
expect((update3.value[r0.id] as TestRecordType).versions).toEqual({ foo: 2 })
|
||||
})
|
||||
|
||||
test('does not go down if theres a migrations without the down migrator in the path', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
|
||||
delete (foo.sequence[1] as any).down
|
||||
const schema = makeSchema([foo])
|
||||
const v0Schema = makePersistedSchema([foo, 0])
|
||||
|
||||
// going up still works
|
||||
const r0 = makeTestRecord(v0Schema)
|
||||
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
|
||||
expect(update.type).toBe('success')
|
||||
|
||||
// going down does not
|
||||
const r3 = makeTestRecord(schema.serialize())
|
||||
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
|
||||
expect(update2.type).toBe('error')
|
||||
})
|
||||
|
||||
test('allows returning a new record from the migrator fn', () => {
|
||||
const foo = mockSequence({ id: 'foo', retroactive: false, versions: 3 })
|
||||
foo.sequence[1] = {
|
||||
id: 'foo/2',
|
||||
scope: 'record',
|
||||
up(r) {
|
||||
const record = r as TestRecordType
|
||||
return { ...record, versions: { ...record.versions, foo: 2 } }
|
||||
},
|
||||
down(r) {
|
||||
const record = r as TestRecordType
|
||||
return { ...record, versions: { ...record.versions, foo: 1 } }
|
||||
},
|
||||
}
|
||||
const schema = makeSchema([foo])
|
||||
const v0Schema = makePersistedSchema([foo, 0])
|
||||
|
||||
const r0 = makeTestRecord(v0Schema)
|
||||
const r3 = makeTestRecord(schema.serialize())
|
||||
const update = schema.migratePersistedRecord(r0, v0Schema, 'up')
|
||||
assert(update.type === 'success', 'the update should be successful')
|
||||
expect((update.value as TestRecordType).versions).toEqual({ foo: 3 })
|
||||
const update2 = schema.migratePersistedRecord(r3, v0Schema, 'down')
|
||||
assert(update2.type === 'success', 'the update should be successful')
|
||||
expect((update2.value as TestRecordType).versions).toEqual({ foo: 0 })
|
||||
})
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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') {
|
||||
|
|
|
@ -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,
|
||||
|
|
51
packages/store/src/lib/test/sortMigrations.test.ts
Normal file
51
packages/store/src/lib/test/sortMigrations.test.ts
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { Migration, MigrationId, sortMigrations } from '../migrate'
|
||||
|
||||
describe(sortMigrations, () => {
|
||||
const m = (id: MigrationId, others?: { dependsOn?: MigrationId[] }): Migration => ({
|
||||
...others,
|
||||
id,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
})
|
||||
const sort = (migrations: Migration[]) => {
|
||||
return sortMigrations(migrations).map((m) => m.id)
|
||||
}
|
||||
it('should sort migrations based on version number', () => {
|
||||
expect(sort([m('foo/2'), m('foo/1')])).toEqual(['foo/1', 'foo/2'])
|
||||
expect(sort([m('foo/1'), m('foo/2')])).toEqual(['foo/1', 'foo/2'])
|
||||
})
|
||||
it('should sort multiple migration sequences based on version number', () => {
|
||||
const result = sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1')])
|
||||
expect(result.filter((id) => id.startsWith('foo/'))).toEqual(['foo/1', 'foo/2'])
|
||||
expect(result.filter((id) => id.startsWith('bar/'))).toEqual(['bar/1', 'bar/2'])
|
||||
})
|
||||
it('should use dependsOn to resolve inter-sequence dependencies', () => {
|
||||
expect(
|
||||
sort([m('foo/2'), m('bar/2'), m('foo/1'), m('bar/1', { dependsOn: ['foo/2'] })])
|
||||
).toEqual(['foo/1', 'foo/2', 'bar/1', 'bar/2'])
|
||||
|
||||
expect(
|
||||
sort([m('foo/2'), m('bar/2'), m('foo/1', { dependsOn: ['bar/2'] }), m('bar/1')])
|
||||
).toEqual(['bar/1', 'bar/2', 'foo/1', 'foo/2'])
|
||||
})
|
||||
|
||||
it('should fail if a cycle is created', () => {
|
||||
expect(() => {
|
||||
sort([m('foo/1', { dependsOn: ['foo/1'] })])
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
|
||||
|
||||
expect(() => {
|
||||
sort([m('foo/1', { dependsOn: ['foo/2'] }), m('foo/2')])
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
|
||||
|
||||
expect(() => {
|
||||
sort([m('foo/1', { dependsOn: ['bar/1'] }), m('bar/1', { dependsOn: ['foo/1'] })])
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: foo/1"`)
|
||||
|
||||
expect(() => {
|
||||
sort([m('bar/1', { dependsOn: ['foo/1'] }), m('foo/1', { dependsOn: ['bar/1'] })])
|
||||
}).toThrowErrorMatchingInlineSnapshot(`"Circular dependency in migrations: bar/1"`)
|
||||
})
|
||||
})
|
|
@ -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,
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
],
|
||||
}
|
||||
)
|
||||
|
|
79
packages/store/src/lib/test/upgradeSchema.test.ts
Normal file
79
packages/store/src/lib/test/upgradeSchema.test.ts
Normal file
|
@ -0,0 +1,79 @@
|
|||
import { SerializedSchemaV1, upgradeSchema } from '../StoreSchema'
|
||||
|
||||
describe('upgradeSchema', () => {
|
||||
it('should upgrade a schema from v1 to v2, assuming its working with tldraw data', () => {
|
||||
const v1: SerializedSchemaV1 = {
|
||||
schemaVersion: 1,
|
||||
storeVersion: 4,
|
||||
recordVersions: {
|
||||
asset: {
|
||||
version: 1,
|
||||
subTypeKey: 'type',
|
||||
subTypeVersions: { image: 2, video: 2, bookmark: 0 },
|
||||
},
|
||||
camera: { version: 1 },
|
||||
document: { version: 2 },
|
||||
instance: { version: 22 },
|
||||
instance_page_state: { version: 5 },
|
||||
page: { version: 1 },
|
||||
shape: {
|
||||
version: 3,
|
||||
subTypeKey: 'type',
|
||||
subTypeVersions: {
|
||||
group: 0,
|
||||
text: 1,
|
||||
bookmark: 1,
|
||||
draw: 1,
|
||||
geo: 7,
|
||||
note: 4,
|
||||
line: 1,
|
||||
frame: 0,
|
||||
arrow: 1,
|
||||
highlight: 0,
|
||||
embed: 4,
|
||||
image: 2,
|
||||
video: 1,
|
||||
},
|
||||
},
|
||||
instance_presence: { version: 5 },
|
||||
pointer: { version: 1 },
|
||||
},
|
||||
}
|
||||
|
||||
expect(upgradeSchema(v1)).toMatchInlineSnapshot(`
|
||||
{
|
||||
"ok": true,
|
||||
"value": {
|
||||
"schemaVersion": 2,
|
||||
"sequences": {
|
||||
"com.tldraw.asset": 1,
|
||||
"com.tldraw.asset.bookmark": 0,
|
||||
"com.tldraw.asset.image": 2,
|
||||
"com.tldraw.asset.video": 2,
|
||||
"com.tldraw.camera": 1,
|
||||
"com.tldraw.document": 2,
|
||||
"com.tldraw.instance": 22,
|
||||
"com.tldraw.instance_page_state": 5,
|
||||
"com.tldraw.instance_presence": 5,
|
||||
"com.tldraw.page": 1,
|
||||
"com.tldraw.pointer": 1,
|
||||
"com.tldraw.shape": 3,
|
||||
"com.tldraw.shape.arrow": 1,
|
||||
"com.tldraw.shape.bookmark": 1,
|
||||
"com.tldraw.shape.draw": 1,
|
||||
"com.tldraw.shape.embed": 4,
|
||||
"com.tldraw.shape.frame": 0,
|
||||
"com.tldraw.shape.geo": 7,
|
||||
"com.tldraw.shape.group": 0,
|
||||
"com.tldraw.shape.highlight": 0,
|
||||
"com.tldraw.shape.image": 2,
|
||||
"com.tldraw.shape.line": 1,
|
||||
"com.tldraw.shape.note": 4,
|
||||
"com.tldraw.shape.text": 1,
|
||||
"com.tldraw.shape.video": 1,
|
||||
},
|
||||
},
|
||||
}
|
||||
`)
|
||||
})
|
||||
})
|
|
@ -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>
|
||||
|
|
165
packages/store/src/lib/test/validateMigrations.test.ts
Normal file
165
packages/store/src/lib/test/validateMigrations.test.ts
Normal file
|
@ -0,0 +1,165 @@
|
|||
import { validateMigrations } from '../migrate'
|
||||
|
||||
describe(validateMigrations, () => {
|
||||
it('should throw if a migration id is invalid', () => {
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo.1' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.1'"`
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/one' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Invalid migration id: 'foo/one'"`)
|
||||
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'foo.2' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Every migration in sequence 'foo' must have an id starting with 'foo/'. Got invalid id: 'foo.2'"`
|
||||
)
|
||||
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'foo/two' as any,
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"Invalid migration id: 'foo/two'"`)
|
||||
})
|
||||
|
||||
it('should throw if the sequenceId is invalid', () => {
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [],
|
||||
sequenceId: 'foo/bar',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"sequenceId cannot contain a '/', got foo/bar"`)
|
||||
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [],
|
||||
sequenceId: '',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(`"sequenceId must be a non-empty string"`)
|
||||
})
|
||||
|
||||
it('should throw if the version numbers do not start at 1', () => {
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/2',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Expected the first migrationId to be 'foo/1' but got 'foo/2'"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw if the version numbers do not increase monotonically', () => {
|
||||
expect(() =>
|
||||
validateMigrations({
|
||||
retroactive: false,
|
||||
sequence: [
|
||||
{
|
||||
id: 'foo/1',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'foo/2',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'foo/4',
|
||||
scope: 'record',
|
||||
up() {
|
||||
// noop
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
sequenceId: 'foo',
|
||||
})
|
||||
).toThrowErrorMatchingInlineSnapshot(
|
||||
`"Migration id numbers must increase in increments of 1, expected foo/3 but got 'foo/4'"`
|
||||
)
|
||||
})
|
||||
})
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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",
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -32,6 +32,7 @@ export async function preloadFont(id: string, font: TLTypeFace) {
|
|||
featureSettings,
|
||||
stretch,
|
||||
unicodeRange,
|
||||
// @ts-expect-error why is this here
|
||||
variant,
|
||||
}
|
||||
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,9 @@
|
|||
"references": [
|
||||
{
|
||||
"path": "../editor"
|
||||
},
|
||||
{
|
||||
"path": "../store"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because one or more lines are too long
57
packages/tlschema/src/__tests__/migrationTestUtils.ts
Normal file
57
packages/tlschema/src/__tests__/migrationTestUtils.ts
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { Migration, MigrationId, Store, UnknownRecord } from '@tldraw/store'
|
||||
import { structuredClone } from '@tldraw/utils'
|
||||
import { createTLSchema } from '../createTLSchema'
|
||||
|
||||
export const testSchema = createTLSchema()
|
||||
|
||||
// mock all migrator fns
|
||||
for (const migration of testSchema.sortedMigrations) {
|
||||
;(migration as any).up = jest.fn(migration.up as any)
|
||||
if (typeof migration.down === 'function') {
|
||||
;(migration as any).down = jest.fn(migration.down as any)
|
||||
}
|
||||
}
|
||||
|
||||
function getEmptySnapshot() {
|
||||
const store = new Store({
|
||||
schema: testSchema,
|
||||
props: null as any,
|
||||
})
|
||||
store.ensureStoreIsUsable()
|
||||
return store.getSnapshot()
|
||||
}
|
||||
|
||||
export function snapshotUp(migrationId: MigrationId, ...records: UnknownRecord[]) {
|
||||
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
|
||||
if (!migration) {
|
||||
throw new Error(`Migration ${migrationId} not found`)
|
||||
}
|
||||
const snapshot = getEmptySnapshot()
|
||||
for (const record of records) {
|
||||
snapshot.store[record.id as any] = structuredClone(record as any)
|
||||
}
|
||||
|
||||
const result = migration.up(snapshot.store as any)
|
||||
return result ?? snapshot.store
|
||||
}
|
||||
|
||||
export function getTestMigration(migrationId: MigrationId) {
|
||||
const migration = testSchema.sortedMigrations.find((m) => m.id === migrationId) as Migration
|
||||
if (!migration) {
|
||||
throw new Error(`Migration ${migrationId} not found`)
|
||||
}
|
||||
return {
|
||||
id: migrationId,
|
||||
up: (stuff: any) => {
|
||||
const result = structuredClone(stuff)
|
||||
return migration.up(result) ?? result
|
||||
},
|
||||
down: (stuff: any) => {
|
||||
if (typeof migration.down !== 'function') {
|
||||
throw new Error(`Migration ${migrationId} does not have a down function`)
|
||||
}
|
||||
const result = structuredClone(stuff)
|
||||
return migration.down(result) ?? result
|
||||
},
|
||||
}
|
||||
}
|
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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 }>,
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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(() => ({
|
||||
|
|
|
@ -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'> => ({
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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: {},
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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'> => ({
|
||||
|
|
|
@ -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',
|
||||
}
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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: [],
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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: [] })
|
||||
|
|
|
@ -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: [] })
|
||||
|
|
|
@ -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
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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 }))
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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 }
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -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
Loading…
Reference in a new issue