docs(canvas): explain query indexes, copy checkpoints and runtime ports
This commit is contained in:
@@ -1,3 +1,8 @@
|
||||
/**
|
||||
* core 与 runtime/service 的共享合同:RuntimeState 与文档/历史/操作一起由一个 Root 发布。
|
||||
* ledger 是调度事实源,acknowledged+versions 是已确认服务基线,working 文档仍位于 EngineSnapshot.document。
|
||||
* 外部异步 read/submit/query 只能形成提案,由 engine/reconciler 校验后统一发布,不直接覆盖 working。
|
||||
*/
|
||||
import type {OrphanFile} from './orphan-files.ts';
|
||||
import type { ImportJob } from './imports.ts';
|
||||
import type { CanvasRecord, ElementRecord, Document, Id, Json, OperationIntent, Snapshot } from '../canvas-core/types.ts';
|
||||
@@ -7,42 +12,72 @@ import type { RunRecord } from './runs.ts';
|
||||
import type { ResourceRecord } from './resources.ts';
|
||||
import type { RecoveryAudit } from './conflicts.ts';
|
||||
|
||||
/** 持久运行事实集合;view/selection/gesture 和渲染缓存不放入该结构。 */
|
||||
export type RuntimeState = {
|
||||
/** 项目资源与共享 AssetDepot 的授权/身份绑定,非第二份图片字节。 */
|
||||
readonly assetBindings?:ReadonlyMap<Id,import('./asset-depot.ts').SharedAssetBinding>;
|
||||
/** 分段读取过程中保留的 canonical 捕获提案,与已有 ACK 基线分开协调。 */
|
||||
readonly loadingRead?:CanonicalProposal;
|
||||
/** 当前流式加载覆盖范围/进度与发送门禁事实。 */
|
||||
readonly loading?:import('./loading.ts').LoadingState;
|
||||
/** 孤儿文件盘点/保留事实,不因当前 DOM 未展示就立即删除。 */
|
||||
readonly orphanFiles?:ReadonlyMap<Id,OrphanFile>;
|
||||
/** 导入与复制 job 的可恢复进度,包含源身份/游标/稳定映射,任务不归 view 所有。 */
|
||||
readonly imports?: ReadonlyMap<Id, ImportJob>;
|
||||
/** 唯一逻辑操作/冻结请求/结果事实;queue 和到期 heap 由它派生调度。 */
|
||||
readonly ledger: LedgerSnapshot;
|
||||
/** 已受理/控制/产生输出的生成任务记录,与文档元素和外部任务 ID 分开。 */
|
||||
readonly runs: ReadonlyMap<Id, RunRecord>;
|
||||
/** 最新已确认模型基线;后续本地编辑叠加在 working,不能用旧 ACK 直接覆盖。 */
|
||||
readonly acknowledged: Document;
|
||||
/** 每个参与 canvas 的服务端版本,用于条件提交;不是 rootRevision 或组件 dataVersion。 */
|
||||
readonly versions: ReadonlyMap<Id, number>;
|
||||
/** Raw recovery inputs, or adapter-owned summaries for fully reconciled document requests. */
|
||||
/** 原始恢复输入或已完全协调请求的精简回执,保留外部执行证据。 */
|
||||
readonly receipts: ReadonlyMap<Id, Json>;
|
||||
/** 待处理冲突原因,不能从请求队列为空推断冲突已消失。 */
|
||||
readonly conflicts: ReadonlyMap<Id, string>;
|
||||
/** 文件/远端资产的逻辑资源状态,不把临时 blob URL 当可恢复身份。 */
|
||||
readonly resources: ReadonlyMap<Id,ResourceRecord>;
|
||||
/** 显式保留 owner→资源 ID 列表(如 recovery);working/history/ledger 的反向 pin 表由扫描派生。 */
|
||||
readonly resourcePins: ReadonlyMap<Id,readonly Id[]>;
|
||||
/** 用户已审阅的恢复/采纳证据,用于后续重放与审计,避免再次猜测冲突取舍。 */
|
||||
readonly recoveries: ReadonlyMap<Id,RecoveryAudit>;
|
||||
};
|
||||
/** core Snapshot 的 runtime 具象化,单次订阅可同时看到模型和对应运行事实。 */
|
||||
export type EngineSnapshot = Snapshot<RuntimeState>;
|
||||
/** 经过服务 codec 校验的文档与版本向量候选;只有协调完成后才能成为新基线。 */
|
||||
export type CanonicalProposal = { readonly document: Document; readonly versions: ReadonlyMap<Id, number> };
|
||||
/** Receiving records are read deltas, not a validated Document or an ACK base. */
|
||||
/** receiving 是尚未完成的读取增量,数量/owner 证据不表示整份 payload 已通过校验。 */
|
||||
export type CanonicalReadUpdate = CanonicalReadResult | { readonly status:'receiving'; readonly canvasElementCounts?:import('./scope-counts.ts').CanvasElementCounts;readonly elementOwners?:import('./scope-counts.ts').ElementOwners; readonly projectId:Id; readonly canvases:readonly CanvasRecord[]; readonly elements:readonly ElementRecord[]; readonly tombstones:ReadonlyMap<Id,number>; readonly versions:ReadonlyMap<Id,number>; readonly receivedElements:number; readonly totalElements:number };
|
||||
/** ready 提供新模型;migration-required 描述显式迁移资格,不在读取时偷偷执行迁移。 */
|
||||
export type CanonicalReadResult = ({ readonly status: 'ready';readonly sourceToken?:string } & CanonicalProposal) | { readonly status: 'migration-required'; readonly rootId: Id; readonly sourceVersions: ReadonlyMap<Id, number> };
|
||||
/** A validated historical execution receipt; its old model is never published over a fresh read. */
|
||||
/** 验证历史 applied 回执的执行证据;assertCanonical 比对 fresh read,不发布旧回执里的旧模型。 */
|
||||
export type AppliedModelProof = { readonly versions:ReadonlyMap<Id,number>; assertCanonical(proposal:CanonicalProposal):void };
|
||||
/** 增量服务端口:compile/canonical 是纯编解码,submit/query/read 是可取消外部 I/O。 */
|
||||
export type PersistencePort = {
|
||||
/** Complete canonical read for a quiescent recovery session; never a legacy fallback. */
|
||||
/** 恢复协调所需的完整 canonical read;不能拿 legacy fallback 当目标协议基线。 */
|
||||
read?: (signal: AbortSignal) => Promise<CanonicalProposal>;
|
||||
/** 从原请求与回执提取历史执行证明,供 receipt 过期/旧模型协调使用。 */
|
||||
verifyApplied?: (request:FrozenRequest,receipt:Json) => AppliedModelProof;
|
||||
/** 判断是否有原冻结协议解释器,不支持时保留原 body/key 并阻塞,不能重编码换身份。 */
|
||||
supportsRequest?: (request: FrozenRequest) => boolean;
|
||||
/** 将稳定意图与当前基线编成待冻结请求;网络发送前仍需满足持久化屏障。 */
|
||||
compile(intent: OperationIntent, requestId: Id, snapshot: EngineSnapshot): CompiledRequest;
|
||||
/** Compile an ordered mutable batch; logical IDs and original intents remain independent. */
|
||||
/** 可把有序未冻结意图合并为一份 wire 请求,原 logical IDs 与意图事实仍独立保留。 */
|
||||
compileBatch?: (intents: readonly OperationIntent[], requestId: Id, snapshot: EngineSnapshot) => CompiledRequest;
|
||||
/** 发送原 FrozenRequest,保持已冻结字节/key;中断或丢响应不等于服务端没执行。 */
|
||||
submit(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
/** 查询原请求的执行状态;unknown/404 的含义由协议结果处理,不能擅自当 not-applied。 */
|
||||
query(request: FrozenRequest, signal: AbortSignal): Promise<OperationStatus>;
|
||||
/** Optional pure codec for a joint resolved publication. Rejection/failure retains the raw recovery input. */
|
||||
/** 可选精简回执 codec,仅在联合已协调发布时采用;失败应保留原恢复输入。 */
|
||||
compactResolvedReceipt?: (request:FrozenRequest,receipt:Json,applied:CanonicalProposal)=>Json;
|
||||
/** Pure codec, merging only participants into the latest base, never an old working snapshot. */
|
||||
/** 将回执参与者合并进最新已确认基线,不能拿旧 working 快照替换新本地编辑。 */
|
||||
canonical(request: FrozenRequest, status: OperationStatus, snapshot: EngineSnapshot): CanonicalProposal;
|
||||
};
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
/**
|
||||
* 跨文档写入、上传和 run 读取共享的并发闸门。
|
||||
* 等待项按 FIFO 排队,AbortSignal 取消等待;运行中的网络调用仍由 callback 自己响应 signal。
|
||||
* 闸门只控制容量,不保存 operation/request 身份,也不能代替 ledger 的依赖和结果协调。
|
||||
*/
|
||||
import { fault } from '../canvas-core/values.ts';
|
||||
import { createDeque } from './structures.ts';
|
||||
|
||||
/** Shared bounded transport capacity for writes, uploads and run reads. */
|
||||
/** 建立正整数容量的 gate,beforeRun 在实际执行 callback 前检查宿主身份等条件。 */
|
||||
export function createNetworkGate(limit = 2, beforeRun?:()=>void) {
|
||||
if(!Number.isSafeInteger(limit)||limit<1)throw fault('invalid_concurrency');
|
||||
let active = 0;
|
||||
const waiting = createDeque<{ signal: AbortSignal; grant(): void; cancelled: boolean }>();
|
||||
/** 有空位直接占用,否则等待 grant;已取消的等待项标记保留,释放时惰性跳过。 */
|
||||
async function enter(signal: AbortSignal) {
|
||||
signal.throwIfAborted();
|
||||
if (active < limit) { active++; return; }
|
||||
await new Promise<void>((resolve,reject) => {
|
||||
// grant 解除 abort 监听再唤醒;被唤醒后 run 还会再次检查 signal,避免取消竞态执行请求。
|
||||
const item = { signal, cancelled: false, grant() { signal.removeEventListener('abort',abort);resolve(); } };
|
||||
/** 取消只拒绝此等待者,不占用/释放活跃名额;队列节点由 release 跳过。 */
|
||||
const abort = () => { item.cancelled=true;signal.removeEventListener('abort',abort);reject(signal.reason); };
|
||||
signal.addEventListener('abort',abort,{once:true});waiting.push(item);
|
||||
});
|
||||
}
|
||||
/** 优先把原名额直接交给下一有效等待者;无人等待时才 active--,避免超发容量。 */
|
||||
function release() {
|
||||
let item=waiting.pop();while(item?.cancelled)item=waiting.pop();
|
||||
if(item)item.grant();else active--;
|
||||
}
|
||||
/** 统一入口:enter 后即使 signal/beforeRun/callback 抛错,finally 也会归还名额。 */
|
||||
return Object.freeze({ async run<T>(signal: AbortSignal, callback: () => Promise<T>): Promise<T> {
|
||||
await enter(signal);
|
||||
try { signal.throwIfAborted();beforeRun?.();return await callback(); } finally { release(); }
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/**
|
||||
* 流式 canonical 捕获的范围证据校验:每个 canvas 的直属成员数量、全部活元素 ID→owner 和拥有树。
|
||||
* 这些头部证据用于判断某 owner 是否完整,不能替代尾部 payload/schema/端点的完整文档校验。
|
||||
*/
|
||||
import type {Id} from '../canvas-core/types.ts';
|
||||
import {fault} from '../canvas-core/values.ts';
|
||||
|
||||
/** Snapshot-bound direct membership counts, including nodes and connections. */
|
||||
/** 当前捕获快照的直属数量,包含节点和边;不是递归子树大小或 DOM 已挂载数量。 */
|
||||
export type CanvasElementCounts=Readonly<Record<Id,number>>;
|
||||
/** 要求 counts 精确覆盖 canvasIds、每项非负安全整数,且总和等于声明 total。 */
|
||||
export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[],total:number):asserts value is CanvasElementCounts {
|
||||
if(!value||typeof value!=='object'||Array.isArray(value))throw fault('invalid_model_coverage');
|
||||
const counts=value as Record<Id,unknown>,keys=Object.keys(counts);let sum=0;
|
||||
@@ -12,18 +18,24 @@ export function validateCanvasElementCounts(value:unknown,canvasIds:readonly Id[
|
||||
}
|
||||
|
||||
/** All active IDs in the capture, including records not yet received. */
|
||||
/** 完整活 ID 命名空间,即使部分记录尚未传到客户端,也预先知道其直接 owner。 */
|
||||
export type ElementOwners=Readonly<Record<Id,Id>>;
|
||||
/** 核验每个活 ID 的 owner 合法,并按 owner 反算数量与 counts 一致,不靠已读前缀猜完整性。 */
|
||||
export function validateElementOwners(value:unknown,counts:CanvasElementCounts,total:number):asserts value is ElementOwners {
|
||||
if(!value||typeof value!=='object'||Array.isArray(value)||Object.keys(value).length!==total)throw fault('invalid_model_identity');
|
||||
// 无原型计数表避免特殊 owner 键污染计数;空 canvas 由缺省 0 与 counts 比较。
|
||||
const actual:Record<Id,number>=Object.create(null);
|
||||
for(const [id,owner]of Object.entries(value)){if(!id||typeof owner!=='string'||!Object.hasOwn(counts,owner))throw fault('invalid_model_identity');actual[owner]=(actual[owner]??0)+1;}
|
||||
if(Object.keys(counts).some(id=>(actual[id]??0)!==counts[id]))throw fault('invalid_model_identity');
|
||||
}
|
||||
|
||||
/** Full captured topology, independent of which entry payloads have arrived. */
|
||||
/** 用完整 topology+owner 证明验证单根、父存在、入口唯一/归属正确和无环。
|
||||
* 此时入口记录可能尚未收到,入口是否确为 node 仍需 payload 到达后的文档校验。 */
|
||||
export function validateCanvasTopology(canvases:readonly import('../canvas-core/types.ts').CanvasRecord[],owners:ElementOwners,projectId:Id):void {
|
||||
if(!Array.isArray(canvases)||!canvases.length)throw fault('invalid_model_topology');
|
||||
const table=new Map(canvases.map(canvas=>[canvas?.id,canvas]));if(table.size!==canvases.length||!table.has(projectId))throw fault('invalid_model_topology');
|
||||
// 每个入口最多绑定一个子 canvas;逐条父链用 seen 检测环,根 ID 必须等于 projectId。
|
||||
let roots=0;const entries=new Set<Id>();
|
||||
for(const canvas of canvases){if(!canvas||typeof canvas.id!=='string'||!canvas.id||!Object.hasOwn(canvas,'parent'))throw fault('invalid_model_topology');if(canvas.parent===null){roots++;if(canvas.id!==projectId)throw fault('invalid_model_topology');continue;}
|
||||
const parent=canvas.parent;if(!parent||!table.has(parent.canvasId)||typeof parent.entryElementId!=='string'||!parent.entryElementId||entries.has(parent.entryElementId)||owners[parent.entryElementId]!==parent.canvasId)throw fault('invalid_model_topology');entries.add(parent.entryElementId);
|
||||
|
||||
@@ -1,19 +1,39 @@
|
||||
/**
|
||||
* 便携持久化端口:定义 scope、leader/fence、恢复图像与原子 DurableBatch。
|
||||
* 这里不导入 IndexedDB/Web Locks 实现,Node/测试宿主可以提供其它 adapter。
|
||||
* 成功 commit 才能确认 durable,内存发布或排队成功不能替代它。
|
||||
*/
|
||||
import type { Json } from '../canvas-core/types.ts';
|
||||
|
||||
/** Portable durable-store contract. Browser database types belong to the adapter. */
|
||||
/** 账号/工作区/项目共同隔离持久事实;同 projectId 不得串用其它账号的存储。 */
|
||||
export type StorageScope = { readonly accountId: string; readonly workspaceId: string; readonly projectId: string };
|
||||
/** 可恢复单 Root 图像;formatVersion 是解释器版本,rootRevision 与文档 localRevision 分开。 */
|
||||
export type RecoveryImage = { readonly formatVersion: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17; readonly rootRevision: number; readonly localRevision: number; readonly payload: Json };
|
||||
/** 同一提交携带恢复图像、文件写入/删除及可选共享 depot cut,adapter 保证合同中的原子性。 */
|
||||
export type DurableBatch = { readonly image: RecoveryImage; readonly deleteFiles?:readonly string[]; readonly depot?:import('./asset-depot.ts').DepotCut; readonly files?: readonly { readonly id: string; readonly bytes: Blob }[] };
|
||||
/** 排他写者句柄;fence 区分代次,active 检查加实际存储校验阻止旧 writer 在新 leader 后继续写。 */
|
||||
export type StorageLeader = { readonly scope: StorageScope; readonly fence: number; readonly active: boolean; release(): Promise<void> };
|
||||
/** 文件盘点只返回元信息,不为扫描加载全部 Blob 字节。 */
|
||||
export type StoredFileInfo={readonly id:string;readonly byteLength:number;readonly mimeType:string};
|
||||
/** 分页盘点绑定 rootRevision,nextAfter 是后续页游标,不能跨变更拼接成完整清理依据。 */
|
||||
export type FileInventoryPage={readonly files:readonly StoredFileInfo[];readonly rootRevision:number;readonly nextAfter?:string};
|
||||
/** 宿主实现的存储能力;可选字段必须显式探测,缺能力不能假装已完成扫描或原子删除。 */
|
||||
export type StoragePort = {
|
||||
/** 可选工作区共享资源仓库,文件/引用保留通过 depot 合同处理。 */
|
||||
readonly assetDepot?:import('./asset-depot.ts').AssetDepotPort;
|
||||
/** 在 expectedRootRevision 下按稳定游标读取文件页,revision 不匹配应拒绝。 */
|
||||
scanFiles?(leader:StorageLeader,input:{readonly expectedRootRevision:number;readonly after?:string;readonly limit:number}):Promise<FileInventoryPage>;
|
||||
/** atomic-v1 明确声明支持与 Root 一起提交文件删除,不能用普通异步 delete 冒充。 */
|
||||
readonly fileDeletion?: 'atomic-v1';
|
||||
/** 取得作用域 leader,已有有效写者时拒绝,而不是悄悄允许两个 writer。 */
|
||||
claim(scope: StorageScope): Promise<StorageLeader>;
|
||||
/** 在有效 leader 下恢复最后可靠图像;null 表示没有已提交快照。 */
|
||||
recover(leader: StorageLeader): Promise<RecoveryImage | null>;
|
||||
/** 原子提交 DurableBatch 并返回真实完成的 rootRevision,不能提前在请求入队时 resolve。 */
|
||||
commit(leader: StorageLeader, batch: DurableBatch): Promise<{ readonly durableRootRevision: number }>;
|
||||
/** 读取指定文件的 Blob,缺失用 undefined;不从文档资源引用猜字节已经存在。 */
|
||||
readFile(leader: StorageLeader, id: string): Promise<Blob | undefined>;
|
||||
/** 关闭 adapter 的本地句柄,leader 的异步释放仍通过其 release 合同完成。 */
|
||||
close(): void;
|
||||
};
|
||||
|
||||
@@ -1,31 +1,51 @@
|
||||
/**
|
||||
* runtime 使用的基础调度容器,不包含任务状态事实:FIFO 存可执行顺序,min-heap 存优先项。
|
||||
* 两者都是内存结构,ledger/checkpoint 才负责可恢复事实;容器本身不去重、不校验依赖或执行网络。
|
||||
*/
|
||||
/** FIFO with amortized O(1) removal, without Array.shift on each item. */
|
||||
/** 当前接口是先进先出队列:push 入尾、pop 出头,并非 history 的后进先出 stack。
|
||||
* head 游标避免每次 Array.shift 搬动所有元素,周期压缩使出队摊销 O(1)。 */
|
||||
export function createDeque<T>() {
|
||||
// 已出队区间保留到下一次压缩/clear;有效长度为 items.length-head。
|
||||
let items: T[] = [], head = 0;
|
||||
return {
|
||||
/** 有效未消费数量,不包含 head 前已取出的槽位。 */
|
||||
get size() { return items.length - head; },
|
||||
/** 追加队尾,不处理重复值;queued Set 等去重策略由上层维护。 */
|
||||
push(value: T) { items.push(value); },
|
||||
/** 查看最早未消费项,不移动 head。 */
|
||||
peek(): T | undefined { return items[head]; },
|
||||
/** 取出队头;空队列返回 undefined,已消费量较大且过半时压缩数组。 */
|
||||
pop(): T | undefined {
|
||||
if (head === items.length) return undefined;
|
||||
const value = items[head++];
|
||||
if (head > 1024 && head * 2 >= items.length) { items = items.slice(head); head = 0; }
|
||||
return value;
|
||||
},
|
||||
/** 同时丢弃底层数组和游标,释放旧任务引用。 */
|
||||
clear() { items = []; head = 0; },
|
||||
/** 返回有效区间的数组副本,外部改副本不会更改队列成员。 */
|
||||
values() { return items.slice(head); },
|
||||
};
|
||||
}
|
||||
/** 数组实现二叉最小堆,compare<0 表示左项优先;peek O(1),push/pop O(log n)。
|
||||
* 相等优先级不保证插入稳定性,上层若需要 FIFO tie-break 必须把序列写进 comparator。 */
|
||||
export function createMinHeap<T>(compare: (left: T, right: T) => number) {
|
||||
// 父下标 (i-1)>>1,左右孩子 2i+1/2i+2;堆序只要求父不大于孩子,不是全数组排序。
|
||||
const items: T[] = [];
|
||||
return {
|
||||
/** 当前堆条目数,包含上层可能尚未清理的过期条目。 */
|
||||
get size() { return items.length; },
|
||||
/** 查看最优项,不移除;任务是否过期仍由调度器校验。 */
|
||||
peek(): T | undefined { return items[0]; },
|
||||
/** 新值从末端向上筛选,把较大的祖先下移到空位,最后写入新值。 */
|
||||
push(value: T) {
|
||||
let index = items.length; items.push(value);
|
||||
while (index > 0) { const parent = (index - 1) >> 1; if (compare(items[parent], value) <= 0) break; items[index] = items[parent]; index = parent; }
|
||||
items[index] = value;
|
||||
},
|
||||
pop(): T | undefined {
|
||||
// 出堆:取根并移走末项,再将末项沿较小孩子向下筛选,恢复父≤孩子的不变量。
|
||||
if (!items.length) return undefined;
|
||||
const first = items[0], last = items.pop()!;
|
||||
if (items.length) {
|
||||
@@ -40,6 +60,7 @@ export function createMinHeap<T>(compare: (left: T, right: T) => number) {
|
||||
}
|
||||
return first;
|
||||
},
|
||||
/** 清空全部条目;不会替任务执行取消或修改 ledger 状态。 */
|
||||
clear() { items.length = 0; },
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user