UNPKG

y-localforage

Version:

a simple Yjs storage provider using localForage for persistence

389 lines (303 loc) 13.2 kB
import * as Y from 'yjs' import { Observable } from 'lib0/observable' // Store Key Pattern: [<subdoc-guid>]@<timestamp>-<n> //namespace LocalForageProvider { const GUIDPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}@/i type SubDocChanges = { added:Set<Y.Doc>, removed:Set<Y.Doc>, loaded:Set<Y.Doc> } export class LocalForageProvider extends Observable<any> { private _Store:any private _sharedDoc:Y.Doc private _SuperProvider?:LocalForageProvider private _isBusy:boolean = false private _UpdateLimit:number = 500 private _pendingUpdates:number = 0 private _completedUpdates:number = 0 private _enqueuedUpdates:Uint8Array[] = [] private _SubDocMap:Map<Y.Doc,LocalForageProvider> = new Map() constructor ( Store:any, sharedDoc:Y.Doc, UpdateLimit:number = 500, SuperProvider?:LocalForageProvider ) { super() this._Store = Store this._sharedDoc = sharedDoc this._SuperProvider = SuperProvider this._isBusy = false this._UpdateLimit = UpdateLimit this._storeUpdate = this._storeUpdate.bind(this) sharedDoc.on('update', this._storeUpdate) this._manageSubDocs = this._manageSubDocs.bind(this) sharedDoc.on('subdocs', this._manageSubDocs) this.destroy = this.destroy.bind(this) sharedDoc.on('destroy', this.destroy) this._applyStoredUpdates() // is safe, even while updated or destroyed } /**** isSynced - is true while this provider and its sharedDoc are in-sync ****/ get isSynced ():boolean { return (this._pendingUpdates === 0) } /**** isFullySynced - is true while this._sharedDoc and all subdocs are in-sync ****/ get isFullySynced ():boolean { return ( (this._pendingUpdates === 0) && Array.from(this._SubDocMap.values()).every( (SubProvider) => SubProvider.isSynced ) ) } /**** SubDocIsSynced - is true while the given SubDoc is in-sync ****/ public SubDocIsSynced (SubDoc:Y.Doc):boolean { const SubDocProvider = this._SubDocMap.get(SubDoc) return (SubDocProvider != null) && SubDocProvider.isSynced } /**** destroy - destroys persistence, invalidates provider ****/ async destroy ():Promise<void> { if (this._Store == null) { return } // provider has been destroyed this._sharedDoc.off('update', this._storeUpdate) this._sharedDoc.off('subdocs', this._manageSubDocs) this._sharedDoc.off('destroy', this.destroy) if (! this.isSynced) { this._pendingUpdates = 0 this.emit('sync-aborted',[this,1.0]) } const KeysToDelete = ( this._SuperProvider == null ? await this._StorageKeys() : await this._StorageSubKeysFor(this._sharedDoc) ) let Store = this._Store // @ts-ignore allow clearing of "this._Store" this._Store = undefined for (let i = 0, l = KeysToDelete.length; i < l; i++) { await Store.removeItem(KeysToDelete[i]) } } /**** _applyStoredUpdates - applies all stored (incremental) updates to sharedDoc ****/ private async _applyStoredUpdates ():Promise<void> { this._isBusy = true // prevents update entries from being persisted try { this._pendingUpdates = 1 // very bad trick to keep this.isSynced false const UpdateKeys = ( this._SuperProvider == null ? await this._StorageKeys() : await this._StorageSubKeysFor(this._sharedDoc) ) this._pendingUpdates-- // compensate trick from above if (UpdateKeys.length > 0) { this._pendingUpdates += UpdateKeys.length; this._reportProgress() for (let i = 0, l = UpdateKeys.length; i < l; i++) { if (this._Store == null) { return } // provider has been destroyed const Update = await this._Store.getItem(UpdateKeys[i]) Y.applyUpdate(this._sharedDoc, Update, this) // updates can be applied in any order this._completedUpdates++; this._reportProgress() } this._sharedDoc.emit('load',[this]) // resolves "whenLoaded" } else { this._reportProgress() } } catch (Signal:any) { this._breakdownWith( 'could not restore document from persistence', Signal ) } this._isBusy = false // allows update entries to be persisted if (this._enqueuedUpdates.length > 0) { this._storeUpdatesAndCompact() } } /**** _storeUpdate - stores a given (incremental) update ****/ private _storeUpdate (Update:Uint8Array, Origin?:any):void { if (this._Store == null) { return } // provider has been destroyed if (Origin !== this) { // ignore updates applied by this provider this._pendingUpdates++; this._reportProgress() this._enqueuedUpdates.push(Update) if (! this._isBusy) { this._storeUpdatesAndCompact() } // never write (and compact!) multiple updates concurrently } } /**** _storeUpdatesAndCompact - stores enqueued updates and compacts ****/ private async _storeUpdatesAndCompact ():Promise<void> { if (this._Store == null) { return } // provider has been destroyed this._isBusy = true const UpdateKeys = ( this._SuperProvider == null ? await this._StorageKeys() : await this._StorageSubKeysFor(this._sharedDoc) ) while ((this._Store != null) && (this._enqueuedUpdates.length > 0)) { try { await this._storeNextUpdateAmong(UpdateKeys) if (this._Store == null) { return } // provider has been destroyed } catch (Signal) { this._breakdownWith( 'could not persist document update', Signal ) } if (UpdateKeys.length >= this._UpdateLimit) { try { await this._compactUpdates(UpdateKeys) } catch (Signal) { this._breakdownWith( 'could not compact document updates', Signal ) } } } this._isBusy = false } /**** _storeNextUpdateAmong - stores next enqueued updates ****/ private async _storeNextUpdateAmong (UpdateKeys:string[]):Promise<void> { let UpdateKey:string = this._newUpdateKeyAmong(UpdateKeys) UpdateKeys.push(UpdateKey) await this._Store.setItem(UpdateKey,this._enqueuedUpdates[0]) this._enqueuedUpdates.shift() this._completedUpdates++; this._reportProgress() } /**** _compactUpdates - compacts the given list of updates ****/ private async _compactUpdates (UpdateKeys:string[]):Promise<void> { const thisHadEnqueuedUpdates = (this._enqueuedUpdates.length > 0) this._pendingUpdates -= this._enqueuedUpdates.length this._enqueuedUpdates = [] // all enqueued updates will be included let CompactKey:string = this._newUpdateKeyAmong(UpdateKeys) await this._Store.setItem(CompactKey,Y.encodeStateAsUpdate(this._sharedDoc)) if (this._Store == null) { return } // provider has been destroyed for (let i = 0, l = UpdateKeys.length; i < l; i++) { await this._Store.removeItem(UpdateKeys[i]) if (this._Store == null) { return } // provider has been destroyed } UpdateKeys.splice(0,UpdateKeys.length, CompactKey) if (thisHadEnqueuedUpdates) { this._reportProgress() } } /**** _newUpdateKeyAmong - generates a new unique update key ****/ private _newUpdateKeyAmong (UpdateKeys:string[]):string { let KeyBase:string = ( (this._SuperProvider == null ? '' : this._sharedDoc.guid) + '@' + Date.now() ), KeySuffix:number = 0 let UpdateKey:string = KeyBase + '-' + KeySuffix while (UpdateKeys.indexOf(UpdateKey) >= 0) { KeySuffix++ UpdateKey = KeyBase + '-' + KeySuffix } return UpdateKey } /**** _removeStoredSubDoc - removes a single stored subdoc ****/ private async _removeStoredSubDoc (SubDoc:Y.Doc):Promise<void> { let KeysToDelete = await this._StorageSubKeysFor(SubDoc) try { for (let i = 0, l = KeysToDelete.length; i < l; i++) { await this._Store.removeItem(KeysToDelete[i]) if (this._Store == null) { return } // provider has been destroyed } } catch (Signal) { this._breakdownWith( 'could not remove persistence for subdoc ' + SubDoc.guid, Signal ) } } /**** _breakdown - breaks down this provider ****/ private _breakdown ():void { // @ts-ignore allow clearing of "this._Store" this._Store = undefined this._isBusy = false if (! this.isSynced) { this._enqueuedUpdates = [] this._pendingUpdates = 0 this.emit('sync-aborted',[this,1.0]) } this._SubDocMap.forEach((Provider) => Provider._breakdown()) } /**** _breakdownWith - breaks down this provider after failure ****/ private _breakdownWith (Message:string, Reason?:any):never { this._breakdown() throw new Error( Message + (Reason == null ? '' : ', reason: ' + Reason) ) } /**** _manageSubDocs - manages subdoc persistences ****/ private async _manageSubDocs (Changes:SubDocChanges):Promise<void> { const providePersistenceFor = (SubDoc:Y.Doc) => { if ( ! this._SubDocMap.has(SubDoc) && (this._sharedDoc.guid !== SubDoc.guid) // "doc copies" are strange ) { const SubDocProvider = new LocalForageProvider( this._Store, SubDoc, this._UpdateLimit, this ) this._SubDocMap.set(SubDoc,SubDocProvider) } } const { added, removed, loaded } = Changes if (removed != null) { let SubDocList:Y.Doc[] = Array.from(removed.values()) for (let i = 0, l = SubDocList.length; i < l; i++) { const SubDoc = SubDocList[i] const Provider = this._SubDocMap.get(SubDoc) if (Provider != null) { Provider._breakdown() } this._SubDocMap.delete(SubDoc) if ( (this._sharedDoc != null) && // "doc copies" are strange... (this._sharedDoc.guid !== SubDoc.guid) && Array.from(this._sharedDoc.getSubdocs().values()).every( (existingSubDoc) => (existingSubDoc.guid !== SubDoc.guid) ) // ...really ) { await this._removeStoredSubDoc(SubDoc) } // warning: pot. race condition if "guid" is immediately used again } } if (loaded != null) { loaded.forEach((SubDoc:Y.Doc) => { providePersistenceFor(SubDoc) }) } } /**** _reportProgress - emits events reporting synchronization progress ****/ private _reportProgress ():void { switch (true) { case (this._pendingUpdates === 0): this._completedUpdates = 0 this.emit('synced',[this]) this._sharedDoc.emit('sync',[this]) // resolves "whenSynced", once if (this._SuperProvider != null) { this._SuperProvider.emit('subdoc-synced',[this,this._sharedDoc]) } break case (this._completedUpdates === 0) && (this._pendingUpdates === 1): this.emit('sync-started',[this,0.0]) break case (this._completedUpdates === this._pendingUpdates): this.emit('sync-finished',[this,1.0]) this._pendingUpdates = this._completedUpdates = 0 this.emit('synced',[this]) this._sharedDoc.emit('sync',[this]) // resolves "whenSynced", once if (this._SuperProvider != null) { this._SuperProvider.emit('subdoc-synced',[this,this._sharedDoc]) } break default: const Progress = this._completedUpdates/this._pendingUpdates this.emit('sync-continued',[this,Progress]) } } /**** _StorageKeys - lists all keys used for sharedDoc itself ****/ private async _StorageKeys ():Promise<string[]> { let StoreKeys:string[] = await this._Store.keys() return StoreKeys.filter((Key) => Key.startsWith('@')) } /**** _StorageSubKeys - lists all keys used for subdocs of sharedDoc ****/ private async _StorageSubKeys ():Promise<string[]> { let StoreKeys:string[] = await this._Store.keys() return StoreKeys.filter((Key) => ! Key.startsWith('@')) } /**** _StorageSubKeysFor - lists all keys used for a given subdoc ****/ private async _StorageSubKeysFor (SubDoc:Y.Doc):Promise<string[]> { const KeyPrefix = SubDoc.guid + '@' let StoreKeys:string[] = await this._Store.keys() return StoreKeys.filter((Key) => Key.startsWith(KeyPrefix)) } } //}