项目同步与补丁安全 深度解析
这一页关注 Pireel 如何保存一个包含视频时间线、HTML blocks、chat 和 agent context 的大项目,同时避免冲突和全量重传。
导入链与契约
在 packages/studio-engine/src/project-dto.ts:1,文件注释说明 DTO 是 server route 和 client sync 的共同边界。 在 packages/studio-engine/src/project-dto.ts:23,context 保存 ASR、plan 和 media 索引。 在 packages/studio-engine/src/project-dto.ts:38,StudioProjectDto 是完整项目 payload。 在 packages/studio-engine/src/project-dto.ts:52,StudioProjectMeta 是列表轻量视图。 在 packages/studio-engine/src/project-dto.ts:76,MAX_PROJECT_BYTES 限制项目体积。
121/* ==================== incremental save wire format ==================== */ 122/* 123 * The PUT body is a DIFF, two levels: 124 * 1. Sectioning: the full payload splits into five sections (comp/chat/context/coverThumb/meta); 125 * unchanged sections aren't sent, all-unchanged = zero request; on the server, ABSENT = unchanged, 126 * keeping the current stored value. 127 * 2. Intra-section JSON Patch (RFC 6902, fast-json-patch): the client keeps a copy of the 128 * "last successfully saved value"; the changed big sections (comp/chat/context) compare against 129 * it into an op list, and if that's smaller than the whole section (<60%) it sends the patch — 130 * dragging one block = one replace of a few hundred bytes, no more re-sending the whole 246KB. 131 * Correctness anchor: comp/chat patches carry a target canonical hash; the server verifies after 132 * applying, and on mismatch (base drifted / patch corrupt) → 422 need_full, the client clears the 133 * baseline and re-sends the whole section — worst case degrades to full, never silently wrong. 134 * context patches aren't verified (the server may have merged in keys from elsewhere, legitimately 135 * more than the client), and apply itself preserves unknown keys. The patch precondition = 136 * baseVersion optimistic concurrency: on 409 the client re-seeds the baseline (ackedFromDto) from 137 * the returned server full, so the retry diff is computed against server truth, converging per section. 138 */ 139 140/** Diff wire format from client to server (serverSaveProject builds it from the full payload). 141 * Each big section is one of three: full value / patch+target hash / absent (unchanged). */ 142export interface ProjectSaveWire { 143 baseVersion: number | null; 144 comp?: Composition; 145 compPatch?: Operation[]; 146 compHash?: string; 147 chat?: unknown[]; 148 chatPatch?: Operation[]; 149 chatHash?: string; 150 context?: StudioProjectContext; 151 contextPatch?: Operation[]; 152 coverThumb?: string | null; 153 title?: string; 154 videoSig?: string | null; 155 videoDurationSec?: number | null; 156} 157 158/** Canonical serialization (object keys sorted): the hash must match on both ends, but jsonb 159 * storage reorders keys, so plain JSON.stringify yields different strings for the same data. 160 * undefined handled per JSON semantics (object keys dropped / array elements → null). */
核心实现,逐段拆
▸Project Sync 的第一层约束
在 packages/studio-engine/src/project-dto.ts:121,保存协议拆成两层 diff。 在 packages/studio-engine/src/project-dto.ts:131,patch 应用后用 hash 校验。 在 packages/studio-engine/src/project-dto.ts:136,409 后客户端以服务端 truth 重建 baseline。 在 packages/studio-engine/src/project-dto.ts:161,canonicalJson 保证 key 排序。 在 packages/studio-engine/src/project-dto.ts:172,hashSection 用双 rolling hash。 在 packages/studio-engine/src/project-dto.ts:194,AckedSections 保存值和 hash。
267export function buildSaveWire( 268 p: ProjectSavePayload, 269 baseVersion: number | null, 270 acked: AckedSections | null, 271): { wire: ProjectSaveWire; acked: AckedSections } | null { 272 const compCanon = canonicalJson({ ...p.comp, video: null }); 273 const chatCanon = canonicalJson(p.chat ?? []); 274 const ctxCanon = canonicalJson(p.context ?? {}); 275 const hashes: SectionHashes = { 276 comp: hashSection(compCanon), 277 chat: hashSection(chatCanon), 278 context: hashSection(ctxCanon), 279 coverThumb: hashSection(p.coverThumb ?? ''), 280 meta: metaHashOf(p.title, p.videoSig, p.videoDurationSec), 281 }; 282 // JSON-clean current values (parsed from the canonical string: incidentally drops undefined, so diff is structurally comparable to the baseline) 283 const values: AckedSections['values'] = { 284 comp: JSON.parse(compCanon) as Composition, 285 chat: JSON.parse(chatCanon) as unknown[], 286 context: JSON.parse(ctxCanon) as StudioProjectContext, 287 }; 288 const wire: ProjectSaveWire = { baseVersion }; 289 const w = wire as unknown as Record<string, unknown>; 290 let changed = false; 291 292 /** Big-section tri-state: unchanged → absent / has baseline and patch is smaller → send patch / else whole section. */ 293 const emitBig = (key: 'comp' | 'chat' | 'context', canon: string, withHash: boolean) => { 294 if (acked && acked.hashes[key] === hashes[key]) return; 295 changed = true; 296 if (acked) { 297 try { 298 const ops = diffOps(acked.values[key], values[key]); 299 if (JSON.stringify(ops).length < canon.length * PATCH_WORTH_RATIO) { 300 w[`${key}Patch`] = ops; 301 if (withHash) w[`${key}Hash`] = hashes[key]; 302 return; 303 } 304 } catch { 305 /* diff failed → fall back to whole section */ 306 } 307 } 308 w[key] = values[key]; 309 }; 310 emitBig('comp', compCanon, true); 311 emitBig('chat', chatCanon, true); 312 emitBig('context', ctxCanon, false); 313 314 if (!acked || acked.hashes.coverThumb !== hashes.coverThumb) { 315 wire.coverThumb = p.coverThumb; 316 changed = true; 317 } 318 if (!acked || acked.hashes.meta !== hashes.meta) { 319 if (p.title !== undefined) wire.title = p.title; 320 wire.videoSig = p.videoSig; 321 wire.videoDurationSec = p.videoDurationSec; 322 changed = true; 323 } 324 return changed ? { wire, acked: { values, hashes } } : null;
▸Project Sync 的第二层约束
在 packages/studio-engine/src/project-dto.ts:203,ackedFromDto 从 server full DTO 重建 baseline。 在 packages/studio-engine/src/project-dto.ts:232,注释解释为什么不用 fast-json-patch compare。 在 packages/studio-engine/src/project-dto.ts:251,jsondiffpatch 用 objectHash 对齐数组元素。 在 packages/studio-engine/src/project-dto.ts:267,buildSaveWire 接收 full payload 和 baseline。 在 packages/studio-engine/src/project-dto.ts:288,wire 从 baseVersion 开始。 在 packages/studio-engine/src/project-dto.ts:293,emitBig 决定 patch/full/absent。
391export function mergeSaveIntoRow( 392 existing: { 393 title: string; 394 comp: unknown; 395 chat: unknown; 396 context?: unknown; 397 videoSig: string | null; 398 videoDurationSec: string | number | null; 399 coverThumb: string | null; 400 }, 401 p: NonNullable<ReturnType<typeof sanitizeSavePayload>>, 402): { 403 title: string; 404 comp: unknown; 405 chat: unknown; 406 context: Record<string, unknown>; 407 videoSig: string | null; 408 videoDurationSec: string | null; 409 coverThumb: string | null; 410} | null { 411 // comp: full > patch (with hash verify, strip video once more after apply as a safety net) > keep 412 let comp = existing.comp; 413 if (p.comp) comp = p.comp; 414 else if (p.compPatch) { 415 if (!p.compHash) return null; // patch must carry a target hash; missing = client broken 416 const patched = applySectionPatch(existing.comp, p.compPatch, p.compHash); 417 if (patched === null) return null; 418 comp = { ...(patched as Composition), video: null }; 419 } 420 421 let chat = existing.chat; 422 if (p.chat) chat = p.chat; 423 else if (p.chatPatch) { 424 if (!p.chatHash) return null; 425 const patched = applySectionPatch(Array.isArray(existing.chat) ? existing.chat : [], p.chatPatch, p.chatHash); 426 if (patched === null) return null; 427 chat = patched; 428 } 429 430 // context: full = merge by key (guards partial state against erasure: missing key = don't know ≠ delete); 431 // patch = applied on the server's current value, naturally preserving keys the client doesn't know, 432 // no hash verify (the server may have merged in keys from elsewhere, legitimately more than the client, so verify would always false-positive). 433 const exCtx = (existing.context && typeof existing.context === 'object' ? existing.context : {}) as Record<string, unknown>; 434 let context = exCtx; 435 if (p.context) context = { ...exCtx, ...(p.context as Record<string, unknown>) }; 436 else if (p.contextPatch) { 437 const patched = applySectionPatch(exCtx, p.contextPatch); 438 if (patched === null || typeof patched !== 'object' || Array.isArray(patched)) return null; 439 context = patched as Record<string, unknown>; 440 } 441 442 const exDur = existing.videoDurationSec; 443 return { 444 title: p.title ?? existing.title, 445 comp, 446 chat, 447 context, 448 // null doesn't overwrite non-empty: the saving tab may not have hydrated yet (its "absent" ≠ "user deleted") 449 videoSig: p.videoSig ?? existing.videoSig, 450 videoDurationSec: p.videoDurationSec != null ? String(p.videoDurationSec) : exDur == null ? null : String(exDur), 451 coverThumb: p.coverThumb ?? existing.coverThumb, 452 };
关键流程
在 packages/studio-engine/src/project-dto.ts:327,sanitizeOps 屏蔽明显垃圾 patch。 在 packages/studio-engine/src/project-dto.ts:335,sanitizeSavePayload 截断 title/hash/cover 等字段。 在 packages/studio-engine/src/project-dto.ts:356,comp 保存前剥离 video。 在 packages/studio-engine/src/project-dto.ts:372,applySectionPatch 设置 mutateDocument=false。 在 packages/studio-engine/src/project-dto.ts:387,mergeSaveIntoRow 负责 update path 合并。 在 packages/studio-engine/src/project-dto.ts:411,comp patch 要求 compHash。
边界 / 失败模式
▸失败时要让调用方知道该怎么修
在 packages/studio-engine/src/project-dto.ts:430,context full 是按 key merge。 在 packages/studio-engine/src/project-dto.ts:448,videoSig 的 null 不覆盖已有值。 在 packages/studio-engine/src/mcp.ts:72,createProject 可离线创建空项目。 在 packages/studio-engine/src/mcp.ts:75,listProjects 提供轻量 meta。 在 packages/studio-engine/src/mcp.ts:77,switchProject 通过更新时间切 active。 在 packages/studio-ui/src/media.ts:26,fileSig 为本地文件生成稳定 fingerprint。
- patch 没有目标 hash 会被拒绝,避免客户端错误把 comp 写坏。
- context 不做 hash verify,因为服务端可能合法合并了客户端不知道的新字段。
- video blob 不进 comp 持久层,避免把本地 File 或 blob URL 当云端 truth。