pireelProject Sync
§ · Nested detail page

项目同步与补丁安全 深度解析

这一页关注 Pireel 如何保存一个包含视频时间线、HTML blocks、chat 和 agent context 的大项目,同时避免冲突和全量重传。

§ 00 · IMPORT CHAIN

导入链与契约

在 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 限制项目体积。

packages/studio-engine/src/project-dto.ts · L121-160TypeScript
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). */
§ 01 · CORE IMPL

核心实现,逐段拆

▸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。

packages/studio-engine/src/project-dto.ts · L267-324TypeScript
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。

packages/studio-engine/src/project-dto.ts · L391-452TypeScript
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  };
§ 02 · KEY FLOW

关键流程

client full project -> canonical sections -> hash compare changed big section -> JSON Patch if compact, else full section server apply -> verify hash for comp/chat -> merge context by key -> conflict returns need_full

在 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。

§ 03 · EDGE CASES

边界 / 失败模式

▸失败时要让调用方知道该怎么修

在 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。
Sub-page · 2026-07-23Source · pireel/pireel↑ 返回主页面