课程进度 课程大纲 已发布 18/18 课
第一部分:落笔之前先选画布——产品、像素与坐标
第二部分:给像素世界装上大脑——模型、调度、输入与工具
第三部分:从“能拖动”到“值得信赖”——交互、文字、资产与恢复
第四部分:大师级决策——性能、Worker、GPU、SDK、协作与 AI
从一个五岁孩子也能理解的游戏开始
本章唯一的真理:无论是人还是 AI,都只能通过经过验证、可追踪、可逆的操作来修改共享文档。
三个孩子一起写一本故事书。“故事正文”放在共享书架上。每个孩子的书包里装着“我现在在哪一页,以及我选择了哪支笔”。此刻举起的一面彩旗表示“我的手指在哪里,以及我正在看哪一页”;孩子离开教室后,这面旗就会消失。先预测一下:如果旗帜的位置被永久印在故事书里,明天会发生什么?书中将充满已经失效的动作痕迹。再预测一下,如果所有人都同步自己的书包,会发生什么?当 A 翻页时,A 也会夺走 B 的视野。
老师可以选择两种协作书写方式。第一种方式是,每张变更单都交给老师,由老师检查姓名、权限和页码,盖上连续编号的印章,然后广播出去。老师的版本是权威版本。第二种方式是,每个孩子都使用能够合并并发笔画的特殊透明纸。交换变更后,所有人都应用相同的合并规则;这类似于 CRDT。但无论哪种纸,都不会自动强制执行“孩子不能擦除校长的签名”这条规则。仍然需要权限和领域验证。
AI 是第四个孩子。它只能收到八张获准使用的形状积木卡,不能把整本书带走。它会先排列出一个预览,并列出将受影响的积木。如果需要批准,它就等待人工批准。完成后,它会留下来源记录和一张撤销卡。使用相同编号重新发送请求时,只会执行一次。
- 共享故事持久化文档
- 你自己的书包本地会话
- 此刻的旗帜临时在线状态
- AI 只能获得获准使用的积木类型化操作
- 盖章前预览验证、撤销与审计
把玩具映射到 Canvas
| 玩具故事书 | Canvas 实验室 | 生命周期与规则 |
|---|---|---|
| 共享正文 | 持久化文档 | 形状、绑定、评论和资产引用;持久化并迁移 |
| 你自己的书包 | 本地会话 | 相机、当前页面、工具和本地选择;除非产品主动选择,否则不广播 |
| 此刻的旗帜 | 临时在线状态 | 光标、共享选择、视口和在线状态;设有 TTL 且不持久化 |
| 孩子的姓名卡 | 身份 | 由身份验证系统签发;绝不信任客户端的自我声明 |
| 老师的印章 | 权威服务器 | 验证、排序、冲突处理和广播 |
| 可合并的透明纸 | CRDT 共享类型 | 并发更新按照 CRDT 规则合并 |
| 交换纸张的信使 | 网络提供程序 | 传输 CRDT 更新;不决定业务权限 |
| 装离线变更单的袋子 | 离线队列 | 保留顺序、版本和幂等键,以便重新连接后重放 |
| 定期装订 | 快照/压缩 | 截断可安全归档的操作日志 |
| AI 积木卡 | 类型化 AI 操作 | 有限联合类型;不允许任意代码或任意补丁 |
| 桌面上的试排 | 预览 | 不改变权威修订版本;可以过期或取消 |
| 撤销卡 | 撤销事务 | 只逆转本次已提交的操作,绝不逆转远程历史 |
| 借用积木时的签名 | 来源/审计 | 模型、工具版本、批准人、受影响的 ID 和结果 |
类比到此为止:CRDT 并不只是层层叠放的透明纸。它依赖唯一客户端/时钟、因果信息、数据类型和提供程序。收敛并不保证业务语义正确。在线状态广播和 TTL 由提供程序/服务器实现,并不保证“绝不写入磁盘”;团队必须在存储层明确排除这些数据。AI 截图属于敏感上下文。称其为“查看桌面”并不能免除权限、最小化和保留策略要求。
先破除错误直觉
- “最后写入者胜出最简单。” 它会整体覆盖其他用户的独立更改,对离线客户端尤其危险。至少要使用修订版本/序列和冲突策略。
- “CRDT 没有冲突。” CRDT 可以让副本收敛,但当 A 删除对象而 B 编辑对象时,产品仍必须定义最终结果。权限、引用、资产和 Schema 仍可能验证失败。
- “tldraw sync 就是 Yjs。” 第 17 章已经验证,tldraw sync 使用服务器权威协调机制;官方项目明确说明它不是 Yjs/CRDT。
- “在线状态也是一条记录,所以把它放进快照。” 光标和在线状态会迅速过期。持久化会泄露行为信息、造成数据膨胀,并恢复出幽灵用户。
- “乐观更新只要成功显示出来,就已经提交。” 客户端必须区分待确认状态与已确认状态。如果服务器拒绝更新,应进行协调,而不是默默保留它。
- “AI 返回了 JSON,所以成功的
JSON.parse意味着我们可以执行它。” 仍然缺少 Schema、语义验证、权限、批准、幂等性、速率限制和原子事务。 - “把整个 Store 交给 AI,能让它最聪明。” 最小化上下文更安全、更稳定:相关的结构化形状、选择、范围受限的交互摘要,以及经过权限裁剪的截图。
- “如果 AI 断开连接,就从头开始。” 如果服务器已经提交,重新运行会重复执行删除/移动。应以原子方式在结果表中登记操作 ID 和幂等键。
- “撤销就是恢复旧快照。” 这会抹除其他用户后续进行的更改。本地撤销只逆转本地来源/事务,并根据当前状态重新验证。
生产环境工具包
前置条件
第 5 章将文档与渲染器解耦。第 13 章提供版本、迁移、事务、崩溃恢复和三种作用域。第 17 章定义自建/购买/混合方案,并且只把 tldraw 5.3.2 Store 视为一个投影。协作协议和 AI 操作都只能调用同一个领域命令边界;两者都不得绕过历史记录、权限或审计。
形式化知识
首先分离三类数据
| 作用域 | 内容 | 持久化 | 网络 | 典型所有者 |
|---|---|---|---|---|
| 文档 | 页面、形状、绑定、评论和资产 ID | 是,带版本控制 | 可靠同步 | 房间/权威存储 |
| 会话 | 当前页面、相机、工具、私有选择和面板状态 | 可选择仅存于本地 | 默认不发送 | 用户+设备 |
| 在线状态 | 光标、共享选择、视口和状态 | 否;使用 TTL | 尽力而为、高频 | 当前连接 |
身份来自身份验证令牌/会话。权限分为房间、记录以及操作/字段级别。资产同步不会把 Blob 放入操作日志。首先获取上传意图;执行上传、验证和扫描;接收不可变的资产 ID;然后在文档事务中引用它。处于待处理/缺失状态的文档引用应提供占位符和恢复路径。
模式一:服务器权威同步
流程为 Client Diff → Authoritative Server → Reconciliation → Broadcast。客户端发送 {operationId, baseRevision, schemaVersion, protocolVersion, commands}。服务器验证身份,并检查房间生命周期、协议/Schema、权限、命令 Schema、引用和领域不变量。在一个数据库事务中,它写入操作日志、文档修订版本和审计记录,分配单调递增的序列号,返回确认并广播。客户端保留一个已确认的基础状态以及待确认的乐观操作。权威结果到达后,它会重放仍处于待确认状态的本地操作。
重新连接从版本握手开始,然后使用 lastSequence 请求增量更新。如果日志已被压缩,或者版本差距过大,则下载快照,然后重放离线队列。每个 operationId 都具有幂等性,因此重新传输队列中的项目不会执行两次。冲突可以被拒绝、按字段变基,或通过应用程序专用规则处理。“两个用户同时移动”可以选择最后一个排序后的移动、合并相对增量,或显示明确的冲突,但协议必须说明采用哪一种方式。服务器验证很容易集中实现。代价是服务器/房间会成为排序和可用性的中心,而且团队必须自行设计离线合并规则。
Room 生命周期包括创建、预热/加载、活跃、空闲刷盘、归档以及删除/恢复。每个 Room 都需要一个逻辑串行化点。它可以是单个 Actor/Partition,也可以是条件数据库写入和事务;并不要求只有一台物理写入机器。Operation Log 支持重放和 Audit,但在它无限增长之前,需要执行 Snapshot + Compaction。服务器无法等待“未来可能重新上线的所有离线客户端”逐一确认,因此协议会声明一个截止点。如果客户端的 lastSequence 早于该截止点,它将不再接收旧日志;而是下载当前 Snapshot,然后重放仍然兼容的本地队列。Compliance Audit 拥有独立的保留策略,不能随同步日志压缩而消失。Schema Version 描述 Document;Protocol Version 描述消息。一个编号不能同时代表二者。
模式二:Yjs / CRDT
其流程是 Concurrent Shared Types → CRDT Merge → Network Provider → Optional Offline Storage。Y.Doc 中的 Y.Map/Y.Array/Y.Text 会发出携带因果信息的更新。无论以何种顺序接收这些更新,包括收到重复更新,系统都会按照 CRDT 规则将其整合并最终收敛。Provider 通过 WebSocket/WebRTC 或其他通道传输更新。y-indexeddb 可以持久化本地离线状态。Awareness 是一套独立的临时协议,用于 Cursor、Selection 和用户状态。它不会写入 Y.Doc,也绝不能成为 Durable Document。
Yjs UndoManager 使用追踪的 Origin,仅跟踪本地事务,从而避免撤销远程用户的操作。CRDT 适合强离线操作、去中心化/多 Provider,或细粒度并发合并。然而,如果普通服务器无法查看或解释加密更新,就很难在每次字段变更前实施权威 Permission。可选方案包括:在 Provider/服务器层验证 Room 访问权限;先将更新应用到服务器端 Y.Doc 的候选副本,再检查不变量;对 Shared Type 进行分区;或者设计受控的 Command Gateway。绝不要先把不可信更新写入权威 Y.Doc,再指望之后通过可靠的“撤销”拒绝它。每种方案都要在延迟、乐观客户端协调和安全性之间进行权衡。CRDT 不会替你解决 Asset Blob、Schema Migration、审计语义或付费权限问题。
| 维度 | 服务器权威模式 | Yjs / CRDT |
|---|---|---|
| 排序/冲突 | 服务器序列和应用规则 | CRDT 数据类型的确定性合并 |
| 离线 | 自定义队列/变基/冲突处理 | 更新天然可在稍后合并;由 Provider 决定持久化方式 |
| 权限验证 | 提交前直接进行集中式验证 | 更新级/合并后验证更加困难,需要专门设计 |
| 中心依赖 | Room 的权威节点是中心化的 | 合并不需要中心节点;Provider 仍会影响可用性 |
| 撤销 | 反向执行本地 Operation/Command | UndoManager 跟踪本地 Origin |
| 在线状态 | 独立的临时通道 | Awareness 协议,仍与 Y.Doc 分离 |
| 审计 | Domain Operation 易于解释 | 原始更新难以解释;需要语义审计 |
| 最适用场景 | 强业务规则、权威权限和可审计工作流 | 强离线支持、细粒度并发和 Shared Type |
两种模式都能构建编辑器,也可以组合使用,但它们的名称不能互换。Canvas Lab 为 Visual Operations 场景选择服务器权威模式,因为权限、AI 审批、审计和业务不变量具有更高优先级。Presence 使用独立的尽力而为通道。如果未来强离线文本成为核心需求,应考虑对选定字段使用 Y.Text,而不是一次性用 CRDT 替换整个 Domain。
AI 原生 Canvas 是一种受约束协议
AI 只能接收经过权限过滤的 Structured Shape Data、必要的 Screenshot(经过脱敏、限定区域且仅短期保留)、当前 Selection、受限长度的 Interaction History 摘要以及 Action Schema。仅允许执行以下 Action:CreateShape、UpdateShape、DeleteShape、MoveShape、ConnectShapes、AlignShapes、GroupShapes 和 AddComment。不要接受 JavaScript、SQL、任意 JSON Patch、URL 获取请求或未注册的 Shape 类型。
每个 Action Envelope 都包含 Schema/Version。验证涵盖结构、数值范围、引用、循环以及领域不变量。Permission 会在解析真实级联影响之后,对 Action、字段和 Affected ID 逐一进行检查。Preview 在不可提交的克隆上生成 before/after/diff/cost。Preview Token 必须绑定 actor + document + baseRevision + canonical action hash + expiry;否则,有人可以批准 A,却使用同一 Token 提交 B。Approval Policy 根据删除操作、数量和敏感对象选择 auto/confirm/deny。Transaction 确保整个 Batch 要么全部成功,要么全部失败。Undo 存储可根据当前状态进行验证的逆向 Patch;它绝不会恢复整个旧 Snapshot,从而抹除之后的远程工作。可信的模型网关会记录 Provenance——模型、工具版本、提示词哈希、请求者/操作者/审批者——而不是信任客户端声明。Cancellation 仅在提交前有效;提交后应使用 Undo。将 Idempotency Key 与请求哈希绑定,并在同一个数据库 Transaction 中通过唯一约束登记结果。按操作者/Room/Action/成本进行 Rate Limit。Audit Log 采用仅追加模式并经过脱敏。
证据与兼容性(已于 2026-08-29 验证)
tldraw sync 的 TLSocketRoom 会保留权威 Document 并协调差异;请参阅官方的 tldraw sync 和协作服务器架构。官方项目明确指出,tldraw sync 并不是 CRDT。关于并发 Shared Type、更新、Provider 和离线支持,请参阅 Yjs 简介;关于临时用户状态,请参阅 Awareness 与 Presence;关于本地撤销 Origin,请参阅 Y.UndoManager。不同 Provider 的加密、身份验证、持久化和扩展行为各不相同;请验证所选实现。仅仅“使用 Yjs”本身并不能证明权限或离线 UI 的正确性。
本章的工程增量
**起点:**第 17 章的 Hybrid SDK 可以在单台机器上编辑和导出。**终点:**服务器权威 Room 支持 Snapshot、Incremental Update、Sequence、Reconnect、Offline Queue、Optimistic Reconciliation、Presence 和 Asset 协议。AI 通过完整且受约束的 Action 流水线提交操作,该流水线包含 Preview/Approve/Cancel/Undo/Idempotent Retry/Audit。
添加以下文件和接口:
src/collab/protocol.ts:Schema/Protocol 版本以及 Operation/Ack/Resync;src/collab/AuthoritativeRoom.ts:服务器验证、Sequence、Log 和 Compaction;src/collab/OfflineQueue.ts:幂等 Reconnect;src/presence/PresenceHub.ts:TTL、容量和脱敏;src/ai/action-schema.ts:八种可辨识联合 Action;src/ai/ActionService.ts:Preview、Approval、Transaction、Undo、Provenance、Rate 和 Audit;tests/collab/races.test.ts和tests/ai/actions.test.ts:故障与安全回归测试。
首先,将 AI 可能执行的一切操作都封装进 Zod Schema。任何 unknown 都不得跨越 parse。字段采用允许列表,而不是让模型提交自由格式的 Shape Record:
import { z } from 'zod';
const ShapeId = z.string().regex(/^shape-[a-z0-9-]+$/);
const BindingId = z.string().regex(/^binding-[a-z0-9-]+$/);
const GroupId = z.string().regex(/^group-[a-z0-9-]+$/);
const CommentId = z.string().regex(/^comment-[a-z0-9-]+$/);
const Point = z
.object({ x: z.number().finite().min(-1e7).max(1e7), y: z.number().finite().min(-1e7).max(1e7) })
.strict();
const ShapeKind = z.enum(['task', 'note', 'decision']);
const EditableFields = z
.object({
title: z.string().min(1).max(500).optional(),
status: z.enum(['todo', 'doing', 'done']).optional(),
color: z.enum(['gray', 'blue', 'green', 'red']).optional(),
})
.strict();
const ShapeIds = z
.array(ShapeId)
.min(2)
.max(100)
.refine((ids) => new Set(ids).size === ids.length, 'duplicate ids');
export const AiActionSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('CreateShape'),
id: ShapeId,
shapeKind: ShapeKind,
at: Point,
props: EditableFields.required({ title: true }),
})
.strict(),
z
.object({
type: z.literal('UpdateShape'),
id: ShapeId,
patch: EditableFields.refine((v) => Object.keys(v).length > 0, 'empty patch'),
})
.strict(),
z.object({ type: z.literal('DeleteShape'), id: ShapeId }).strict(),
z.object({ type: z.literal('MoveShape'), id: ShapeId, to: Point }).strict(),
z
.object({
type: z.literal('ConnectShapes'),
id: BindingId,
fromId: ShapeId,
toId: ShapeId,
relation: z.enum(['blocks', 'relates']),
})
.strict(),
z
.object({
type: z.literal('AlignShapes'),
ids: ShapeIds,
axis: z.enum(['left', 'center-x', 'top', 'center-y']),
})
.strict(),
z.object({ type: z.literal('GroupShapes'), id: GroupId, ids: ShapeIds }).strict(),
z
.object({
type: z.literal('AddComment'),
id: CommentId,
shapeId: ShapeId,
text: z.string().min(1).max(2_000),
})
.strict(),
]);
export type AiAction = z.infer<typeof AiActionSchema>;
export const AiEnvelopeSchema = z
.object({
protocolVersion: z.literal(1),
schemaVersion: z.literal(3),
documentId: z.string().regex(/^doc-[a-z0-9-]+$/),
actionId: z.string().uuid(),
idempotencyKey: z.string().uuid(),
baseRevision: z.number().int().nonnegative(),
approvalToken: z.string().uuid().optional(),
actions: z.array(AiActionSchema).min(1).max(100),
})
.strict();
export type AiEnvelope = z.infer<typeof AiEnvelopeSchema>;
ID 前缀也是 Schema 的一部分。如果共享一个宽泛的 Id,DeleteShape 就会接受 comment-*,直到执行深处才失败。ShapeIds 还会拒绝重复项,防止 [shape-a, shape-a] 在实际只操作一个对象时满足“至少两个”的要求。provenance 不会放入不可信的 Envelope。经过身份验证的模型网关会将其作为 TrustedInvocation 单独传递。
下面的执行器将“先解析,再直接修改 Store”转变为安全的流水线。真实数据库必须为 Repository.transaction 实现原子性。Preview Token 会绑定 Action 哈希、Revision、Actor 和过期时间,因此一个人的批准无法被重复用于其他请求:
type Shape = {
id: string;
kind: 'task' | 'note' | 'decision';
x: number;
y: number;
w: number;
h: number;
title: string;
status?: string;
color?: string;
locked?: boolean;
};
type Binding = { id: string; fromId: string; toId: string; relation: 'blocks' | 'relates' };
type Comment = { id: string; shapeId: string; text: string; authorId: string };
type Group = { id: string; ids: string[] };
type Document = {
id: string;
revision: number;
shapes: Map<string, Shape>;
bindings: Map<string, Binding>;
comments: Map<string, Comment>;
groups: Map<string, Group>;
};
type Preview = {
token: string;
actorId: string;
documentId: string;
baseRevision: number;
requestHash: string;
affectedIds: string[];
before: Document;
after: Document;
requiresApproval: boolean;
expiresAt: number;
};
type Result = {
actionId: string;
actorId: string;
documentId: string;
requestHash: string;
revision: number;
affectedIds: string[];
undoId: string;
};
type User = {
id: string;
canComment: boolean;
editableRecordIds: Set<string>;
canCreate: boolean;
editableFields: Set<'title' | 'status' | 'color'>;
};
type TrustedInvocation = {
actorId: string;
model: string;
toolVersion: string;
promptHash: string;
};
const cloneDoc = (d: Document): Document => ({
...d,
shapes: new Map([...d.shapes].map(([k, v]) => [k, { ...v }])),
bindings: new Map([...d.bindings].map(([k, v]) => [k, { ...v }])),
comments: new Map([...d.comments].map(([k, v]) => [k, { ...v }])),
groups: new Map([...d.groups].map(([k, v]) => [k, { ...v, ids: [...v.ids] }])),
});
function idsOf(a: AiAction): string[] {
switch (a.type) {
case 'CreateShape':
case 'UpdateShape':
case 'DeleteShape':
case 'MoveShape':
return [a.id];
case 'ConnectShapes':
return [a.id, a.fromId, a.toId];
case 'AlignShapes':
return [...a.ids];
case 'GroupShapes':
return [a.id, ...a.ids];
case 'AddComment':
return [a.id, a.shapeId];
}
}
function requireShape(doc: Document, id: string) {
const shape = doc.shapes.get(id);
if (!shape) throw new Error(`SHAPE_NOT_FOUND:${id}`);
return shape;
}
function applyAction(doc: Document, a: AiAction, actorId: string): string[] {
switch (a.type) {
case 'CreateShape':
if (doc.shapes.has(a.id)) throw new Error('ID_EXISTS');
doc.shapes.set(a.id, {
id: a.id,
kind: a.shapeKind,
x: a.at.x,
y: a.at.y,
w: 240,
h: 112,
...a.props,
});
return [a.id];
case 'UpdateShape': {
const s = requireShape(doc, a.id);
doc.shapes.set(a.id, { ...s, ...a.patch });
return [a.id];
}
case 'DeleteShape': {
requireShape(doc, a.id);
const affected = [a.id];
doc.shapes.delete(a.id);
for (const [id, b] of doc.bindings)
if (b.fromId === a.id || b.toId === a.id) {
doc.bindings.delete(id);
affected.push(id);
}
for (const [id, c] of doc.comments)
if (c.shapeId === a.id) {
doc.comments.delete(id);
affected.push(id);
}
for (const [id, g] of doc.groups)
if (g.ids.includes(a.id)) {
const ids = g.ids.filter((shapeId) => shapeId !== a.id);
if (ids.length < 2) doc.groups.delete(id);
else doc.groups.set(id, { ...g, ids });
affected.push(id);
}
return affected;
}
case 'MoveShape': {
const s = requireShape(doc, a.id);
doc.shapes.set(a.id, { ...s, x: a.to.x, y: a.to.y });
return [a.id];
}
case 'ConnectShapes':
requireShape(doc, a.fromId);
requireShape(doc, a.toId);
if (a.fromId === a.toId) throw new Error('SELF_BINDING');
if (doc.bindings.has(a.id)) throw new Error('ID_EXISTS');
doc.bindings.set(a.id, { id: a.id, fromId: a.fromId, toId: a.toId, relation: a.relation });
return [a.id, a.fromId, a.toId];
case 'AlignShapes': {
const shapes = a.ids.map((id) => requireShape(doc, id));
const value =
a.axis === 'left'
? Math.min(...shapes.map((s) => s.x))
: a.axis === 'center-x'
? shapes.reduce((n, s) => n + s.x + s.w / 2, 0) / shapes.length
: a.axis === 'top'
? Math.min(...shapes.map((s) => s.y))
: shapes.reduce((n, s) => n + s.y + s.h / 2, 0) / shapes.length;
for (const s of shapes)
doc.shapes.set(s.id, {
...s,
...(a.axis === 'left'
? { x: value }
: a.axis === 'center-x'
? { x: value - s.w / 2 }
: a.axis === 'top'
? { y: value }
: { y: value - s.h / 2 }),
});
return [...a.ids];
}
case 'GroupShapes':
if (doc.groups.has(a.id)) throw new Error('ID_EXISTS');
for (const id of a.ids) requireShape(doc, id);
doc.groups.set(a.id, { id: a.id, ids: [...a.ids] });
return [a.id, ...a.ids];
case 'AddComment':
requireShape(doc, a.shapeId);
if (doc.comments.has(a.id)) throw new Error('ID_EXISTS');
doc.comments.set(a.id, { id: a.id, shapeId: a.shapeId, text: a.text, authorId: actorId });
return [a.id, a.shapeId];
}
}
function authorize(user: User, doc: Document, actions: AiAction[]) {
for (const a of actions) {
if (['CreateShape', 'ConnectShapes', 'GroupShapes'].includes(a.type) && !user.canCreate)
throw new Error('FORBIDDEN_CREATE');
if (a.type === 'AddComment' && !user.canComment) throw new Error('FORBIDDEN_COMMENT');
if (
a.type === 'UpdateShape' &&
Object.keys(a.patch).some((k) => !user.editableFields.has(k as 'title' | 'status' | 'color'))
)
throw new Error('FORBIDDEN_FIELD');
const existing = idsOf(a).filter(
(id) =>
doc.shapes.has(id) || doc.bindings.has(id) || doc.comments.has(id) || doc.groups.has(id),
);
if (a.type !== 'AddComment' && existing.some((id) => !user.editableRecordIds.has(id)))
throw new Error('FORBIDDEN_ID');
if (a.type === 'DeleteShape') {
const cascade = [...doc.bindings.values()]
.filter((b) => b.fromId === a.id || b.toId === a.id)
.map((b) => b.id)
.concat([...doc.comments.values()].filter((c) => c.shapeId === a.id).map((c) => c.id))
.concat([...doc.groups.values()].filter((g) => g.ids.includes(a.id)).map((g) => g.id));
if (cascade.some((id) => !user.editableRecordIds.has(id)))
throw new Error('FORBIDDEN_CASCADE_ID');
}
if (existing.some((id) => doc.shapes.get(id)?.locked)) throw new Error('LOCKED_ID');
}
}
type UndoPatch = {
id: string;
before: Shape | Binding | Comment | Group | null;
after: Shape | Binding | Comment | Group | null;
};
function record(doc: Document, id: string) {
return (
doc.shapes.get(id) ?? doc.bindings.get(id) ?? doc.comments.get(id) ?? doc.groups.get(id) ?? null
);
}
function inversePatches(before: Document, after: Document, ids: string[]): UndoPatch[] {
return [...new Set(ids)].map((id) => ({
id,
before: structuredClone(record(before, id)),
after: structuredClone(record(after, id)),
}));
}
function canonicalRequest(e: AiEnvelope, invocation: TrustedInvocation) {
return {
protocolVersion: e.protocolVersion,
schemaVersion: e.schemaVersion,
documentId: e.documentId,
actionId: e.actionId,
idempotencyKey: e.idempotencyKey,
baseRevision: e.baseRevision,
actions: e.actions,
invocation,
};
}
function assertSameRequest(result: Result, hash: string, e: AiEnvelope, user: User) {
if (
result.requestHash !== hash ||
result.actionId !== e.actionId ||
result.documentId !== e.documentId ||
result.actorId !== user.id
)
throw new Error('IDEMPOTENCY_KEY_REUSED_FOR_DIFFERENT_REQUEST');
return result;
}
interface Repository {
load(id: string): Promise<Document>;
findResult(idempotencyKey: string): Promise<Result | undefined>;
savePreview(actionId: string, preview: Preview, actorId: string): Promise<void>;
loadApprovedPreview(actionId: string, token: string, actorId: string): Promise<Preview>;
transaction<T>(
work: (tx: {
findResult(key: string): Promise<Result | undefined>;
saveDocument(doc: Document, expectedRevision: number): Promise<void>;
saveUndo(id: string, patches: UndoPatch[]): Promise<void>;
saveResult(key: string, result: Result): Promise<void>;
appendAudit(event: Record<string, unknown>): Promise<void>;
}) => Promise<T>,
): Promise<T>;
}
interface Gate {
rateLimit(actorId: string, roomId: string, cost: number): Promise<void>;
isCanceled(actionId: string): Promise<boolean>;
hashCanonical(value: unknown): Promise<string>;
randomId(): string;
now(): number;
}
export class AiActionService {
constructor(
private repo: Repository,
private gate: Gate,
) {}
async preview(raw: unknown, user: User, invocation: TrustedInvocation) {
const e = AiEnvelopeSchema.parse(raw);
await this.gate.rateLimit(user.id, e.documentId, e.actions.length);
if (invocation.actorId !== user.id) throw new Error('PROVENANCE_ACTOR_MISMATCH');
const requestHash = await this.gate.hashCanonical(canonicalRequest(e, invocation));
const doc = await this.repo.load(e.documentId);
if (doc.revision !== e.baseRevision) throw new Error('STALE_BASE_REVISION');
authorize(user, doc, e.actions);
const before = cloneDoc(doc),
after = cloneDoc(doc);
const affectedIds = [
...new Set(e.actions.flatMap((action) => applyAction(after, action, user.id))),
];
const requiresApproval =
e.actions.some((a) => a.type === 'DeleteShape') || affectedIds.length > 10;
const preview: Preview = {
token: this.gate.randomId(),
actorId: user.id,
documentId: e.documentId,
baseRevision: e.baseRevision,
requestHash,
affectedIds,
before,
after,
requiresApproval,
expiresAt: this.gate.now() + 5 * 60_000,
};
await this.repo.savePreview(e.actionId, preview, user.id);
return {
actionId: e.actionId,
token: preview.token,
affectedIds,
requiresApproval,
summary: e.actions.map((a) => a.type),
};
}
async commit(raw: unknown, user: User, invocation: TrustedInvocation): Promise<Result> {
const e = AiEnvelopeSchema.parse(raw);
if (invocation.actorId !== user.id) throw new Error('PROVENANCE_ACTOR_MISMATCH');
const requestHash = await this.gate.hashCanonical(canonicalRequest(e, invocation));
const old = await this.repo.findResult(e.idempotencyKey);
if (old) return assertSameRequest(old, requestHash, e, user);
await this.gate.rateLimit(user.id, e.documentId, e.actions.length);
if (await this.gate.isCanceled(e.actionId)) throw new Error('CANCELED');
if (!e.approvalToken) throw new Error('PREVIEW_REQUIRED');
const preview = await this.repo.loadApprovedPreview(e.actionId, e.approvalToken, user.id);
if (preview.expiresAt < this.gate.now()) throw new Error('PREVIEW_EXPIRED');
if (
preview.requestHash !== requestHash ||
preview.actorId !== user.id ||
preview.documentId !== e.documentId ||
preview.baseRevision !== e.baseRevision
)
throw new Error('PREVIEW_REQUEST_MISMATCH');
const current = await this.repo.load(e.documentId);
if (current.revision !== preview.baseRevision) throw new Error('PREVIEW_STALE');
authorize(user, current, e.actions);
if (await this.gate.isCanceled(e.actionId)) throw new Error('CANCELED');
const next = cloneDoc(preview.after);
next.revision = current.revision + 1;
const result: Result = {
actionId: e.actionId,
actorId: user.id,
documentId: e.documentId,
requestHash,
revision: next.revision,
affectedIds: preview.affectedIds,
undoId: this.gate.randomId(),
};
return this.repo.transaction(async (tx) => {
const duplicate = await tx.findResult(e.idempotencyKey);
if (duplicate) return assertSameRequest(duplicate, requestHash, e, user);
await tx.saveDocument(next, current.revision);
await tx.saveUndo(result.undoId, inversePatches(preview.before, next, preview.affectedIds));
await tx.saveResult(e.idempotencyKey, result);
await tx.appendAudit({
type: 'ai-action-committed',
actionId: e.actionId,
actorId: user.id,
model: invocation.model,
toolVersion: invocation.toolVersion,
promptHash: invocation.promptHash,
requestHash,
affectedIds: result.affectedIds,
revision: result.revision,
approved: true,
at: this.gate.now(),
});
return result;
});
}
}
saveResult、条件式 saveDocument(expectedRevision)、逆向 Patch 和 Audit 必须受到同一个数据库 Transaction 和幂等性唯一索引的保护。在事务内部查找可以提前返回,但不能取代唯一约束。Preview 可以短暂存储 before/after 克隆。真正的 Undo 只持久化受影响记录的 before/after,并针对当前权威状态进行检查/变基;它绝不会恢复整个旧 Document。失败的领域 Transaction 不会写入“已提交”的 Audit。禁止访问或触发速率限制等拒绝事件应写入访问限制更严格、同样经过脱敏的安全日志。如果 Cancel 在最后一次取消检查之后与提交发生竞态,则以最先成功的数据库操作为准。如果提交已经成功,应返回 Result 并提供 Undo,而不是错误地声称操作已取消。
如果选择 Yjs,Document 和 Awareness 仍然保持分离,并且本地 Undo 只跟踪其自身的 origin:
import * as Y from 'yjs';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const doc = new Y.Doc(),
shapes = doc.getMap<Y.Map<unknown>>('shapes'),
LOCAL = Symbol('local-user');
const network = new WebsocketProvider('wss://sync.example.test', 'doc-42', doc);
const offline = new IndexeddbPersistence('doc-42', doc);
network.awareness.setLocalStateField('cursor', { x: 120, y: 80 }); // Not written to doc
const undo = new Y.UndoManager(shapes, { trackedOrigins: new Set([LOCAL]) });
doc.transact(() => {
const shape = new Y.Map<unknown>();
shape.set('x', 10);
shape.set('y', 20);
shapes.set('shape-1', shape);
}, LOCAL);
await offline.whenSynced;
undo.undo();
回到故事书的比喻:Y.Map 是那张特殊的透明纸,而 Awareness 是旗子。把 Cursor 放入 doc.getMap('shapes'),就会把旗子再次印进永久保存的故事中。本地 origin 使撤销卡只撤销你自己的笔画。
测试必须证明系统能够正确处理重复、拒绝无权限操作、断开连接和原子性,而不只是“正常创建”:
import { expect, test } from 'vitest';
import { AiActionSchema } from '../action-schema';
test('Action schema rejects arbitrary fields and arbitrary code', () => {
expect(() =>
AiActionSchema.parse({ type: 'DeleteShape', id: 'shape-a', javascript: 'document.cookie' }),
).toThrow();
expect(() =>
AiActionSchema.parse({ type: 'MoveShape', id: 'shape-a', to: { x: Infinity, y: 0 } }),
).toThrow();
expect(() => AiActionSchema.parse({ type: 'DeleteShape', id: 'comment-a' })).toThrow();
expect(() =>
AiActionSchema.parse({ type: 'GroupShapes', id: 'group-a', ids: ['shape-a', 'shape-a'] }),
).toThrow();
});
test('retrying the same idempotency key returns only the same revision', async () => {
const first = await service.commit(approvedEnvelope, editorUser, trustedInvocation);
const retryAfterReplyWasLost = await service.commit(
approvedEnvelope,
editorUser,
trustedInvocation,
);
expect(retryAfterReplyWasLost).toEqual(first);
expect(repository.documentWritesFor(approvedEnvelope.idempotencyKey)).toBe(1);
await expect(
service.commit(
{ ...approvedEnvelope, actions: differentActions },
editorUser,
trustedInvocation,
),
).rejects.toThrow('IDEMPOTENCY_KEY_REUSED_FOR_DIFFERENT_REQUEST');
});
test('an unauthorized object fails the entire batch and leaves no partial action', async () => {
const before = await repository.load('doc-a');
await expect(
service.commit(envelopeMovingAllowedAndLockedShape, limitedUser, trustedInvocation),
).rejects.toThrow(/FORBIDDEN|LOCKED/);
expect(await repository.load('doc-a')).toEqual(before);
expect(repository.auditEventsFor(envelopeMovingAllowedAndLockedShape.actionId)).toHaveLength(0);
});
运行 npm exec vitest run tests/collab tests/ai。预期乱序、重复、版本不匹配、冲突、离线重放、Presence TTL,以及 AI Schema/权限/审批/取消/幂等性/原子回滚/审计测试全部通过。运行 npm exec playwright test tests/multiplayer-ai.spec.ts;预期双浏览器并发、断开/重新连接、AI Preview/审批/Undo,以及 Keyboard/Screen Reader 任务全部通过。运行 npm run capstone:verify;预期每项能力都有对应的证据路径,而不是只有一个汇总布尔值。
最终综合项目:可视化运营画布
交付成果必须同时包含以下全部内容:自定义业务 Shapes、Text DOM 编辑、Image/Secure Assets、Connector/Binding、Snapping、Keyboard、History、Persistence、Migrations、PNG/SVG/JSON/高分辨率 Export、Observability 和 Performance Budget。只有在第 16 章的 Benchmark 证明有必要后,才能启用 Worker/GPU。添加 Multiplayer、Offline Queue、Presence、AI Actions、Permission 和 Audit Log。每项“可选”技术都意味着“证据支持启用或不启用它”,而不是跳过这个决定。
故意破坏它
| 注入的故障 | 症状 | 证据 | 修复 | 回归测试 | 恢复 |
|---|---|---|---|---|---|
| A 删除时 B 正在编辑 | 对象复活,或 B 永远处于待处理状态 | seq/conflict/audit | 定义删除与更新冲突时的规则 | 双客户端屏障测试 | 协调处理/显示冲突 |
| 两位用户同时移动 | Shape 跳回或闪烁 | pending/confirmed trace | 顺序控制 + 产品合并策略 | Concurrent-Move fixture | 采用权威位置 + 重放待处理操作 |
| 离线编辑使用旧 Schema | 重新连接时解析失败 | Handshake versions | 在迁移后重放,或拒绝写入并设为只读 | v2 离线→v3 服务器 | 导出本地副本并升级 |
| Presence 被持久化 | 重新打开后出现幽灵 Cursor | Snapshot scope audit | 使用独立的 TTL Store | 重新加载/过期测试 | 删除错误的 Records |
| AI 执行两次 | Shape 移动两次或评论重复 | Idempotency unique/audit | 原子创建 Result Record | 响应丢失后的重试 | 返回第一次的 Result |
| AI 编辑无权访问的对象 | 敏感内容发生变化 | Denied audit/permission trace | 对每个受影响的 ID 进行授权检查 | 混合 Batch 测试 | 回滚整个 Transaction |
| AI 执行到一半时断开连接 | 只完成了一半 Shapes 的 Batch | Revision/transaction log | 原子提交 | 在每个步骤断开连接 | 使用同一密钥重试或 Undo |
| Asset 上传与 Document 冲突 | 引用永久缺失 | Asset/Document states | intent→scan→ready→reference | 完成顺序重排测试 | 使用占位符/重新绑定 ready ID |
| Server/Client Protocol 不一致 | 消息解析错误 | Handshake rejection | 明确的兼容窗口 | v1↔v2 矩阵 | 只读、刷新/回退 |
| 旧客户端在 Migration 后重新连接 | 旧客户端删除新字段 | Schema guard | 阻止旧客户端写入并协调发布 | 旧客户端重新连接 fixture | 保留队列、升级,然后重放 |
用证据通过验收
| 自动化证据 | 手动证据 | 通过条件 |
|---|---|---|
| 双客户端竞态、离线重连、幂等性、Protocol/Schema 偏差和 Presence TTL | 在两台真实设备上断开连接并恢复;演练冲突通知和 Local Undo | 副本最终一致;Session 不会相互侵占;Presence 会过期;Undo 不会擦除远程更改 |
| 八种 AI Actions 的 Schema,以及禁止访问、预览/审批、取消竞态、原子回滚和审计测试 | 检查删除 Preview、受影响的 ID、审批文本、Undo 和审计可读性 | AI 无法绕过有限的 Actions;重试/断开连接不会导致重复执行;审计不会泄露原始 Prompt |
capstone:verify 能力列表和性能预算 | Keyboard/Screen Reader、低端设备,以及崩溃/升级/降级恢复演练 | 所有 17 个掌握度问题都有当前代码、测试、Trace 或 ADR 证据 |
| 掌握度问题 | 必答内容 |
|---|---|
| 1. 为什么选择这个 Renderer? | 由产品语义、对象/变更量、无障碍能力和 Benchmark 共同决定;Document 不依赖它 |
| 2. 为什么 Document 不依赖 Renderer? | 它是持久的业务事实;Renderer 是可替换的投影 |
| 3. 如何保持坐标一致? | 使用一个 Camera 矩阵、明确的空间定义和往返属性测试 |
| 4. Hit Test 如何扩展? | 在 Geometry Narrow Phase 之前添加视口/空间 Broad Phase,同时保持结果等价 |
| 5. 为什么 Tool 是状态机? | 每个事件都有唯一的状态/Transition/Commit 边界,并且 Cancel 可以被验证 |
| 6. 为什么 Text 需要 DOM? | IME、Caret、Selection、Bidi、Clipboard 和 Screen Reader 都需要浏览器的文本系统 |
| 7. 如何分离三个作用域? | Document 持久且共享,Session 属于本地,而 Presence 是带 TTL 的临时广播 |
| 8. Undo 的边界在哪里? | 一次用户/AI 意图对应一个 Transaction,并且只撤销本地已提交的工作 |
| 9. 如何验证 Migration? | unknown→验证旧版本→纯函数式逐步迁移→验证当前版本,并配有 fixture/幂等性/故障测试 |
| 10. 如何支持 Keyboard/Screen Reader? | 使用 DOM Inspector、Focus Mapping、同源 Commands 和 Non-Drag Alternative |
| 11. 什么能够证明性能瓶颈? | 代表性设备上的 p50/p95/p99,以及 input/model/query/render/present Trace |
| 12. 是否需要 OffscreenCanvas? | 只有当减少主线程争用带来的收益超过传输/协议成本时才启用 |
| 13. 是否需要 GPU? | 只有当 Canvas 渲染/批处理原型证明其能在代表性设备上带来收益时才启用 |
| 14. 为什么自行构建或使用 SDK? | ADR 会比较产品、TCO、许可证、迁移和退出方案;本课程的技术验证选择 Hybrid |
| 15. 权威式方案与 CRDT 有何区别? | 前者由服务器排序/验证/协调;后者并发合并共享类型,并由 provider 负责传输 |
| 16. 为什么必须约束 AI 并让其操作可逆? | 它是不受信任的提议者;schema、permission、preview、transaction 和 undo 会限制其影响范围 |
| 17. 系统如何从断开连接/崩溃/升级/降级中恢复? | Snapshot+log+queue+idempotency、双槽位、版本握手、迁移和只读 Compatibility Renderer |
- 在 Schema、Store 和网络通道中明确分离持久的 Document、本地的 Session 和临时的 Presence。
- 解释 Server-Authoritative 与 Yjs/CRDT 在排序、离线、权限、Undo、Presence 和审计方面的差异,不要将它们混为一谈。
- 对 Snapshot、Incremental Update、Sequence、Reconnect、Offline Queue、Optimistic Reconcile 和 Compaction 进行测试。
- 全部八种 AI Actions 都有严格的 Schema、Validation、Permission 和受影响的 ID。
- 每次 AI 更改都从 Preview 开始并遵循审批策略;Transaction、Undo、Provenance、Cancellation、Idempotency、Rate Limit 和 Audit 均已完备。
- Asset 与 Document 的写入顺序有明确协议;Schema 和 Protocol 版本保持分离。
- 综合项目中的 Renderer、model、text、assets、migration、testing、performance、collaboration 和 AI 都有证据;一张截图不能代替完整交付。
向五岁孩子解释
不要说“CRDT”“Server-Authoritative”“Idempotency”“Transaction”或“Audit”。解释为什么共享故事、个人背包和当前旗子不能混在一起。老师盖章的方法与可合并的透明纸有什么不同?为什么 AI 只能使用获准的积木?为什么同一张纸条提交两次也只能执行一次?
一个不使用术语的好答案
每个人都需要长期看到故事里的文字。我正在阅读哪一页,只属于我的背包。朋友此刻指向哪里,应该在他们离开时消失。采用老师的方法时,每项更改都先经过检查和编号,而且所有人都信任老师盖过章的书。使用透明纸时,每个人都可以书写,之后再按照相同规则合并更改,但“谁可以擦掉什么”仍然需要单独的规则。AI 只能从八张积木卡中选择。它会先摆出一个示例并说明将触碰哪些内容,等待任何必需的批准,一次性完成全部更改,然后留下撤销卡和签名。即使断开连接导致同一张带编号的纸条被送达两次,也只有第一次的结果算数。