kopia lustrzana https://github.com/Tldraw/Tldraw
[feature] Easier store persistence API + persistence example (#1480)
This PR adds `getSnapshot` and `loadSnapshot` to the `Store`, sanding down a rough corner that existed when persisting and loading data. Avoids learning about stores vs schemas vs migrations until a little later. ### Change Type - [x] `minor` — New Feature ### Test Plan - [x] Unit Tests ### Release Notes - [tlstore] adds `getSnapshot` and `loadSnapshot`double-click-for-text
rodzic
e3cf05f408
commit
0dc0587bea
|
@ -38,6 +38,7 @@
|
||||||
"@playwright/test": "^1.34.3",
|
"@playwright/test": "^1.34.3",
|
||||||
"@tldraw/assets": "workspace:*",
|
"@tldraw/assets": "workspace:*",
|
||||||
"@tldraw/tldraw": "workspace:*",
|
"@tldraw/tldraw": "workspace:*",
|
||||||
|
"@tldraw/utils": "workspace:*",
|
||||||
"@vercel/analytics": "^1.0.1",
|
"@vercel/analytics": "^1.0.1",
|
||||||
"lazyrepo": "0.0.0-alpha.26",
|
"lazyrepo": "0.0.0-alpha.26",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|
|
@ -0,0 +1,93 @@
|
||||||
|
import {
|
||||||
|
Canvas,
|
||||||
|
ContextMenu,
|
||||||
|
TAB_ID,
|
||||||
|
TldrawEditor,
|
||||||
|
TldrawEditorConfig,
|
||||||
|
TldrawUi,
|
||||||
|
} from '@tldraw/tldraw'
|
||||||
|
import '@tldraw/tldraw/editor.css'
|
||||||
|
import '@tldraw/tldraw/ui.css'
|
||||||
|
import { throttle } from '@tldraw/utils'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
const PERSISTENCE_KEY = 'example-3'
|
||||||
|
const config = new TldrawEditorConfig()
|
||||||
|
const instanceId = TAB_ID
|
||||||
|
const store = config.createStore({ instanceId })
|
||||||
|
|
||||||
|
export default function PersistenceExample() {
|
||||||
|
const [state, setState] = useState<
|
||||||
|
| {
|
||||||
|
name: 'loading'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'ready'
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
name: 'error'
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
>({ name: 'loading', error: undefined })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState({ name: 'loading' })
|
||||||
|
|
||||||
|
// Get persisted data from local storage
|
||||||
|
const persistedSnapshot = localStorage.getItem(PERSISTENCE_KEY)
|
||||||
|
|
||||||
|
if (persistedSnapshot) {
|
||||||
|
try {
|
||||||
|
const snapshot = JSON.parse(persistedSnapshot)
|
||||||
|
store.loadSnapshot(snapshot)
|
||||||
|
setState({ name: 'ready' })
|
||||||
|
} catch (e: any) {
|
||||||
|
setState({ name: 'error', error: e.message }) // Something went wrong
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setState({ name: 'ready' }) // Nothing persisted, continue with the empty store
|
||||||
|
}
|
||||||
|
|
||||||
|
const persist = throttle(() => {
|
||||||
|
// Each time the store changes, persist the store snapshot
|
||||||
|
const snapshot = store.getSnapshot()
|
||||||
|
localStorage.setItem(PERSISTENCE_KEY, JSON.stringify(snapshot))
|
||||||
|
}, 1000)
|
||||||
|
|
||||||
|
// Each time the store changes, run the (debounced) persist function
|
||||||
|
const cleanupFn = store.listen(persist)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupFn()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (state.name === 'loading') {
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<h2>Loading...</h2>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.name === 'error') {
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<h2>Error!</h2>
|
||||||
|
<p>{state.error}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="tldraw__editor">
|
||||||
|
<TldrawEditor instanceId={instanceId} store={store} config={config} autoFocus>
|
||||||
|
<TldrawUi>
|
||||||
|
<ContextMenu>
|
||||||
|
<Canvas />
|
||||||
|
</ContextMenu>
|
||||||
|
</TldrawUi>
|
||||||
|
</TldrawEditor>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ import CustomComponentsExample from './10-custom-components/CustomComponentsExam
|
||||||
import UserPresenceExample from './11-user-presence/UserPresenceExample'
|
import UserPresenceExample from './11-user-presence/UserPresenceExample'
|
||||||
import UiEventsExample from './12-ui-events/UiEventsExample'
|
import UiEventsExample from './12-ui-events/UiEventsExample'
|
||||||
import StoreEventsExample from './13-store/StoreEventsExample'
|
import StoreEventsExample from './13-store/StoreEventsExample'
|
||||||
|
import PersistenceExample from './14-persistence/PersistenceExample'
|
||||||
import ExampleApi from './2-api/APIExample'
|
import ExampleApi from './2-api/APIExample'
|
||||||
import CustomConfigExample from './3-custom-config/CustomConfigExample'
|
import CustomConfigExample from './3-custom-config/CustomConfigExample'
|
||||||
import CustomUiExample from './4-custom-ui/CustomUiExample'
|
import CustomUiExample from './4-custom-ui/CustomUiExample'
|
||||||
|
@ -86,6 +87,10 @@ export const allExamples: Example[] = [
|
||||||
path: '/user-presence',
|
path: '/user-presence',
|
||||||
element: <UserPresenceExample />,
|
element: <UserPresenceExample />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/persistence',
|
||||||
|
element: <PersistenceExample />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/e2e',
|
path: '/e2e',
|
||||||
element: <ForEndToEndTests />,
|
element: <ForEndToEndTests />,
|
||||||
|
|
|
@ -5,5 +5,9 @@
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./.tsbuild"
|
"outDir": "./.tsbuild"
|
||||||
},
|
},
|
||||||
"references": [{ "path": "../../packages/tldraw" }, { "path": "../../packages/assets" }]
|
"references": [
|
||||||
|
{ "path": "../../packages/tldraw" },
|
||||||
|
{ "path": "../../packages/utils" },
|
||||||
|
{ "path": "../../packages/assets" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -236,11 +236,19 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
// (undocumented)
|
// (undocumented)
|
||||||
_flushHistory(): void;
|
_flushHistory(): void;
|
||||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||||
|
getSnapshot(): {
|
||||||
|
store: StoreSnapshot<R>;
|
||||||
|
schema: SerializedSchema;
|
||||||
|
};
|
||||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||||
readonly history: Atom<number, RecordsDiff<R>>;
|
readonly history: Atom<number, RecordsDiff<R>>;
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
isPossiblyCorrupted(): boolean;
|
isPossiblyCorrupted(): boolean;
|
||||||
listen: (listener: StoreListener<R>) => () => void;
|
listen: (listener: StoreListener<R>) => () => void;
|
||||||
|
loadSnapshot(snapshot: {
|
||||||
|
store: StoreSnapshot<R>;
|
||||||
|
schema: SerializedSchema;
|
||||||
|
}): void;
|
||||||
// @internal (undocumented)
|
// @internal (undocumented)
|
||||||
markAsPossiblyCorrupted(): void;
|
markAsPossiblyCorrupted(): void;
|
||||||
mergeRemoteChanges: (fn: () => void) => void;
|
mergeRemoteChanges: (fn: () => void) => void;
|
||||||
|
|
|
@ -10,7 +10,7 @@ import { ID, IdOf, UnknownRecord } from './BaseRecord'
|
||||||
import { Cache } from './Cache'
|
import { Cache } from './Cache'
|
||||||
import { RecordType } from './RecordType'
|
import { RecordType } from './RecordType'
|
||||||
import { StoreQueries } from './StoreQueries'
|
import { StoreQueries } from './StoreQueries'
|
||||||
import { StoreSchema } from './StoreSchema'
|
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
||||||
import { devFreeze } from './devFreeze'
|
import { devFreeze } from './devFreeze'
|
||||||
|
|
||||||
type RecFromId<K extends ID<UnknownRecord>> = K extends ID<infer R> ? R : never
|
type RecFromId<K extends ID<UnknownRecord>> = K extends ID<infer R> ? R : never
|
||||||
|
@ -461,6 +461,45 @@ export class Store<R extends UnknownRecord = UnknownRecord, Props = unknown> {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a serialized snapshot of the store and its schema.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const snapshot = store.getSnapshot()
|
||||||
|
* store.loadSnapshot(snapshot)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
getSnapshot() {
|
||||||
|
return {
|
||||||
|
store: this.serializeDocumentState(),
|
||||||
|
schema: this.schema.serialize(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a serialized snapshot.
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const snapshot = store.getSnapshot()
|
||||||
|
* store.loadSnapshot(snapshot)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param snapshot - The snapshot to load.
|
||||||
|
*
|
||||||
|
* @public
|
||||||
|
*/
|
||||||
|
loadSnapshot(snapshot: { store: StoreSnapshot<R>; schema: SerializedSchema }): void {
|
||||||
|
const migrationResult = this.schema.migrateStoreSnapshot(snapshot.store, snapshot.schema)
|
||||||
|
|
||||||
|
if (migrationResult.type === 'error') {
|
||||||
|
throw new Error(`Failed to migrate snapshot: ${migrationResult.reason}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.deserialize(migrationResult.value)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an array of all values in the store.
|
* Get an array of all values in the store.
|
||||||
*
|
*
|
||||||
|
|
|
@ -564,3 +564,163 @@ describe('Store', () => {
|
||||||
expect(listener.mock.calls[2][0].source).toBe('user')
|
expect(listener.mock.calls[2][0].source).toBe('user')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('snapshots', () => {
|
||||||
|
let store: Store<Book | Author>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<Book | Author>(
|
||||||
|
{
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshotMigrations: {
|
||||||
|
currentVersion: 0,
|
||||||
|
firstVersion: 0,
|
||||||
|
migrators: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
transact(() => {
|
||||||
|
store.put([
|
||||||
|
Author.create({ name: 'J.R.R Tolkein', id: Author.createCustomId('tolkein') }),
|
||||||
|
Author.create({ name: 'James McAvoy', id: Author.createCustomId('mcavoy') }),
|
||||||
|
Author.create({ name: 'Butch Cassidy', id: Author.createCustomId('cassidy') }),
|
||||||
|
Book.create({
|
||||||
|
title: 'The Hobbit',
|
||||||
|
id: Book.createCustomId('hobbit'),
|
||||||
|
author: Author.createCustomId('tolkein'),
|
||||||
|
numPages: 300,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
store.put([
|
||||||
|
Book.create({
|
||||||
|
title: 'The Lord of the Rings',
|
||||||
|
id: Book.createCustomId('lotr'),
|
||||||
|
author: Author.createCustomId('tolkein'),
|
||||||
|
numPages: 1000,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates and loads a snapshot', () => {
|
||||||
|
const serializedStore1 = store.serialize()
|
||||||
|
const serializedSchema1 = store.schema.serialize()
|
||||||
|
|
||||||
|
const snapshot1 = store.getSnapshot()
|
||||||
|
|
||||||
|
const store2 = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<Book | Author>(
|
||||||
|
{
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshotMigrations: {
|
||||||
|
currentVersion: 0,
|
||||||
|
firstVersion: 0,
|
||||||
|
migrators: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
store2.loadSnapshot(snapshot1)
|
||||||
|
|
||||||
|
const serializedStore2 = store2.serialize()
|
||||||
|
const serializedSchema2 = store2.schema.serialize()
|
||||||
|
const snapshot2 = store2.getSnapshot()
|
||||||
|
|
||||||
|
expect(serializedStore1).toEqual(serializedStore2)
|
||||||
|
expect(serializedSchema1).toEqual(serializedSchema2)
|
||||||
|
expect(snapshot1).toEqual(snapshot2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws errors when loading a snapshot with a different schema', () => {
|
||||||
|
const snapshot1 = store.getSnapshot()
|
||||||
|
|
||||||
|
const store2 = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<Book>(
|
||||||
|
{
|
||||||
|
book: Book,
|
||||||
|
// no author
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshotMigrations: {
|
||||||
|
currentVersion: 0,
|
||||||
|
firstVersion: 0,
|
||||||
|
migrators: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
// @ts-expect-error
|
||||||
|
store2.loadSnapshot(snapshot1)
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(`"Failed to migrate snapshot: unknown-type"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('throws errors when loading a snapshot with a different schema', () => {
|
||||||
|
const snapshot1 = store.getSnapshot()
|
||||||
|
|
||||||
|
const store2 = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<Book | Author>(
|
||||||
|
{
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshotMigrations: {
|
||||||
|
currentVersion: -1,
|
||||||
|
firstVersion: 0,
|
||||||
|
migrators: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
store2.loadSnapshot(snapshot1)
|
||||||
|
}).toThrowErrorMatchingInlineSnapshot(`"Failed to migrate snapshot: target-version-too-old"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('migrates the snapshot', () => {
|
||||||
|
const snapshot1 = store.getSnapshot()
|
||||||
|
|
||||||
|
const store2 = new Store({
|
||||||
|
props: {},
|
||||||
|
schema: StoreSchema.create<Book | Author>(
|
||||||
|
{
|
||||||
|
book: Book,
|
||||||
|
author: Author,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
snapshotMigrations: {
|
||||||
|
currentVersion: 1,
|
||||||
|
firstVersion: 0,
|
||||||
|
migrators: {
|
||||||
|
1: {
|
||||||
|
up: (r) => r,
|
||||||
|
down: (r) => r,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
store2.loadSnapshot(snapshot1)
|
||||||
|
}).not.toThrowError()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
|
@ -9502,6 +9502,7 @@ __metadata:
|
||||||
"@playwright/test": ^1.34.3
|
"@playwright/test": ^1.34.3
|
||||||
"@tldraw/assets": "workspace:*"
|
"@tldraw/assets": "workspace:*"
|
||||||
"@tldraw/tldraw": "workspace:*"
|
"@tldraw/tldraw": "workspace:*"
|
||||||
|
"@tldraw/utils": "workspace:*"
|
||||||
"@vercel/analytics": ^1.0.1
|
"@vercel/analytics": ^1.0.1
|
||||||
dotenv: ^16.0.3
|
dotenv: ^16.0.3
|
||||||
lazyrepo: 0.0.0-alpha.26
|
lazyrepo: 0.0.0-alpha.26
|
||||||
|
|
Ładowanie…
Reference in New Issue