[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`
This commit is contained in:
parent
e3cf05f408
commit
0dc0587bea
8 changed files with 313 additions and 2 deletions
|
@ -38,6 +38,7 @@
|
|||
"@playwright/test": "^1.34.3",
|
||||
"@tldraw/assets": "workspace:*",
|
||||
"@tldraw/tldraw": "workspace:*",
|
||||
"@tldraw/utils": "workspace:*",
|
||||
"@vercel/analytics": "^1.0.1",
|
||||
"lazyrepo": "0.0.0-alpha.26",
|
||||
"react": "^18.2.0",
|
||||
|
|
93
apps/examples/src/14-persistence/PersistenceExample.tsx
Normal file
93
apps/examples/src/14-persistence/PersistenceExample.tsx
Normal file
|
@ -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 UiEventsExample from './12-ui-events/UiEventsExample'
|
||||
import StoreEventsExample from './13-store/StoreEventsExample'
|
||||
import PersistenceExample from './14-persistence/PersistenceExample'
|
||||
import ExampleApi from './2-api/APIExample'
|
||||
import CustomConfigExample from './3-custom-config/CustomConfigExample'
|
||||
import CustomUiExample from './4-custom-ui/CustomUiExample'
|
||||
|
@ -86,6 +87,10 @@ export const allExamples: Example[] = [
|
|||
path: '/user-presence',
|
||||
element: <UserPresenceExample />,
|
||||
},
|
||||
{
|
||||
path: '/persistence',
|
||||
element: <PersistenceExample />,
|
||||
},
|
||||
{
|
||||
path: '/e2e',
|
||||
element: <ForEndToEndTests />,
|
||||
|
|
|
@ -5,5 +5,9 @@
|
|||
"compilerOptions": {
|
||||
"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)
|
||||
_flushHistory(): void;
|
||||
get: <K extends IdOf<R>>(id: K) => RecFromId<K> | undefined;
|
||||
getSnapshot(): {
|
||||
store: StoreSnapshot<R>;
|
||||
schema: SerializedSchema;
|
||||
};
|
||||
has: <K extends IdOf<R>>(id: K) => boolean;
|
||||
readonly history: Atom<number, RecordsDiff<R>>;
|
||||
// @internal (undocumented)
|
||||
isPossiblyCorrupted(): boolean;
|
||||
listen: (listener: StoreListener<R>) => () => void;
|
||||
loadSnapshot(snapshot: {
|
||||
store: StoreSnapshot<R>;
|
||||
schema: SerializedSchema;
|
||||
}): void;
|
||||
// @internal (undocumented)
|
||||
markAsPossiblyCorrupted(): void;
|
||||
mergeRemoteChanges: (fn: () => void) => void;
|
||||
|
|
|
@ -10,7 +10,7 @@ import { ID, IdOf, UnknownRecord } from './BaseRecord'
|
|||
import { Cache } from './Cache'
|
||||
import { RecordType } from './RecordType'
|
||||
import { StoreQueries } from './StoreQueries'
|
||||
import { StoreSchema } from './StoreSchema'
|
||||
import { SerializedSchema, StoreSchema } from './StoreSchema'
|
||||
import { devFreeze } from './devFreeze'
|
||||
|
||||
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.
|
||||
*
|
||||
|
|
|
@ -564,3 +564,163 @@ describe('Store', () => {
|
|||
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
|
||||
"@tldraw/assets": "workspace:*"
|
||||
"@tldraw/tldraw": "workspace:*"
|
||||
"@tldraw/utils": "workspace:*"
|
||||
"@vercel/analytics": ^1.0.1
|
||||
dotenv: ^16.0.3
|
||||
lazyrepo: 0.0.0-alpha.26
|
||||
|
|
Loading…
Reference in a new issue