Files
Endoleap/src/lib/canvas-runtime/workspace.ts
T

59 lines
3.8 KiB
TypeScript

import type { Document, Id, Registry } from '../canvas-core/types.ts';
import { fault, readMap } from '../canvas-core/values.ts';
import { openProjectEngine, type EngineOptions } from './engine.ts';
import {createDigestPool,type DigestPort} from './digest-pool.ts';
import type { StoragePort } from './storage.ts';
import { createDisplayCache } from './display-cache.ts';
export type ProjectEngine = Awaited<ReturnType<typeof openProjectEngine>>;
export type WorkspaceOptions = {
accountId: Id; workspaceId: Id; authEpoch: number; registry: Registry; storage: StoragePort; digests?:DigestPort; images?:import('./image-decoder.ts').ImageDecoderPort; queryBudget?:EngineOptions['queryBudget'];
isCurrentIdentity(): boolean;
adapters(projectId: Id, current: () => boolean): Pick<EngineOptions, 'persistence' | 'assets' | 'runs'>;
onError?: (error: unknown) => void;
};
/** Workspace owns persistent projects; a view only borrows one and never closes it. */
export function createWorkspaceRuntime(options: WorkspaceOptions) {
const digests=options.digests??createDigestPool();
const displays=createDisplayCache({onError:options.onError});
const projects = new Map<Id, Promise<ProjectEngine>>();
let accepting = true, closed = false, closing: Promise<void> | undefined;
const current = () => accepting && !closed && options.isCurrentIdentity();
return Object.freeze({
accountId: options.accountId, workspaceId: options.workspaceId, authEpoch: options.authEpoch, registry: options.registry,
isCurrentIdentity: current,
getProjects: () => readMap(projects),
open(document: Document, versions: ReadonlyMap<Id, number>) {
if (!current()) return Promise.reject(fault('identity_changed'));
const existing = projects.get(document.projectId);
if (existing) return existing.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
const opening = openProjectEngine({ scope: { accountId: options.accountId, workspaceId: options.workspaceId, projectId: document.projectId }, registry: options.registry, document, versions, storage: options.storage, displays, digests, images:options.images, queryBudget:options.queryBudget, ...options.adapters(document.projectId, current), onError: options.onError,isCurrentIdentity:current,
traversalPolicy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
policy: () => current() ? { enabled: true } : { enabled: false, code: 'identity_changed', targets: [] },
});
projects.set(document.projectId, opening);
void opening.catch(() => { if (projects.get(document.projectId) === opening) projects.delete(document.projectId); });
return opening.then(engine => { if (!current()) throw fault('identity_changed'); engine.refresh(document, versions); return engine; });
},
close() {
accepting = false; // Fence commands/transports before awaiting pending opens or storage.
if (closed) return Promise.resolve();
if (closing) return closing;
const task = (async () => {
const results = await Promise.allSettled([...projects.values()].map(async pending => {
let engine: ProjectEngine;
try { engine = await pending; } catch { return; }
await engine.close();
}));
const errors = results.flatMap(result => result.status === 'rejected' ? [result.reason] : []);
if (errors.length) throw new AggregateError(errors, 'workspace_close_failed');
displays.close();digests.close();options.images?.close();options.storage.close(); projects.clear(); closed = true;
})();
closing = task.finally(() => { closing = undefined; });
return closing;
},
});
}
export type WorkspaceRuntime = ReturnType<typeof createWorkspaceRuntime>;