From 0dc0587bea6e9a2f73dcf9f1a5cf4bd3e8a41afa Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Tue, 30 May 2023 16:22:49 +0100 Subject: [PATCH] [feature] Easier store persistence API + persistence example (#1480) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` --- apps/examples/package.json | 1 + .../src/14-persistence/PersistenceExample.tsx | 93 ++++++++++ apps/examples/src/index.tsx | 5 + apps/examples/tsconfig.json | 6 +- packages/tlstore/api-report.md | 8 + packages/tlstore/src/lib/Store.ts | 41 ++++- .../tlstore/src/lib/test/recordStore.test.ts | 160 ++++++++++++++++++ public-yarn.lock | 1 + 8 files changed, 313 insertions(+), 2 deletions(-) create mode 100644 apps/examples/src/14-persistence/PersistenceExample.tsx diff --git a/apps/examples/package.json b/apps/examples/package.json index bacee77b1..52380bd87 100644 --- a/apps/examples/package.json +++ b/apps/examples/package.json @@ -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", diff --git a/apps/examples/src/14-persistence/PersistenceExample.tsx b/apps/examples/src/14-persistence/PersistenceExample.tsx new file mode 100644 index 000000000..d1e8108c8 --- /dev/null +++ b/apps/examples/src/14-persistence/PersistenceExample.tsx @@ -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 ( +
+

Loading...

+
+ ) + } + + if (state.name === 'error') { + return ( +
+

Error!

+

{state.error}

+
+ ) + } + + return ( +
+ + + + + + + +
+ ) +} diff --git a/apps/examples/src/index.tsx b/apps/examples/src/index.tsx index 1b150f662..2a8577d2c 100644 --- a/apps/examples/src/index.tsx +++ b/apps/examples/src/index.tsx @@ -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: , }, + { + path: '/persistence', + element: , + }, { path: '/e2e', element: , diff --git a/apps/examples/tsconfig.json b/apps/examples/tsconfig.json index 1d6833a6e..6862ecba9 100644 --- a/apps/examples/tsconfig.json +++ b/apps/examples/tsconfig.json @@ -5,5 +5,9 @@ "compilerOptions": { "outDir": "./.tsbuild" }, - "references": [{ "path": "../../packages/tldraw" }, { "path": "../../packages/assets" }] + "references": [ + { "path": "../../packages/tldraw" }, + { "path": "../../packages/utils" }, + { "path": "../../packages/assets" } + ] } diff --git a/packages/tlstore/api-report.md b/packages/tlstore/api-report.md index 9b25b3535..ca8b0b564 100644 --- a/packages/tlstore/api-report.md +++ b/packages/tlstore/api-report.md @@ -236,11 +236,19 @@ export class Store { // (undocumented) _flushHistory(): void; get: >(id: K) => RecFromId | undefined; + getSnapshot(): { + store: StoreSnapshot; + schema: SerializedSchema; + }; has: >(id: K) => boolean; readonly history: Atom>; // @internal (undocumented) isPossiblyCorrupted(): boolean; listen: (listener: StoreListener) => () => void; + loadSnapshot(snapshot: { + store: StoreSnapshot; + schema: SerializedSchema; + }): void; // @internal (undocumented) markAsPossiblyCorrupted(): void; mergeRemoteChanges: (fn: () => void) => void; diff --git a/packages/tlstore/src/lib/Store.ts b/packages/tlstore/src/lib/Store.ts index 14979307c..86f847f96 100644 --- a/packages/tlstore/src/lib/Store.ts +++ b/packages/tlstore/src/lib/Store.ts @@ -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 ? R : never @@ -461,6 +461,45 @@ export class Store { }) } + /** + * 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; 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. * diff --git a/packages/tlstore/src/lib/test/recordStore.test.ts b/packages/tlstore/src/lib/test/recordStore.test.ts index c2d06d7a0..8a8319760 100644 --- a/packages/tlstore/src/lib/test/recordStore.test.ts +++ b/packages/tlstore/src/lib/test/recordStore.test.ts @@ -564,3 +564,163 @@ describe('Store', () => { expect(listener.mock.calls[2][0].source).toBe('user') }) }) + +describe('snapshots', () => { + let store: Store + + beforeEach(() => { + store = new Store({ + props: {}, + schema: StoreSchema.create( + { + 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: 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, + // 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: 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: Book, + author: Author, + }, + { + snapshotMigrations: { + currentVersion: 1, + firstVersion: 0, + migrators: { + 1: { + up: (r) => r, + down: (r) => r, + }, + }, + }, + } + ), + }) + + expect(() => { + store2.loadSnapshot(snapshot1) + }).not.toThrowError() + }) +}) diff --git a/public-yarn.lock b/public-yarn.lock index a401cb718..deb82afee 100644 --- a/public-yarn.lock +++ b/public-yarn.lock @@ -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