import { Observable } from "rxjs"; export type DataStatus = "syncing" | "synced" | "offline"; export function mergeDataStatus(statusList: DataStatus[]): DataStatus { if (statusList.includes("offline")) { return "offline"; } else if (statusList.includes("syncing")) { return "syncing"; } else { return "synced"; } } export type Subscriber = (data: TData) => void; export interface DataAndStatus { data: TData | null; status: DataStatus; } export class DataLine2 { constructor( private config: { saveData: (data: TData) => Promise; getSavedData: () => Promise; // return null for offline fetchData: (savedData: TData | null) => Promise; } ) {} private _current: DataAndStatus | null = null; private _observers: Subscriber>[] = []; private _syncPromise: Promise | null = null; get currentData(): DataAndStatus | null { return this._current; } get isDestroyable(): boolean { const { _observers, currentData } = this; return ( _observers.length === 0 && (currentData == null || currentData.status !== "syncing") ); } private next(data: DataAndStatus): void { this._current = data; this._observers.forEach((o) => o(data)); } subscribe(subsriber: Subscriber>): void { void this.sync(); // TODO: Should I sync at this point or let the user sync explicitly. this._observers.push(subsriber); const { currentData } = this; if (currentData != null) { subsriber(currentData); } } unsubscribe(subsriber: Subscriber>): void { const index = this._observers.indexOf(subsriber); if (index > -1) this._observers.splice(index, 1); } getObservalble(): Observable> { return new Observable>((observer) => { const f = (data: DataAndStatus): void => { observer.next(data); }; this.subscribe(f); return () => { this.unsubscribe(f); }; }); } private syncWithAction(action: () => Promise): Promise { if (this._syncPromise != null) return this._syncPromise; this._syncPromise = action().then(() => { this._syncPromise = null; }); return this._syncPromise; } sync(): Promise { return this.syncWithAction(this.doSync.bind(this)); } private async doSync(): Promise { const { currentData } = this; this.next({ data: currentData?.data ?? null, status: "syncing" }); const savedData = await this.config.getSavedData(); if (currentData == null && savedData != null) { this.next({ data: savedData, status: "syncing" }); } const data = await this.config.fetchData(savedData); if (data == null) { this.next({ data: savedData, status: "offline", }); } else { await this.config.saveData(data); this.next({ data: data, status: "synced" }); } } save(data: TData): Promise { return this.syncWithAction(this.doSave.bind(this, data)); } private async doSave(data: TData): Promise { await this.config.saveData(data); this.next({ data: data, status: "synced" }); } getSavedData(): Promise { return this.config.getSavedData(); } } export class DataHub2 { private readonly subscriptionLineMap = new Map>(); private keyToString: (key: TKey) => string; private cleanTimerId = 0; // setup is called after creating line and if it returns a function as destroyer, then when the line is destroyed the destroyer will be called. constructor( private config: { saveData: (key: TKey, data: TData) => Promise; getSavedData: (key: TKey) => Promise; fetchData: (key: TKey, savedData: TData | null) => Promise; keyToString?: (key: TKey) => string; } ) { this.keyToString = config.keyToString ?? ((value): string => { if (typeof value === "string") return value; else throw new Error( "Default keyToString function only pass string value." ); }); } private cleanLines(): void { const toDelete: string[] = []; for (const [key, line] of this.subscriptionLineMap.entries()) { if (line.isDestroyable) { toDelete.push(key); } } if (toDelete.length === 0) return; for (const key of toDelete) { this.subscriptionLineMap.delete(key); } if (this.subscriptionLineMap.size === 0) { window.clearInterval(this.cleanTimerId); this.cleanTimerId = 0; } } private createLine(key: TKey): DataLine2 { const keyString = this.keyToString(key); const newLine: DataLine2 = new DataLine2({ saveData: (data) => this.config.saveData(key, data), getSavedData: () => this.config.getSavedData(key), fetchData: (savedData) => this.config.fetchData(key, savedData), }); this.subscriptionLineMap.set(keyString, newLine); if (this.subscriptionLineMap.size === 1) { this.cleanTimerId = window.setInterval(this.cleanLines.bind(this), 20000); } return newLine; } getLine(key: TKey): DataLine2 { const keyString = this.keyToString(key); return this.subscriptionLineMap.get(keyString) ?? this.createLine(key); } }