UNPKG

y-localstorage

Version:

a simple Yjs storage provider persisting in localStorage (for educational purposes)

340 lines (273 loc) 11.1 kB
import * as Y from 'yjs' import { Observable } from 'lib0/observable' //namespace LocalStorageProvider { 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 LocalStorageProvider extends Observable<any> { private _DocPrefix:string private _sharedDoc:Y.Doc private _UpdateCounter:number = 1 // index "0" is reserved for compaction private _CounterLimit:number = 5 private _pendingUpdates:number = 0 private _completedUpdates:number = 0 private _SubDocMap:Map<Y.Doc,LocalStorageProvider> = new Map() constructor (DocName:string, sharedDoc:Y.Doc, CounterLimit:number = 5) { super() this._DocPrefix = DocName + '-' this._sharedDoc = sharedDoc this._UpdateCounter = 0 // will be updated by "_applyStoredUpdates" this._CounterLimit = CounterLimit try { this._applyStoredUpdates() // also updates "this._UpdateCounter" } catch (Signal:any) { this._breakdownWith( 'could not restore document from persistence', Signal ) } 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) } /**** isSynced - is true while this provider and its sharedDoc are in-sync ****/ public get isSynced ():boolean { return (this._pendingUpdates === 0) } /**** destroy - destroys persistence, invalidates provider ****/ public destroy ():void { if (this._sharedDoc == null) { return } // persistence no longer exists this._removeStoredUpdatesStartingWith(0) this._removeStoredSubDocs() 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]) } // @ts-ignore allow clearing of "this._sharedDoc" this._sharedDoc = undefined } /**** destroyPersistenceNamed ****/ public static destroyPersistenceNamed (DocName:string):void { let KeyList:string[] = [] for (let i = 0, l = localStorage.length; i < l; i++) { const Key = localStorage.key(i) as string if ( Key.startsWith(DocName + '-') || Key.startsWith(DocName + '.') ) { KeyList.push(Key) } } KeyList.forEach((Key) => { localStorage.removeItem(Key) }) } /**** _applyStoredUpdates - applies all stored (incremental) updates to sharedDoc ****/ private _applyStoredUpdates ():void { const PrefixLength = this._DocPrefix.length const StorageKeys = this._StorageKeys() if (StorageKeys.length > 0) { this._pendingUpdates += StorageKeys.length; this._reportProgress() StorageKeys.forEach((Key) => { let UpdateIndex = parseInt(Key.slice(PrefixLength),10) if (UpdateIndex > this._UpdateCounter) { this._UpdateCounter = UpdateIndex } const Update = new Uint8Array(JSON.parse(localStorage.getItem(Key) as string)) Y.applyUpdate(this._sharedDoc, Update, this) // updates can be applied in any order this._completedUpdates++; this._reportProgress() }) } else { this._UpdateCounter = 1 // keep "0" for compacted updates this._reportProgress() } } /**** _storeUpdate - stores a given (incremental) update ****/ private _storeUpdate (Update:Uint8Array, Origin?:any):void { if (this._sharedDoc == null) { return } // persistence no longer exists if (Origin !== this) { // ignore updates applied by this provider this._pendingUpdates++; this._reportProgress() try { if (this._UpdateCounter < this._CounterLimit) { // append update localStorage.setItem( // may fail this._DocPrefix + this._UpdateCounter, JSON.stringify(Array.from(Update)) ) this._UpdateCounter++ } else { // compact previous and current updates localStorage.setItem( // may fail this._DocPrefix + 0, JSON.stringify(Array.from(Y.encodeStateAsUpdate(this._sharedDoc))) ) this._removeStoredUpdatesStartingWith(1) } } catch (Signal:any) { this._breakdownWith( 'could not persist document update', Signal ) } this._completedUpdates++; this._reportProgress() } } /**** _removeStoredUpdatesStartingWith - removes stored (incremental) updates ****/ private _removeStoredUpdatesStartingWith (minimalIndex:number):void { const PrefixLength = this._DocPrefix.length let lastFailure:any = undefined this._StorageKeys().forEach((Key) => { let UpdateIndex = parseInt(Key.slice(PrefixLength),10) if (UpdateIndex >= minimalIndex) { try { localStorage.removeItem(Key) // may fail } catch (Signal:any) { lastFailure = Signal } } }) if (lastFailure != null) { console.warn( 'y-localstorage: could not clean-up localstorage, reason: ' + lastFailure ) } this._UpdateCounter = minimalIndex } /**** _removeStoredSubDocs - removes any stored subdocs ****/ private _removeStoredSubDocs ():void { this._removeStoredSubDoc() // avoids duplicating code } /**** _removeStoredSubDoc - removes a single stored subdoc ****/ private _removeStoredSubDoc (SubDoc?:Y.Doc):void { let lastFailure:any = undefined const StorageKeys = ( SubDoc == null ? this._StorageSubKeys() : this._StorageSubKeysFor(SubDoc) ) StorageKeys.forEach((Key) => { try { localStorage.removeItem(Key) // may fail } catch (Signal:any) { lastFailure = Signal } }) if (lastFailure != null) { console.warn( 'y-localstorage: could not clean-up localstorage, reason: ' + lastFailure ) } } /**** _breakdown - breaks down this provider ****/ private _breakdown ():void { // @ts-ignore allow clearing of "this._sharedDoc" this._sharedDoc = undefined if (! this.isSynced) { 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 _manageSubDocs (Changes:SubDocChanges):void { const providePersistenceFor = (SubDoc:Y.Doc) => { if ( ! this._SubDocMap.has(SubDoc) && (this._sharedDoc.guid !== SubDoc.guid) // "doc copies" are strange ) { const SubDocProvider = new LocalStorageProvider( this._DocPrefix.slice(0,-1) + '.' + SubDoc.guid, SubDoc, this._CounterLimit ) this._SubDocMap.set(SubDoc,SubDocProvider) } } const { added, removed, loaded } = Changes if (removed != null) { removed.forEach((SubDoc:Y.Doc) => { 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 ) { this._removeStoredSubDoc(SubDoc) } }) } 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]) break case (this._completedUpdates === 0): 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]) break default: const Progress = this._completedUpdates/this._pendingUpdates this.emit('sync-continued',[this,Progress]) } } /**** _StorageKeys - lists all keys used for sharedDoc itself ****/ private _StorageKeys ():string[] { return this._StorageSubKeysFor() // avoids duplicating code } /**** _StorageSubKeys - lists all keys used for subdocs of sharedDoc ****/ private _StorageSubKeys ():string[] { const DocPrefix = this._DocPrefix.slice(0,-1) + '.' const PrefixLength = DocPrefix.length const Result:string[] = [] for (let i = 0, l = localStorage.length; i < l; i++) { const Key = localStorage.key(i) as string if ( Key.startsWith(DocPrefix) && (GUIDPattern.test(Key.slice(PrefixLength)) === true) ) { Result.push(Key) } } return Result } /**** _StorageSubKeysFor - lists all keys used for a given subdoc ****/ private _StorageSubKeysFor (SubDoc?:Y.Doc):string[] { const DocPrefix = ( SubDoc == null ? this._DocPrefix : this._DocPrefix.slice(0,-1) + '.' + SubDoc.guid + '-' ) const PrefixLength = DocPrefix.length const Result:string[] = [] for (let i = 0, l = localStorage.length; i < l; i++) { const Key = localStorage.key(i) as string if ( Key.startsWith(DocPrefix) && (/^\d+$/.test(Key.slice(PrefixLength)) === true) ) { Result.push(Key) } } return Result } } //}