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

171 lines
14 KiB
TypeScript

/**
* 资源元数据、对象级依赖足迹与保留引用扫描。ResourceId 指逻辑文件/远端资产,元素副本可共享它;
* Blob 字节、stored 回执、显示 URL、history pin 是不同事实,不能因为 DOM 卸载或队列为空就回收文件。
* 扫描保守解释已知 schema,未知资源字段/历史效果会使 complete=false,阻止永久删除。
*/
import type {ImageDecodeState} from './image-decoder.ts';
import type { DefaultImageSizing } from './default-image-size.ts';
import type { Change, CoreSnapshot, Document, ElementRecord, Id, HistoryState, OperationIntent, Registry } from '../canvas-core/types.ts';
import { historyTransitionChanges } from '../canvas-core/history-transition.ts';
import { ancestors } from '../canvas-core/document.ts';
import type { FrozenRequest, LedgerSnapshot, OperationRecord } from './ledger.ts';
import type { OperationStatus } from './scheduler.ts';
import { readMap, readSet } from '../canvas-core/values.ts';
/** 服务端已确认资源元信息;url 是服务记录,预览显示地址另由 displayURL 决定。 */
export type StoredAsset = { readonly id: Id; readonly url: string; readonly width: number; readonly height: number; readonly bytes: number; readonly mime_type: string };
/** 部分加载阶段的资源准入证据,绑定源捕获、服务源 URL 与允许参与范围。 */
export type LoadingAssetAdmission = {readonly version:1;readonly sourceToken:string;readonly sourceURL:string;readonly canvasIds:readonly Id[]};
/** 项目本地资源事实:上传操作、摘要、解码/默认尺寸进度、可选远端存储与退休水位。 */
export type ResourceRecord = { readonly loadingAdmission?:LoadingAssetAdmission; readonly id: Id; readonly operationId: Id; readonly name: string; readonly mimeType: string; readonly byteLength: number; readonly sizing?: DefaultImageSizing; readonly image?:ImageDecodeState; readonly digest?: string; readonly retirement?: {readonly version:1;readonly unreferencedAt:number;readonly purgedAt?:number}; readonly stored?: StoredAsset };
/** 临时显示 URL 的租用句柄,release 归还引用;URL 本身不是可恢复资源身份。 */
export type DisplayHandle = { readonly url: string; release(): void };
/** 宿主资源端口:原图读取身份、展示地址、原请求上传/查询与回执解释分开。 */
export type AssetPort = {
/** 可选共享资源授权/原字节读取端口,缓存有字节不等于当前仍有权限。 */
readonly authority?:import('./asset-depot.ts').AssetAuthority;
/** 原资源的规范来源,用于授权/身份与摘要验证,不应以转码预览替代。 */
sourceURL(resourceId: Id): string;
/** renderer 使用的可访问预览地址,认证仍由宿主负责。 */
displayURL(resourceId: Id): string;
/** 使用已冻结请求和不可变 Blob 上传,丢响应时不能换资源 ID 重新上传。 */
upload(request: FrozenRequest, bytes: Blob, signal: AbortSignal): Promise<OperationStatus>;
/** 查询原上传请求;缺回执不等于确定没存下字节。 */
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
/** 将执行证明校验为 StoredAsset,只更新资源事实,不重新创建元素或用户历史。 */
accept(request: FrozenRequest, status: OperationStatus, resource: ResourceRecord): StoredAsset;
};
/** 调用者暂存的文件字节/名称及是否请求默认尺寸,元数据先入模型可立即显示占位。 */
export type StagedFile = { readonly bytes: Blob; readonly name: string; readonly defaultSize?: boolean };
/** stageFiles 返回给同步创建回调的资源引用,src 不是 Blob 字节本身。 */
export type ResourceHandle = { readonly id: Id; readonly src: string };
/** 单资源 20 MiB 上限,与解码像素预算是不同约束。 */
export const maxResourceBytes = 20 * 1024 * 1024;
/** 从 changes 收集可能依赖的资源;这里不返回完整性证明,不能直接作为安全回收扫描。 */
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 ?? []));
/** 整记录按组件声明 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. */
/** 构造对象级 read/write 集,避免独立文本编辑被未完成图片上传全局阻塞。 */
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 }; }
/** 读取 owner 也依赖其祖先拥有树;暂未加载的 canvas 仍登记自身键,不能当作无依赖。 */
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}`);
};
/** 元素变化读取其 owner;连线还读取两端节点身份,和几何位置无关。 */
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));
}
/** One synchronous retention pass shares history interpretation across working/ACK.
* No snapshot or mutable reference-count state is retained after the caller returns. */
/** 一次保留扫描的解释器,预处理 history/ledger 可复用到 working 与 ACK;不缓存新的业务快照。 */
export function createResourcePinScan(history:HistoryState,ledger:LedgerSnapshot,registry:Registry){
const transitions=new Map<OperationRecord,readonly Change[]>(),fieldTargets=new Set<Id>();
/** 先记需要解释 data 字段变化的目标,再从历史整记录补齐它可能使用过的组件 schema。 */
const rememberTargets=(values:readonly Change[])=>{for(const change of values)if(change.kind==='field'&&change.field.startsWith('data.'))fieldTargets.add(change.target.id);};
// 最早未解决 sequence 决定还需保留哪些历史 transition 证据;不能只扫描当前非空 undo 栈。
const pending=[...ledger.operations.values()].filter(record=>record.phase!=='resolved'),earliest=pending.reduce((value,record)=>Math.min(value,record.intent.sequence),Infinity);
for(const entry of history.entries.values())rememberTargets(entry.changes);
let interpreted=true;
for(const record of ledger.operations.values()){
// Obtain validated ranges for every understood transition, including resolved
// evidence. The core may reuse only proven immutable transition data.
if(record.intent.historyTransition?.version===1)transitions.set(record,historyTransitionChanges(record.intent.historyTransition));
if(record.phase!=='resolved')rememberTargets(record.intent.changes);
if(record.intent.historyEffect&&record.intent.historyEffect.version!==1)interpreted=false;
if(record.intent.sequence>=earliest&&record.intent.historyTransition){if(record.intent.historyTransition.version!==1)interpreted=false;else rememberTargets(transitions.get(record)!);}
}
// 同一 ID 的历史可能跨组件/版本,不只套用当前元素的 resourceFields。
const schemas=new Map<Id,ElementRecord[]>();
/** 每种 component/version 保留一个历史样本,只为需要解释字段变化的目标建立索引。 */
const remember=(record:ElementRecord|null)=>{if(record&&fieldTargets.has(record.id)){const list=schemas.get(record.id)??[];if(!list.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))list.push(record);schemas.set(record.id,list);}};
/** 从整记录变化的 before/after 补历史 schema,删除后的资源仍可由 history 保留。 */
const rememberChanges=(values:readonly Change[])=>{for(const change of values)if(change.kind==='element'){remember(change.before);remember(change.after);}};
if(fieldTargets.size){
for(const entry of history.entries.values())rememberChanges(entry.changes);
for(const record of ledger.operations.values()){rememberChanges(record.intent.changes);const changes=transitions.get(record);if(changes)rememberChanges(changes);}
}
const fields=new Set([...registry.components.values()].flatMap(component=>component.resourceFields??[]));
/** 为指定 document 生成 resource ID→owner 集;explicit 输入相反,是 owner→资源 ID 列表。 */
return(document:Document,explicit:ReadonlyMap<Id,readonly Id[]>=new Map())=>{
const pins=new Map<Id,Set<string>>();let complete=interpreted;
/** 同一 owner 只计一份保留理由;调用者得到集合,不拥有第二套可变引用计数。 */
const pin=(id:Id,owner:string)=>{const set=pins.get(id)??new Set<string>();set.add(owner);pins.set(id,set);};
/** 已知组件逐资源字段收集,字段格式或 schema 不可解释时降低完整性,不能默认为空。 */
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);else if(ref!==undefined&&ref!==null)complete=false;}
};
/** 对历史/待办扫描 before+after,字段变化对当前和历史 schema 都检查,保守保留旧资源。 */
const changes=(values:readonly Change[],owner:string)=>{
for(const change of values){
if(change.kind==='element'){element(change.before,owner);element(change.after,owner);}
else if(change.kind==='field'&&change.field.startsWith('data.')){
const candidates:ElementRecord[]=[],current=document.elements.get(change.target.id);if(current)candidates.push(current);
for(const record of schemas.get(change.target.id)??[])if(!candidates.some(item=>item.componentId===record.componentId&&item.dataVersion===record.dataVersion))candidates.push(record);
if(!candidates.length)complete=false;
for(const record of candidates){const component=registry.components.get(record.componentId);if(registry.resolve(record)!=='known'||component?.resourceFields===undefined){complete=false;continue;}
if(component.resourceFields.includes(change.field.slice(5)))for(const value of [change.before,change.after])if(typeof value==='string')pin(value,owner);else if(value!==undefined&&value!==null)complete=false;
}
}
}
// Keep the conservative cross-schema fallback and its original insertion order.
const refs=new Set<Id>(),collect=(record:ElementRecord|null)=>{if(record)for(const field of registry.components.get(record.componentId)?.resourceFields??[])if(typeof record.data[field]==='string')refs.add(record.data[field] as string);};
for(const change of values)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')refs.add(change.before);if(typeof change.after==='string')refs.add(change.after);}
for(const id of refs)pin(id,owner);
};
// 合并 working、history、未解决操作、相关 transition 及显式 owner,各保留独立可解释理由。
for(const record of document.elements.values())element(record,`element:${record.id}:${record.incarnation}`);
for(const entry of 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 record of ledger.operations.values())if(record.intent.sequence>=earliest&&record.intent.historyTransition?.version===1)changes(transitions.get(record)!,`history-transition:${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)])))});
};
}
/** Pins are derived from owners; callers receive no second mutable reference count. */
/** 便捷单次扫描;更复杂回收用 resourceRetentionPins 叠加 ACK/显示/临时任务等保留事实。 */
export function resourcePins(snapshot:CoreSnapshot,ledger:LedgerSnapshot,registry:Registry,explicit:ReadonlyMap<Id,readonly Id[]>){return createResourcePinScan(snapshot.history,ledger,registry)(snapshot.document,explicit);}