import type { EventHook } from "../../web/hooks/event.ts"; import type { SpacePrimitives } from "./space_primitives.ts"; import type { FileMeta } from "../../type/index.ts"; import type { DataStore } from "../data/datastore.ts"; import { notFoundError } from "../constants.ts"; /** * Events exposed: * - file:changed (string, oldHash, newHash) * - file:deleted (string) * - file:listed (FileMeta[]) * - file:initial: triggered in case of an initially empty snapshot, after the first set of events has gone out * - page:deleted (string) */ export class EventedSpacePrimitives implements SpacePrimitives { // Various operations may be going on at the same time, and we don't want to trigger events unnessarily. // Therefore, we use this variable to track if any operation is in flight, and if so, we skip event triggering. // This is ok, because any event will be picked up in a following iteration. operationInProgress = false; private enabled = false; constructor( private wrapped: SpacePrimitives, private eventHook: EventHook, private ds: DataStore, private snapshotKey = ["$spaceSnapshot"], ) { } async enable() { console.log("Loading snapshot and enabling events"); this.spaceSnapshot = (await this.ds.get(this.snapshotKey)) || {}; this.snapshotChanged = false; const isFreshSnapshot = Object.keys(this.spaceSnapshot).length === 0; this.enabled = true; // trigger loading and eventing this.fetchFileList().then(async () => { if (isFreshSnapshot) { // Trigger event to signal that an intial batch of events has been triggered await this.dispatchEvent("file:initial"); } }); } // Snapshot state management private spaceSnapshot: Record = {}; private snapshotChanged = false; private updateInSnapshot(key: string, value: number) { const oldValue = this.spaceSnapshot[key]; this.spaceSnapshot[key] = value; this.snapshotChanged = this.snapshotChanged || oldValue !== value; } private deleteFromSnapshot(key: string) { delete this.spaceSnapshot[key]; this.snapshotChanged = true; } private async saveSnapshot() { if (this.enabled && this.snapshotChanged) { console.log("Saving snapshot"); await this.ds.set(this.snapshotKey, this.spaceSnapshot); this.snapshotChanged = false; } } dispatchEvent(name: string, ...args: any[]): Promise { if (!this.enabled) { return Promise.resolve([]); } // console.log("Evented space, dispatching", name, args); return this.eventHook.dispatchEvent(name, ...args); } async fetchFileList(): Promise { if (this.operationInProgress) { // Some other operation (read, write, list, meta) is already going on // this will likely trigger events, so let's not worry about any of that and avoid race condition and inconsistent data. console.info( "alreadyFetching is on, skipping even triggering for fetchFileList.", ); return this.wrapped.fetchFileList(); } if (!this.enabled) { return this.wrapped.fetchFileList(); } // console.log("Fetching file list"); // Fetching mutex this.operationInProgress = true; try { // Fetch the list const newFileList = await this.wrapped.fetchFileList(); // Now we have the list, let's compare it to the snapshot and trigger events appropriately const deletedFiles = new Set(Object.keys(this.spaceSnapshot)); for (const meta of newFileList) { const oldHash = this.spaceSnapshot[meta.name]; const newHash = meta.lastModified; // Update in snapshot this.updateInSnapshot(meta.name, newHash); // Check what happened to the file if ( ( // New file scenario !oldHash ) || ( // Changed file scenario oldHash && oldHash !== newHash ) ) { console.log( "Detected file change during listing", meta.name, oldHash, newHash, ); await this.dispatchEvent( "file:changed", meta.name, oldHash, newHash, ); } // Page found, not deleted deletedFiles.delete(meta.name); } for (const deletedFile of deletedFiles) { this.deleteFromSnapshot(deletedFile); await this.dispatchEvent("file:deleted", deletedFile); if (deletedFile.endsWith(".md")) { const pageName = deletedFile.substring(0, deletedFile.length - 3); await this.dispatchEvent("page:deleted", pageName); } } await this.dispatchEvent("file:listed", newFileList); return newFileList; } finally { await this.saveSnapshot(); this.operationInProgress = false; } } async readFile( path: string, ): Promise<{ data: Uint8Array; meta: FileMeta }> { if (!this.enabled) { return this.wrapped.readFile(path); } try { // Fetching mutex const wasFetching = this.operationInProgress; this.operationInProgress = true; // Fetch file const data = await this.wrapped.readFile(path); if (!wasFetching) { await this.triggerEventsAndCache(path, data.meta.lastModified); } return data; } finally { this.operationInProgress = false; } } async writeFile( path: string, data: Uint8Array, meta?: FileMeta, ): Promise { if (!this.enabled) { return this.wrapped.writeFile(path, data, meta); } const wasFetching = this.operationInProgress; this.operationInProgress = true; try { const newMeta = await this.wrapped.writeFile( path, data, meta, ); if (!wasFetching) { await this.triggerEventsAndCache(path, newMeta.lastModified); } return newMeta; } finally { this.operationInProgress = false; } } /** * @param name * @param newHash * @return whether something changed in the snapshot */ async triggerEventsAndCache(name: string, newHash: number) { const oldHash = this.spaceSnapshot[name]; // if (oldHash && newHash && oldHash !== newHash) { if (oldHash !== newHash) { // Page changed since last cached metadata, trigger event this.dispatchEvent("file:changed", name, oldHash, newHash); } this.updateInSnapshot(name, newHash); await this.saveSnapshot(); } async getFileMeta(path: string, observing?: boolean): Promise { if (!this.enabled) { return this.wrapped.getFileMeta(path, observing); } try { const wasFetching = this.operationInProgress; this.operationInProgress = true; const newMeta = await this.wrapped.getFileMeta(path, observing); if (!wasFetching) { await this.triggerEventsAndCache(path, newMeta.lastModified); } return newMeta; } catch (e: any) { // console.log("Checking error", e, path); if (e.message === notFoundError.message) { await this.dispatchEvent("file:deleted", path); if (path.endsWith(".md")) { const pageName = path.substring(0, path.length - 3); await this.dispatchEvent("page:deleted", pageName); } } throw e; } finally { this.operationInProgress = false; } } async deleteFile(path: string): Promise { if (!this.enabled) { return this.wrapped.deleteFile(path); } try { this.operationInProgress = true; if (path.endsWith(".md")) { const pageName = path.substring(0, path.length - 3); await this.dispatchEvent("page:deleted", pageName); } // await this.getPageMeta(path); // Check if page exists, if not throws Error await this.wrapped.deleteFile(path); this.deleteFromSnapshot(path); await this.dispatchEvent("file:deleted", path); } finally { await this.saveSnapshot(); this.operationInProgress = false; } } }