94 lines
6.1 KiB
TypeScript
94 lines
6.1 KiB
TypeScript
import type { Change, CoreSnapshot, Document, ElementRecord, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
|
import { ancestors } from '../canvas-core/document.ts';
|
|
import type { FrozenRequest, LedgerSnapshot } from './ledger.ts';
|
|
import type { OperationStatus } from './scheduler.ts';
|
|
import { readMap, readSet } from '../canvas-core/values.ts';
|
|
|
|
export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string };
|
|
export type ResourceRecord = { readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly digest?: string; readonly stored?: StoredAsset };
|
|
export type DisplayHandle = { readonly url: string; release(): void };
|
|
export type AssetPort = {
|
|
sourceURL(resourceId: Id): string;
|
|
displayURL(resourceId: Id): string;
|
|
upload(request: FrozenRequest, bytes: Blob, signal: AbortSignal): Promise<OperationStatus>;
|
|
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
|
accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset;
|
|
};
|
|
export type StagedFile = { readonly bytes: Blob; readonly name: string };
|
|
export type ResourceHandle = { readonly id: Id; readonly src: string };
|
|
export const maxResourceBytes = 20 * 1024 * 1024;
|
|
|
|
export function referencedResources(changes: readonly Change[], registry: Registry): Set<Id> {
|
|
const result = new Set<Id>(), fields = new Set([...registry.components.values()].flatMap(component => component.resourceFields ?? []));
|
|
const collect = (element: ElementRecord | null) => {
|
|
if (!element) return;
|
|
for (const field of registry.components.get(element.componentId)?.resourceFields ?? []) if (typeof element.data[field] === 'string') result.add(element.data[field] as string);
|
|
};
|
|
for (const change of changes) {
|
|
if (change.kind === 'element') { collect(change.before); collect(change.after); }
|
|
else if (change.kind === 'field' && change.field.startsWith('data.') && fields.has(change.field.slice(5))) {
|
|
if (typeof change.before === 'string') result.add(change.before);
|
|
if (typeof change.after === 'string') result.add(change.after);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** Read/write dependencies use object identities, rather than blocking every edit behind a pending image. */
|
|
export function operationFootprint(intent: OperationIntent, document: Document) {
|
|
const reads = new Set<string>(), writes = new Set<string>();
|
|
if (intent.operationType === 'asset.upload') { writes.add(`resource:${intent.resourceId}`); return { reads, writes }; }
|
|
const readCanvas = (id: Id) => {
|
|
if (document.canvases.has(id)) for (const ancestor of ancestors(document, id)) reads.add(`canvas:${ancestor}`);
|
|
else reads.add(`canvas:${id}`);
|
|
};
|
|
const record = (element: ElementRecord | null) => {
|
|
if (!element) return;
|
|
readCanvas(element.ownerCanvasId);
|
|
if (element.structure === 'connection') { reads.add(`element:${element.source.elementId}`); reads.add(`element:${element.target.elementId}`); }
|
|
};
|
|
for (const change of intent.changes) {
|
|
if (change.kind === 'canvas') {
|
|
writes.add(`canvas:${change.id}`);
|
|
for (const canvas of [change.before, change.after]) if (canvas?.parent) { readCanvas(canvas.parent.canvasId); reads.add(`element:${canvas.parent.entryElementId}`); }
|
|
} else if (change.kind === 'element') {
|
|
writes.add(`element:${change.id}`); record(change.before); record(change.after);
|
|
} else {
|
|
writes.add(`element:${change.target.id}`); record(document.elements.get(change.target.id) ?? null);
|
|
if (change.field === 'ownerCanvasId') { readCanvas(change.before as string); readCanvas(change.after as string); }
|
|
if (change.field === 'source' || change.field === 'target') for (const endpoint of [change.before, change.after]) {
|
|
if (endpoint && typeof endpoint === 'object' && !Array.isArray(endpoint) && typeof endpoint.elementId === 'string') reads.add(`element:${endpoint.elementId}`);
|
|
}
|
|
}
|
|
}
|
|
return { reads, writes };
|
|
}
|
|
export function conflictsWith(prior: ReturnType<typeof operationFootprint>, next: ReturnType<typeof operationFootprint>) {
|
|
return [...prior.writes].some(id => next.writes.has(id) || next.reads.has(id)) || [...prior.reads].some(id => next.writes.has(id));
|
|
}
|
|
|
|
/** Pins are derived from their owners. There is no second mutable reference count. */
|
|
export function resourcePins(snapshot: CoreSnapshot, ledger: LedgerSnapshot, registry: Registry, explicit: ReadonlyMap<Id, readonly Id[]>) {
|
|
const pins = new Map<Id, Set<string>>();
|
|
let complete = true;
|
|
const pin = (id: Id, owner: string) => { const set = pins.get(id) ?? new Set<string>(); set.add(owner); pins.set(id, set); };
|
|
const element = (record: ElementRecord | null, owner: string) => {
|
|
if (!record) return;
|
|
const component = registry.components.get(record.componentId);
|
|
if (registry.resolve(record) !== 'known' || component?.resourceFields === undefined) { complete = false; return; }
|
|
for (const field of component.resourceFields) { const ref = record.data[field]; if (typeof ref === 'string') pin(ref, owner); }
|
|
};
|
|
const changes = (values: readonly Change[], owner: string) => {
|
|
for (const change of values) if (change.kind === 'element') { element(change.before, owner); element(change.after, owner); }
|
|
for (const id of referencedResources(values, registry)) pin(id, owner);
|
|
};
|
|
for (const record of snapshot.document.elements.values()) element(record, `element:${record.id}:${record.incarnation}`);
|
|
for (const entry of snapshot.history.entries.values()) changes(entry.changes, `history:${entry.id}`);
|
|
for (const record of ledger.operations.values()) if (record.phase !== 'resolved') {
|
|
if (record.intent.operationType === 'asset.upload') pin(record.intent.resourceId, `operation:${record.intent.id}`);
|
|
else changes(record.intent.changes, `operation:${record.intent.id}`);
|
|
}
|
|
for (const [owner, ids] of explicit) for (const id of ids) pin(id, owner);
|
|
return Object.freeze({ complete, pins: readMap(new Map([...pins].map(([id, owners]) => [id, readSet(owners)]))) });
|
|
}
|