/** * Yjs Job — async iterable that pushes items to a Y.Map with progress tracking. * * The job is controlled via a Y.Map toggle — set toggle.value = true to start, * false to stop. Status (running/idle/completed) is tracked in a Y.Map. */ import { yMapIterate } from "@cxai/stream"; import * as Y from "yjs"; export interface JobStatus { status: "idle" | "running" | "completed" | "error"; lastSyncTime?: string; progress?: number; } export interface JobResult { item: T; progress?: number; } /** * Job class that consumes an async iterable and updates a Yjs document map. * Controlled through a Yjs map with a "toggle" property. * * Items are stored in Y.Map(path) keyed by item.id (or auto-generated key). */ export class Job> { public doc: Y.Doc; public path: string; public statusPath: string; public togglePath: string; private iterableFactory: () => AsyncIterable>; private controller: AbortController | null = null; private statusMap: Y.Map; private toggleMap: Y.Map; private toggleObserver: ((event: Y.YMapEvent) => void) | null = null; public items: Y.Map; jobsMap: Y.Map; constructor({ doc, path, statusPath, togglePath, iterable, }: { doc: Y.Doc; path: string; statusPath?: string; togglePath?: string; /** Pass a factory function that creates a fresh iterable, or a single iterable */ iterable: AsyncIterable> | (() => AsyncIterable>); }) { this.doc = doc; this.path = path; this.jobsMap = doc.getMap("@jobs"); this.statusPath = statusPath ?? `@job.${path}.status`; this.togglePath = togglePath ?? `@job.${path}.toggle`; this.items = doc.getMap(path); // Normalize to factory — if a plain iterable is passed, wrap it (consumed once) this.iterableFactory = typeof iterable === "function" ? iterable as () => AsyncIterable> : () => iterable; // Initialize status map this.statusMap = doc.getMap(this.statusPath); if (!this.statusMap.has("status")) { this.statusMap.set("status", "idle"); this.statusMap.set("progress", 0); } // Initialize toggle map this.toggleMap = doc.getMap(this.togglePath); if (!this.toggleMap.has("value")) { this.toggleMap.set("value", false); } // Set up toggle observer this.setupToggleObserver(); } /** * Set up observer for toggle changes */ private setupToggleObserver(): void { this.toggleObserver = (event: Y.YMapEvent) => { if (event.keysChanged.has("value")) { const toggleValue = this.toggleMap.get("value"); const currentStatus = this.statusMap.get("status"); if (toggleValue === true && currentStatus !== "running") { this.start(); } else if (toggleValue === false && currentStatus === "running") { this.stop(); } } }; this.toggleMap.observe(this.toggleObserver); } /** * Updates the status in the Yjs document */ private updateStatus(status: Partial): void { this.doc.transact(() => { for (const [key, value] of Object.entries(status)) { this.statusMap.set(key, value); } this.jobsMap.set(this.path, this.statusMap.toJSON() as JobStatus); }); } /** * Starts processing the async iterable and updating the Yjs document */ private async start(): Promise { if (this.controller) return; this.controller = new AbortController(); const signal = this.controller.signal; this.updateStatus({ status: "running", progress: 0 }); try { const iterator = this.iterableFactory()[Symbol.asyncIterator](); while (true) { if (signal.aborted) break; const next = await iterator.next(); if (next.done) break; const { item, progress } = next.value; this.doc.transact(() => { const collection = this.doc.getMap(this.path); const id = String((item as any).id ?? `item-${collection.size}`); collection.set(id, item); }); if (progress !== undefined) { this.updateStatus({ progress }); } } this.updateStatus({ status: "completed", lastSyncTime: new Date().toISOString(), progress: 100, }); } catch (error) { console.error("Error in job:", error); this.updateStatus({ status: "error", lastSyncTime: new Date().toISOString(), }); } finally { this.controller = null; } } /** * Stops the current job */ public stop(): void { if (!this.controller) return; this.controller.abort(); this.updateStatus({ status: "idle" }); this.controller = null; } /** Check if job is currently running */ public isRunning(): boolean { return this.controller !== null; } /** Get current job status */ public getStatus(): JobStatus { return Object.fromEntries(this.statusMap.entries()) as JobStatus; } /** * Manually triggers the job (set toggle to true) */ public trigger(): void { this.toggleMap.set("value", true); } /** Get all items from the Y.Map */ public getItems(): T[] { return Array.from(this.doc.getMap(this.path).values()); } /** Clear all items and reset status */ public clear(): void { if (this.isRunning()) this.stop(); this.doc.transact(() => { const collection = this.doc.getMap(this.path); collection.clear(); this.updateStatus({ status: "idle", progress: 0, lastSyncTime: new Date().toISOString(), }); }); } /** * Cleanup resources */ public destroy(): void { if (this.toggleObserver) { this.toggleMap.unobserve(this.toggleObserver); this.toggleObserver = null; } if (this.controller) { this.controller.abort(); this.controller = null; } } } /** * Creates a job that consumes an async iterable and updates a Yjs document map * @param options Configuration options * @returns A Job instance */ export function createJob>(options: { doc: Y.Doc; path: string; statusPath?: string; togglePath?: string; iterable: AsyncIterable> | (() => AsyncIterable>); }): Job { const job= new Job(options); options.doc.getMap("@jobs").set(job.path, true); return job; } export function getJobs(doc: Y.Doc){ return Object.assign(doc.getMap("@jobs"), { [Symbol.asyncIterator]: yMapIterate(doc.getMap("@jobs")), }); } /** * Get or create a Job from an existing Yjs doc path * (useful for observing jobs created elsewhere) */ export function getJob>(doc: Y.Doc, path: string): Job { return new Job({ doc, path, iterable: () => { const collection = doc.getMap(path); return { [Symbol.asyncIterator]: async function* () { for (const item of collection.values()) { yield { item }; } }, }; }, }); } /***TBD * declare function JobIterable= Record>(job: Job): AsyncIterable>; const createJobInvokerJob= ({ doc, path, iterable, }: { doc: Y.Doc; path: string; iterable: typeof JobIterable; }) => createJob({ doc, path:`@job.${path}.invoker`, iterable: async function* () { for await (const job of getJobs(doc)) { if (job.item.isRunning()) continue; job.item.trigger(); yield { item: job }; } }} ) const createMetaJob= ({ doc, path, iterable, }: { doc: Y.Doc; path: string; iterable: AsyncIterable>>; }) => createJob({ doc, path:`@job.${path}.invoker`, iterable: async function* () { for await (const job of getJobs(doc)) { } }} ) * */