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