JEPA4Japan · 教程

第 18 章:人与 AI 编辑同一本账簿

8,669字 25分钟阅读 #Canvas#前端工程#无限画布#通俗讲解

比较服务器权威同步与 CRDT,再将 AI 限制在经过验证、可预览、可撤销、可审计的类型化操作内。

课程进度 课程大纲 已发布 18/18 课

从一个五岁孩子也能理解的游戏开始

本章唯一的真理:无论是人还是 AI,都只能通过经过验证、可追踪、可逆的操作来修改共享文档。

三个孩子一起写一本故事书。“故事正文”放在共享书架上。每个孩子的书包里装着“我现在在哪一页,以及我选择了哪支笔”。此刻举起的一面彩旗表示“我的手指在哪里,以及我正在看哪一页”;孩子离开教室后,这面旗就会消失。先预测一下:如果旗帜的位置被永久印在故事书里,明天会发生什么?书中将充满已经失效的动作痕迹。再预测一下,如果所有人都同步自己的书包,会发生什么?当 A 翻页时,A 也会夺走 B 的视野。

老师可以选择两种协作书写方式。第一种方式是,每张变更单都交给老师,由老师检查姓名、权限和页码,盖上连续编号的印章,然后广播出去。老师的版本是权威版本。第二种方式是,每个孩子都使用能够合并并发笔画的特殊透明纸。交换变更后,所有人都应用相同的合并规则;这类似于 CRDT。但无论哪种纸,都不会自动强制执行“孩子不能擦除校长的签名”这条规则。仍然需要权限和领域验证。

AI 是第四个孩子。它只能收到八张获准使用的形状积木卡,不能把整本书带走。它会先排列出一个预览,并列出将受影响的积木。如果需要批准,它就等待人工批准。完成后,它会留下来源记录和一张撤销卡。使用相同编号重新发送请求时,只会执行一次。

  1. 共享故事持久化文档
  2. 你自己的书包本地会话
  3. 此刻的旗帜临时在线状态
  4. AI 只能获得获准使用的积木类型化操作
  5. 盖章前预览验证、撤销与审计
先判断一下:AI 成功“移动了 shape-7”,但回复丢失了。重试时是否应该再次移动它?不应该。相同的幂等键会返回第一次的结果。

把玩具映射到 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/CommandUndoManager 跟踪本地 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 被持久化重新打开后出现幽灵 CursorSnapshot scope audit使用独立的 TTL Store重新加载/过期测试删除错误的 Records
AI 执行两次Shape 移动两次或评论重复Idempotency unique/audit原子创建 Result Record响应丢失后的重试返回第一次的 Result
AI 编辑无权访问的对象敏感内容发生变化Denied audit/permission trace对每个受影响的 ID 进行授权检查混合 Batch 测试回滚整个 Transaction
AI 执行到一半时断开连接只完成了一半 Shapes 的 BatchRevision/transaction log原子提交在每个步骤断开连接使用同一密钥重试或 Undo
Asset 上传与 Document 冲突引用永久缺失Asset/Document statesintent→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 只能从八张积木卡中选择。它会先摆出一个示例并说明将触碰哪些内容,等待任何必需的批准,一次性完成全部更改,然后留下撤销卡和签名。即使断开连接导致同一张带编号的纸条被送达两次,也只有第一次的结果算数。