[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:
Steve Ruiz 2023-05-30 16:22:49 +01:00 committed by GitHub
parent e3cf05f408
commit 0dc0587bea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 313 additions and 2 deletions

View file

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

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

View file

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

View file

@ -5,5 +5,9 @@
"compilerOptions": {
"outDir": "./.tsbuild"
},
"references": [{ "path": "../../packages/tldraw" }, { "path": "../../packages/assets" }]
"references": [
{ "path": "../../packages/tldraw" },
{ "path": "../../packages/utils" },
{ "path": "../../packages/assets" }
]
}

View file

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

View file

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

View file

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

View file

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