80 lines
5.7 KiB
TypeScript
80 lines
5.7 KiB
TypeScript
import { withLocalLifecycles } from './elision.ts';
|
|
import { validateCreationSizeEffect } from '../canvas-core/creation-size-plan.ts';
|
|
import type { Change, Document, Id, OperationIntent, Registry } from '../canvas-core/types.ts';
|
|
import { applyChanges, validateDocument } from '../canvas-core/document.ts';
|
|
import { equal, fault } from '../canvas-core/values.ts';
|
|
import type { LedgerSnapshot } from './ledger.ts';
|
|
|
|
/** A complete read cannot rewind known scopes or change an unchanged-version canvas. */
|
|
export function validateCanonicalRead(previous: Document, previousVersions: ReadonlyMap<Id,number>, next: Document, versions: ReadonlyMap<Id,number>, registry: Registry) {
|
|
if(next.projectId!==previous.projectId)throw fault('project_mismatch');
|
|
validateDocument(next,registry);
|
|
for(const [id,version]of versions)if(!Number.isSafeInteger(version)||version<1)throw fault('invalid_canonical_version',[id]);
|
|
for(const canvas of next.canvases.keys())if(!versions.has(canvas))throw fault('missing_server_version',[canvas]);
|
|
const unchanged=new Set<Id>();
|
|
for(const [id,version]of previousVersions){
|
|
if((versions.get(id)??-1)<version)throw fault('stale_canonical_version',[id]);
|
|
if(versions.get(id)!==version)continue;
|
|
if(!equal(previous.canvases.get(id),next.canvases.get(id)))throw fault('canonical_version_mismatch',[id]);
|
|
unchanged.add(id);
|
|
}
|
|
for(const [id,element]of previous.elements)if(unchanged.has(element.ownerCanvasId)&&!equal(element,next.elements.get(id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);
|
|
for(const [id,element]of next.elements)if(unchanged.has(element.ownerCanvasId)&&!equal(element,previous.elements.get(id)))throw fault('canonical_version_mismatch',[element.ownerCanvasId]);
|
|
for(const [id,incarnation]of previous.tombstones)if((next.tombstones.get(id)??0)<incarnation)throw fault('stale_canonical_lifecycle',[id]);
|
|
if(versions.size===previousVersions.size&&[...versions].every(([id,version])=>previousVersions.get(id)===version)&&
|
|
(previous.tombstones.size!==next.tombstones.size||[...previous.tombstones].some(([id,incarnation])=>next.tombstones.get(id)!==incarnation)))throw fault('canonical_version_mismatch');
|
|
}
|
|
|
|
/** The footprint follows ownership; geometry does not decide which canvas owns an object. */
|
|
export function operationCanvases(intent: OperationIntent, document: Document, after:Document=document): readonly Id[] {
|
|
if(intent.operationType==='asset.upload'||intent.operationType==='run.cancel')return[];
|
|
if (intent.canvasIds?.length) return intent.canvasIds;
|
|
const ids = new Set<Id>();
|
|
for (const change of intent.changes) {
|
|
if (change.kind === 'canvas') {
|
|
ids.add(change.id);
|
|
if (change.before?.parent) ids.add(change.before.parent.canvasId);
|
|
if (change.after?.parent) ids.add(change.after.parent.canvasId);
|
|
} else if (change.kind === 'element') {
|
|
if (change.before) ids.add(change.before.ownerCanvasId);
|
|
if (change.after) ids.add(change.after.ownerCanvasId);
|
|
for(const record of [change.before,change.after])if(record?.structure==='connection')for(const endpoint of [record.source,record.target]){const owner=after.elements.get(endpoint.elementId)?.ownerCanvasId??document.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}
|
|
} else {
|
|
const owner = document.elements.get(change.target.id)?.ownerCanvasId;
|
|
if (owner) ids.add(owner);
|
|
const nextOwner=after.elements.get(change.target.id)?.ownerCanvasId;if(nextOwner)ids.add(nextOwner);
|
|
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'){for(const model of [document,after]){const owner=model.elements.get(endpoint.elementId)?.ownerCanvasId;if(owner)ids.add(owner);}}
|
|
if (change.field === 'ownerCanvasId') { ids.add(change.before as Id); ids.add(change.after as Id); }
|
|
}
|
|
}
|
|
if (!ids.size) throw fault('missing_participants', [intent.id]);
|
|
return [...ids].sort();
|
|
}
|
|
|
|
/** Replay once, by logical sequence. A receipt can already contain a desired field value. */
|
|
export function replayPending(base: Document, ledger: LedgerSnapshot, registry: Registry): Document {
|
|
let document = withLocalLifecycles(base, ledger);
|
|
for (const record of [...ledger.operations.values()].sort((a, b) => a.intent.sequence - b.intent.sequence)) {
|
|
if (record.phase === 'resolved' || !['document.change','run.apply'].includes(record.intent.operationType)) continue;
|
|
if (record.intent.historyEffect) validateCreationSizeEffect(record.intent.historyEffect);
|
|
for (const change of record.intent.changes) {
|
|
if(change.kind==='field'){const owner=document.elements.get(change.target.id)?.ownerCanvasId;if(owner&&record.intent.canvasIds?.length&&!record.intent.canvasIds.includes(owner))throw fault('operation_context_changed',[change.target.id]);}
|
|
if (satisfied(document, change)) continue;
|
|
document = applyChanges(document, [change]);
|
|
}
|
|
}
|
|
validateDocument(document, registry);
|
|
return document;
|
|
}
|
|
function satisfied(document: Document, change: Change) {
|
|
if (change.kind === 'canvas') return equal(document.canvases.get(change.id) ?? null, change.after);
|
|
if (change.kind === 'element') return equal(document.elements.get(change.id) ?? null, change.after);
|
|
const element = document.elements.get(change.target.id);
|
|
if (!element || element.incarnation !== change.target.incarnation) return false;
|
|
if (change.field.startsWith('data.')) {
|
|
const key = change.field.slice(5);
|
|
return Object.hasOwn(element.data, key) === (change.afterPresent ?? true) && equal(element.data[key] ?? null, change.after);
|
|
}
|
|
return equal((element as unknown as Record<string, unknown>)[change.field], change.after);
|
|
}
|