72 lines
9.3 KiB
TypeScript
72 lines
9.3 KiB
TypeScript
/**
|
|
* 同账号/工作区内的共享原图仓库门面,authority 区分服务授权域,digest 标识原始不可变字节。
|
|
* 复用图片是复用资源身份/字节,不复制成新上传任务;新 adoption 必须重新验证当前权限,不能仅信热缓存。
|
|
* 本门面只管理元数据缓存和受控读取,可靠写入通过与项目 Root 同事务的 DepotCut 完成。
|
|
*/
|
|
import type {Id} from '../canvas-core/types.ts';
|
|
import {equal,fault,freeze,readMap} from '../canvas-core/values.ts';
|
|
import type {StoredAsset,LoadingAssetAdmission} from './resources.ts';
|
|
import type {StorageLeader,StorageScope} from './storage-port.ts';
|
|
|
|
/** 版本化共享身份:授权域 + 原字节 SHA256 + 服务端已确认资产,不含临时 blob URL。 */
|
|
export type SharedAsset={readonly version:1;readonly authority:string;readonly digest:string;readonly asset:StoredAsset};
|
|
/** 目标项目采纳资源的操作证据,可附部分加载来源准入,不等于重新上传。 */
|
|
export type SharedAssetBinding=SharedAsset&{readonly operationId:Id;readonly loadingAdmission?:(LoadingAssetAdmission&{readonly operationId:Id})};
|
|
/** 当前服务授权端口:resolve 重新确认权限/元数据,read 取原字节,不用预览转码代替。 */
|
|
export type AssetAuthority={readonly scope:string;resolve(id:Id,signal:AbortSignal):Promise<SharedAsset>;read(id:Id,signal:AbortSignal):Promise<Blob>};
|
|
/** 一次写入的不可变引用与可选原字节;已有共享字节时可只写身份。 */
|
|
export type DepotWrite={readonly ref:SharedAsset;readonly bytes?:Blob};
|
|
/** 某 Root 的仓库清单:完整性、资源→保留 owner 列表和写入,须与 Root 同次提交。 */
|
|
export type DepotCut={readonly version:1;readonly authority:string;readonly complete:boolean;readonly pins:readonly (readonly [Id,readonly string[]])[];readonly writes:readonly DepotWrite[]};
|
|
/** 共享身份即使字节已回收仍保留,unreferencedAt/purgedAt 区分软标记与永久字节清理。 */
|
|
export type DepotEntry={readonly ref:SharedAsset;readonly bytes?:Blob;readonly unreferencedAt?:number;readonly purgedAt?:number};
|
|
/** 分页回收结果;被任何项目的不完整/过期清单阻挡时 complete=false,不能当作安全空仓库。 */
|
|
export type DepotCollection={readonly complete:boolean;readonly blockedProjects:readonly Id[];readonly markedIds:readonly Id[];readonly purgedIds:readonly Id[];readonly pinnedIds:readonly Id[];readonly nextAfter?:Id};
|
|
/** 存储侧共享仓库能力,leader/fence 检查由实际 adapter 执行。 */
|
|
export type AssetDepotPort={
|
|
read(leader:StorageLeader,authority:string,id:Id):Promise<DepotEntry|undefined>;
|
|
collect(leader:StorageLeader,input:{readonly authority:string;readonly now:number;readonly retentionMs:number;readonly limit:number;readonly after?:Id}):Promise<DepotCollection>;
|
|
};
|
|
/** 校验版本、非空授权域/URL、64 字符小写摘要、图片 MIME、正整数尺寸/字节及像素/20MiB上限。
|
|
* 只检查元数据:URL 未限制协议/域,摘要未重算,尺寸未通过实际解码核实;授权真实性由服务端口保证。 */
|
|
export function validateSharedAsset(value:SharedAsset):void{
|
|
if(!value||value.version!==1||typeof value.authority!=='string'||!value.authority||value.authority.length>8192||typeof value.digest!=='string'||!/^([a-f0-9]{64})$/.test(value.digest))throw fault('invalid_shared_asset');
|
|
const asset=value.asset;if(!asset||typeof asset.id!=='string'||!asset.id||typeof asset.url!=='string'||!asset.url||asset.url.length>8192||![asset.width,asset.height,asset.bytes].every(n=>Number.isSafeInteger(n)&&n>0)||asset.width*asset.height>40_000_000||asset.bytes>20*1024*1024||!['image/png','image/jpeg','image/gif','image/webp'].includes(asset.mime_type))throw fault('invalid_shared_asset');
|
|
}
|
|
/** 验证后重建标准外层字段并浅复制 asset,再递归冻结,隔离标准标量元信息。
|
|
* asset 的扩展属性仍由 spread 保留,嵌套扩展值不深克隆,不能当作任意外部对象的完整隔离入口。 */
|
|
export function sharedAssetRef(value:SharedAsset):SharedAsset{validateSharedAsset(value);return freeze({version:1 as const,authority:value.authority,digest:value.digest,asset:{...value.asset}});}
|
|
/** 比较完整不可变引用,不能只因 ID 或 URL 一样就容忍摘要/尺寸变化。 */
|
|
const same=(a:SharedAsset,b:SharedAsset)=>equal(sharedAssetRef(a),sharedAssetRef(b));
|
|
/** Immutable metadata cache only. Every new adoption resolves current permission first. */
|
|
/** 捕获 account/workspace 标量,建立仅存元信息的 Map;字节仍由 port 持有,不建立内存 Blob 缓存。
|
|
* 宿主 current/leader 校验约束使用生命周期,实际存储 fence/权限检查仍由端口实现。
|
|
* 每次 getResources 返回成员快照,旧快照不随 remember/close 改变;它不另做授权或关闭检查。 */
|
|
export function createAssetDepot(options:{scope:Pick<StorageScope,'accountId'|'workspaceId'>;port:AssetDepotPort;current():boolean;digest(bytes:Blob,signal?:AbortSignal):Promise<string>}){
|
|
const scope=freeze({accountId:options.scope.accountId,workspaceId:options.scope.workspaceId});let closed=false;const records=new Map<string,SharedAsset>(),controllers=new Set<AbortController>();
|
|
/** 入口及异步结果采纳前检查宿主/leader,允许同账号工作区的不同项目共享。
|
|
* 不比较 projectId,不读取远端 fence;在途 I/O 是否立即停止由端口/信号决定。 */
|
|
const check=(leader:StorageLeader)=>{if(closed||!options.current())throw fault('identity_changed');if(!leader.active||leader.scope.accountId!==scope.accountId||leader.scope.workspaceId!==scope.workspaceId)throw fault('asset_depot_scope_mismatch');};
|
|
/** 缓存同 authority/assetId 的不可变引用;相同身份元数据改变视为冲突,不静默覆盖。 */
|
|
function remember(ref:SharedAsset){validateSharedAsset(ref);const key=JSON.stringify([ref.authority,ref.asset.id]),previous=records.get(key);if(previous&&!same(previous,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);records.set(key,sharedAssetRef(ref));}
|
|
return Object.freeze({scope,getResources:()=>readMap(records),
|
|
/** 新采纳先 resolve 当前权限,再核对缓存身份;有缓存字节仍验长度/摘要,否则向 authority 读取。
|
|
* combined 信号交端口,异步返回后再检查;入口不先 throwIfAborted,已取消信号仍可能传给 resolve。
|
|
* remember 捕获元信息,但返回 ref 沿用 resolve 结果(只冻结返回 envelope),端口须提供不可变身份。
|
|
* 成功不写共享字节/项目 binding;这些由 engine 同 Root 持久化,不是新上传任务。 */
|
|
async authorize(leader:StorageLeader,authority:AssetAuthority,id:Id,signal:AbortSignal){check(leader);const controller=new AbortController(),combined=AbortSignal.any([signal,controller.signal]);controllers.add(controller);try{const ref=await authority.resolve(id,combined);check(leader);combined.throwIfAborted();validateSharedAsset(ref);if(ref.authority!==authority.scope||ref.asset.id!==id)throw fault('asset_authorization_mismatch');const cached=await options.port.read(leader,authority.scope,id);check(leader);combined.throwIfAborted();if(cached&&!same(cached.ref,ref))throw fault('shared_asset_identity_conflict',[id]);let bytes=cached?.bytes;if(!bytes)bytes=await authority.read(id,combined);check(leader);combined.throwIfAborted();if(bytes.size!==ref.asset.bytes||await options.digest(bytes,combined)!==ref.digest)throw fault('shared_asset_bytes_changed',[id]);check(leader);combined.throwIfAborted();remember(ref);return Object.freeze({ref,bytes});}finally{controllers.delete(controller);}},
|
|
/** 按已绑定 ref 读取本地共享字节并验身份/摘要,不调用 authority.resolve 或回源下载。
|
|
* 无记录直接 undefined;有记录无字节也记下元信息再返回 undefined,不能据此判定服务器资产消失。
|
|
* 输入 ref 沿用调用方身份;本方法不单独证明调用者有权新采纳该资源。 */
|
|
async read(leader:StorageLeader,ref:SharedAsset,signal?:AbortSignal){check(leader);signal?.throwIfAborted();const row=await options.port.read(leader,ref.authority,ref.asset.id);check(leader);signal?.throwIfAborted();if(!row)return;if(!same(row.ref,ref))throw fault('shared_asset_identity_conflict',[ref.asset.id]);if(row.bytes&&(row.bytes.size!==ref.asset.bytes||await options.digest(row.bytes,signal)!==ref.digest))throw fault('shared_asset_bytes_changed',[ref.asset.id]);check(leader);signal?.throwIfAborted();remember(ref);return row.bytes;},
|
|
/** 作用域检查后直接委托 port.collect,结果由端口负责结构与回收证明,不在此重验。
|
|
* 返回后再核对身份;检查失败不代表端口之前的本地存储事务未执行,也不删除服务器资产。 */
|
|
async collect(leader:StorageLeader,input:Parameters<AssetDepotPort['collect']>[1]){check(leader);const result=await options.port.collect(leader,input);check(leader);return result;},
|
|
/** 关闭后拒绝新操作,abort 本门面追踪的 authorize 并清元缓存;不删除持久身份/字节。
|
|
* read/collect 没有被加入 controllers,在途结果由后续 check 拒绝采纳;close 不等待底层 I/O 退出。 */
|
|
close(){closed=true;for(const controller of controllers)controller.abort();controllers.clear();records.clear();},
|
|
});
|
|
}
|
|
/** 公共仓库门面 API 由工厂推导,与底层 AssetDepotPort 分开。 */
|
|
export type AssetDepot=ReturnType<typeof createAssetDepot>;
|