/** * 资源元数据、对象级依赖足迹与保留引用扫描。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; /** 查询原上传请求;缺回执不等于确定没存下字节。 */ query(request: FrozenRequest, signal: AbortSignal): Promise; /** 将执行证明校验为 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 { const result = new Set(), 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(), writes = new Set(); 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, next: ReturnType) { 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(),fieldTargets=new Set(); /** 先记需要解释 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(); /** 每种 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=new Map())=>{ const pins=new Map>();let complete=interpreted; /** 同一 owner 只计一份保留理由;调用者得到集合,不拥有第二套可变引用计数。 */ const pin=(id:Id,owner:string)=>{const set=pins.get(id)??new Set();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(),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){return createResourcePinScan(snapshot.history,ledger,registry)(snapshot.document,explicit);}