Files
plainleaf/lib/spaces/evented_space_primitives.ts
T
Zef HemelandGitHub dc2987a3f9 Sync engine rearchitecture (#1516)
* Don't require auth for .ping
* Finally introducing loggers
* Remove file list caching from disk_space_primitives
I don't think this is required anymore with the sync model
* Simplification of space primitives and more tests
* More reliable online status
* Add .zed settings
* Remove selfUpdate argument from `writeFile` no longer used
- Add `sync` config schema and document sync options
- Move sync config handling to client boot logic
- Move BootConfig type to ui_types.ts
- Remove sync env vars from server
- Update documentation for sync and architecture
* Improve sync logic and proxy routing for full sync state
* Add BoxProxy for late-bound client and improve Lua boot
- Add isMobile syscall for editor
* Add concurrency to sync process and log indexing duration
* Better service worker lifecycle management
2025-09-12 14:43:57 +02:00

264 lines
7.9 KiB
TypeScript

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<string, number> = {};
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<any[]> {
if (!this.enabled) {
return Promise.resolve([]);
}
// console.log("Evented space, dispatching", name, args);
return this.eventHook.dispatchEvent(name, ...args);
}
async fetchFileList(): Promise<FileMeta[]> {
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<string>(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<FileMeta> {
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<FileMeta> {
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<void> {
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;
}
}
}