Tldraw/packages/editor/src/lib/utils/sync/TLLocalSyncClient.ts

355 wiersze
10 KiB
TypeScript
Czysty Zwykły widok Historia

import { RecordsDiff, SerializedSchema, compareSchemas, squashRecordDiffs } from '@tldraw/store'
[refactor] User-facing APIs (#1478) This PR updates our user-facing APIs for the Tldraw and TldrawEditor components, as well as the Editor (App). It mainly incorporates surface changes from #1450 without any changes to validators or migrators, incorporating feedback / discussion with @SomeHats and @ds300. Here we: - remove the TldrawEditorConfig - bring back a loose version of shape definitions - make a separation between "core" shapes and "default" shapes - do not allow custom shapes, migrators or validators to overwrite core shapes - but _do_ allow new shapes ## `<Tldraw>` component In this PR, the `Tldraw` component wraps both the `TldrawEditor` component and our `TldrawUi` component. It accepts a union of props for both components. Previously, this component also added local syncing via a `useLocalSyncClient` hook call, however that has been pushed down to the `TldrawEditor` component. ## `<TldrawEditor>` component The `TldrawEditor` component now more neatly wraps up the different ways that the editor can be configured. ## The store prop (`TldrawEditorProps.store`) There are three main ways for the `TldrawEditor` component to be run: 1. with an externally defined store 2. with an externally defined syncing store (local or remote) 3. with an internally defined store 4. with an internally defined locally syncing store The `store` prop allows for these configurations. If the `store` prop is defined, it may be defined either as a `TLStore` or as a `SyncedStore`. If the store is a `TLStore`, then the Editor will assume that the store is ready to go; if it is defined as a SyncedStore, then the component will display the loading / error screens as needed, or the final editor once the store's status is "synced". When the store is left undefined, then the `TldrawEditor` will create its own internal store using the optional `instanceId`, `initialData`, or `shapes` props to define the store / store schema. If the `persistenceKey` prop is left undefined, then the store will not be synced. If the `persistenceKey` is defined, then the store will be synced locally. In the future, we may also here accept the API key / roomId / etc for creating a remotely synced store. The `SyncedStore` type has been expanded to also include types used for remote syncing, e.g. with `ConnectionStatus`. ## Tools By default, the App has two "baked-in" tools: the select tool and the zoom tool. These cannot (for now) be replaced or removed. The default tools are used by default, but may be replaced by other tools if provided. ## Shapes By default, the App has a set of "core" shapes: - group - embed - bookmark - image - video - text That cannot by overwritten because they're created by the app at different moments, such as when double clicking on the canvas or via a copy and paste event. In follow up PRs, we'll split these out so that users can replace parts of the code where these shapes are created. ### Change Type - [x] `major` — Breaking Change ### Test Plan - [x] Unit Tests
2023-06-01 15:47:34 +00:00
import { TLInstanceId, TLRecord, TLStore } from '@tldraw/tlschema'
2023-04-25 11:01:25 +00:00
import { assert, hasOwnProperty } from '@tldraw/utils'
import { transact } from 'signia'
import { showCantReadFromIndexDbAlert, showCantWriteToIndexDbAlert } from './alerts'
import { loadDataFromStore, storeChangesInIndexedDb, storeSnapshotInIndexedDb } from './indexedDb'
/** How should we debounce persists? */
const PERSIST_THROTTLE_MS = 350
/** If we're in an error state, how long should we wait before retrying a write? */
const PERSIST_RETRY_THROTTLE_MS = 10_000
/**
* IMPORTANT!!!
*
* This is just a quick-n-dirty temporary solution that will be replaced with the remote sync client
* once it has the db integrated
*/
type SyncMessage = {
type: 'diff'
instanceId: TLInstanceId
changes: RecordsDiff<any>
schema: SerializedSchema
}
// Sent by new clients when they connect
// If another client is on the channel with a newer schema version
// It will
type AnnounceMessage = {
type: 'announce'
schema: SerializedSchema
}
type Message = SyncMessage | AnnounceMessage
const msg = (msg: Message) => msg
/** @internal */
2023-04-25 11:01:25 +00:00
export class BroadcastChannelMock {
onmessage?: (e: MessageEvent) => void
constructor(_name: string) {
// noop
}
postMessage(_msg: Message) {
// noop
}
close() {
// noop
}
}
const BC = typeof BroadcastChannel === 'undefined' ? BroadcastChannelMock : BroadcastChannel
/** @internal */
2023-04-25 11:01:25 +00:00
export class TLLocalSyncClient {
private disposables = new Set<() => void>()
private diffQueue: RecordsDiff<any>[] = []
private didDispose = false
private shouldDoFullDBWrite = true
private isReloading = false
readonly universalPersistenceKey: string
readonly serializedSchema: SerializedSchema
private isDebugging = false
initTime = Date.now()
private debug(...args: any[]) {
if (this.isDebugging) {
// eslint-disable-next-line no-console
console.debug(...args)
}
}
constructor(
public readonly store: TLStore,
{
universalPersistenceKey,
onLoad,
onLoadError,
}: {
universalPersistenceKey: string
onLoad: (self: TLLocalSyncClient) => void
onLoadError: (error: Error) => void
},
public readonly channel = new BC(`tldraw-tab-sync-${universalPersistenceKey}`)
) {
if (typeof window !== 'undefined') {
;(window as any).tlsync = this
}
this.universalPersistenceKey = universalPersistenceKey
this.serializedSchema = this.store.schema.serialize()
this.disposables.add(
// Set up a subscription to changes from the store: When
// the store changes (and if the change was made by the user)
// then immediately send the diff to other tabs via postMessage
// and schedule a persist.
store.listen(({ changes, source }) => {
this.debug('changes', changes, source)
if (source === 'user') {
this.diffQueue.push(changes)
this.channel.postMessage(
msg({
type: 'diff',
instanceId: this.store.props.instanceId,
changes,
schema: this.serializedSchema,
})
)
this.schedulePersist()
}
})
)
this.connect(onLoad, onLoadError)
}
private async connect(onLoad: (client: this) => void, onLoadError: (error: Error) => void) {
this.debug('connecting')
let data:
| {
records: TLRecord[]
schema?: SerializedSchema
}
| undefined
try {
data = await loadDataFromStore(this.universalPersistenceKey)
} catch (error: any) {
onLoadError(error)
showCantReadFromIndexDbAlert()
if (typeof window !== 'undefined') {
window.location.reload()
}
return
}
this.debug('loaded data from store', data, 'didDispose', this.didDispose)
if (this.didDispose) return
try {
if (data) {
const snapshot = Object.fromEntries(data.records.map((r) => [r.id, r]))
const migrationResult = this.store.schema.migrateStoreSnapshot(
snapshot,
data.schema ?? this.store.schema.serializeEarliestVersion()
)
if (migrationResult.type === 'error') {
console.error('failed to migrate store', migrationResult)
onLoadError(new Error(`Failed to migrate store: ${migrationResult.reason}`))
return
}
// 3. Merge the changes into the REAL STORE
this.store.mergeRemoteChanges(() => {
// Calling put will validate the records!
this.store.put(
Object.values(migrationResult.value).filter(
(r) => this.store.schema.types[r.typeName].scope !== 'presence'
),
'initialize'
)
2023-04-25 11:01:25 +00:00
})
}
this.channel.onmessage = ({ data }) => {
this.debug('got message', data)
const msg = data as Message
// if their schema is eralier than ours, we need to tell them so they can refresh
// if their schema is later than ours, we need to refresh
const comparison = compareSchemas(
this.serializedSchema,
msg.schema ?? this.store.schema.serializeEarliestVersion()
)
if (comparison === -1) {
// we are older, refresh
// but add a safety check to make sure we don't get in an infnite loop
const timeSinceInit = Date.now() - this.initTime
if (timeSinceInit < 5000) {
// This tab was just reloaded, but is out of date compared to other tabs.
// Not expecting this to ever happen. It should only happen if we roll back a release that incremented
// the schema version (which we should never do)
// Or maybe during development if you have multiple local tabs open running the app on prod mode and you
// check out an older commit. Dev server should be fine.
onLoadError(new Error('Schema mismatch, please close other tabs and reload the page'))
return
}
this.debug('reloading')
this.isReloading = true
window?.location?.reload?.()
return
} else if (comparison === 1) {
// they are older, tell them to refresh and not write any more data
this.debug('telling them to reload')
this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
// schedule a full db write in case they wrote data anyway
this.shouldDoFullDBWrite = true
this.persistIfNeeded()
return
}
// otherwise, all good, same version :)
if (msg.type === 'diff') {
this.debug('applying diff')
const doesDeleteInstance = hasOwnProperty(
msg.changes.removed,
this.store.props.instanceId
)
transact(() => {
this.store.mergeRemoteChanges(() => {
this.store.applyDiff(msg.changes)
})
if (doesDeleteInstance) {
this.store.ensureStoreIsUsable()
}
})
}
}
this.channel.postMessage({ type: 'announce', schema: this.serializedSchema })
this.disposables.add(() => {
this.channel.close()
})
onLoad(this)
} catch (e: any) {
this.debug('error loading data from store', e)
if (this.didDispose) return
onLoadError(e)
return
}
}
close() {
this.debug('closing')
this.didDispose = true
this.disposables.forEach((d) => d())
}
private isPersisting = false
private didLastWriteError = false
private scheduledPersistTimeout: ReturnType<typeof setTimeout> | null = null
/**
* Schedule a persist. Persists don't happen immediately: they are throttled to avoid writing too
* often, and will retry if failed.
*
* @internal
*/
private schedulePersist() {
this.debug('schedulePersist', this.scheduledPersistTimeout)
if (this.scheduledPersistTimeout) return
this.scheduledPersistTimeout = setTimeout(
() => {
this.scheduledPersistTimeout = null
this.persistIfNeeded()
},
this.didLastWriteError ? PERSIST_RETRY_THROTTLE_MS : PERSIST_THROTTLE_MS
)
}
/**
* Persist to indexeddb only under certain circumstances:
*
* - If we're not already persisting
* - If we're not reloading the page
* - And we have something to persist (a full db write scheduled or changes in the diff queue)
*
* @internal
*/
private persistIfNeeded() {
this.debug('persistIfNeeded', {
isPersisting: this.isPersisting,
isReloading: this.isReloading,
shouldDoFullDBWrite: this.shouldDoFullDBWrite,
diffQueueLength: this.diffQueue.length,
storeIsPossiblyCorrupt: this.store.isPossiblyCorrupted(),
})
// if we've scheduled a persist for the future, that's no longer needed
if (this.scheduledPersistTimeout) {
clearTimeout(this.scheduledPersistTimeout)
this.scheduledPersistTimeout = null
}
// if a persist is already in progress, we don't need to do anything -
// if there are still outstanding changes once it's finished, it'll
// schedule another persist
if (this.isPersisting) return
// if we're reloading the page, it's because there's a newer client
// present so lets not overwrite their changes
if (this.isReloading) return
// if the store is possibly corrupted, we don't want to persist
if (this.store.isPossiblyCorrupted()) return
// if we're scheduled for a full write or if we have changes outstanding, let's persist them!
if (this.shouldDoFullDBWrite || this.diffQueue.length > 0) {
this.doPersist()
}
}
/**
* Actually persist to indexeddb. If the write fails, then we'll retry with a full db write after
* a short delay.
*/
private async doPersist() {
assert(!this.isPersisting, 'persist already in progress')
this.isPersisting = true
this.debug('doPersist start')
// instantly empty the diff queue, but keep our own copy of it. this way
// diffs that come in during the persist will still get tracked
const diffQueue = this.diffQueue
this.diffQueue = []
try {
if (this.shouldDoFullDBWrite) {
this.shouldDoFullDBWrite = false
await storeSnapshotInIndexedDb(
this.universalPersistenceKey,
this.store.schema,
this.store.serialize(),
{
didCancel: () => this.didDispose,
}
)
} else {
const diffs = squashRecordDiffs(diffQueue)
await storeChangesInIndexedDb(this.universalPersistenceKey, this.store.schema, diffs)
}
this.didLastWriteError = false
} catch (e) {
// set this.shouldDoFullDBWrite because we clear the diffQueue no matter what,
// so if this is just a temporary error, we will still persist all changes
this.shouldDoFullDBWrite = true
this.didLastWriteError = true
console.error('failed to store changes in indexed db', e)
showCantWriteToIndexDbAlert()
if (typeof window !== 'undefined') {
// adios
window.location.reload()
}
}
this.isPersisting = false
this.debug('doPersist end')
// changes might have come in between when we started the persist and
// now. we request another persist so any new changes can get written
this.schedulePersist()
}
}