agentsclimarketplace

Multi user sync skill

Skill komashiromomo/multi-user-sync-skill

Multi-user 即時同步系統的完整實戰手冊:locking / optimistic lock / 3-way merge / broadcast handler / Yjs CRDT / presence。當用戶在做或修改任何 2+ 人同編工具時觸發。 關鍵詞觸發:「多人同步」、「多人協作」、「即時同步」、「即時協作」、「實時同步」、「同編」、「同時編輯」、「衝突解決」、「樂觀鎖」、「悲觀鎖」、「3-way merge」、「CRDT」、「Yjs」、「Presence」、「在線狀態」、「誰在編輯」、「broadcast」、「廣播」、「postgres_changes」、「Supabase Realtime」、「資料覆蓋」、「打字不丟字」、「scroll 跳走」、「新增消失」、「刪除復活」、「heartbeat」、「lock TTL」、「editingBy」、「multi-user sync」、「realtime sync」、「collaborative editing」、「conflict resolution」、「optimistic locking」、「three-way merge」、「presence indicator」、「supabase realtime」、「broadcast handler」、「data race」、「debounce save」。 即使用戶只說「兩個人同時編輯怎麼辦」、「做個多人協作工具」、「同步」、「Realtime」、「我要 vibe coding 一個 multi-user app」也應觸發此 skill。任何涉及「多人並行寫入同一份資料」的功能(包含但不限於文件協作、看板、表單、財估、PM 工具、白板)都適用。 本 skill 從 Fandora 財估工具 6 天 93+ commits 累積的實戰教訓蒸餾出來,涵蓋 M-1 至 M-45 系列的所有 sync 相關 patterns + 6 個歷史災難 + 4 層保護架構。From its SKILL.md

Install
npx -y skills add komashiromomo/multi-user-sync-skill

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

41.7 KB, ~14.1k tokens by cl100k_base, as published. Nobody here has run it

Multi-user 即時同步系統實戰手冊

本 Skill 是「從 0 到 production-grade 多人同編工具」的單檔規範。任何涉及 2+ 用戶同時讀寫同一份雲端狀態的功能,都應該先讀這份。

目錄

  1. 為什麼這個 skill 存在
  2. 4 層保護架構
  3. 全局原則
  4. 技術棧建議
  5. 起手式 SOP
  6. Layer 1 鎖機制
  7. Layer 2 樂觀鎖 + 3-way merge
  8. Layer 3 Broadcast Handler
  9. Layer 4 Yjs CRDT
  10. Side-channel Presence
  11. 6 個歷史災難
  12. 加新 sync feature 前的 7 個必問
  13. 上線前 checklist
  14. 何時 NOT 用這個 skill / 不在範圍內

為什麼這個 skill 存在

多人同編看起來「就是接個 realtime 就好」,但實際上有 6 個你不會想到的坑

  1. 資料覆蓋 — UserA 還沒存完,UserB save 把 A 的變動吃掉
  2. 刪除復活 — A 刪了 IP、B 的舊狀態又把它推回雲端
  3. 新增消失 — A 點「新增」後 2 秒內,B 的 broadcast 把新項目蓋掉
  4. 打字丟字 — 兩人同編同一個 textarea、字元級交錯
  5. scroll 跳頂端 — 別人 save 觸發本地重畫、user 的捲軸位置歸零
  6. 災難覆蓋 — 某 edge case 讓 default state 推回雲端、把全公司資料變成「範例」

這 skill 不是教你「怎麼用 Supabase Realtime」,是教你「怎麼避免上面這 6 個坑」。

4 層保護架構(核心心智模型)

┌────────────────────────────────────────┐
│ Layer 1:鎖機制(lock)                  │
│ 軟提醒「對方正在編輯」、避免你也下手         │
└────────────────────────────────────────┘
              ↓
┌────────────────────────────────────────┐
│ Layer 2:樂觀鎖 + 3-way merge            │
│ 真要寫雲端時、發現雲端比你新 → 自動合併     │
└────────────────────────────────────────┘
              ↓
┌────────────────────────────────────────┐
│ Layer 3:broadcast handler              │
│ 別人寫完後、你的 client 怎麼接收 + 顯示    │
└────────────────────────────────────────┘
              ↓
┌────────────────────────────────────────┐
│ Layer 4:Yjs CRDT(字元級 textarea)     │
│ 兩人同編一個 textarea 不丟字              │
└────────────────────────────────────────┘
              +
┌────────────────────────────────────────┐
│ Side-channel:Presence(在線狀態)        │
│ 誰上線、在哪個 IP、是否在打字              │
└────────────────────────────────────────┘

思考順序:先把 Layer 1-3 做穩、再上 Layer 4。Presence 是錦上添花。

全局原則(不分 Layer 都適用)

原則 1:永遠假設「user 跟自己同時操作」

不要設計「只有自己會在此時改」的邏輯。Multi-user 系統的每個 critical section 都要假設另一個 user 同時也在這裡

原則 2:destructive operations 必須可逆

  • 雲端寫入有歷史快照(workspace_history table、trim trigger 保留最近 N 筆)
  • 大規模刪除 / 寫空白 → 二次 confirm + cloud diff guard
  • broadcast 收到「大規模刪除」直接拒收 + console.error

原則 3:UI 不要被別人的動作打斷

  • broadcast 進來不重畫 main(保留 scroll、保留打字)
  • 只重畫 sidebar / status indicator
  • 顯示 toast「XX 更新了資料」、user 想看自己點刷新

原則 4:local 變動有 grace period

  • saveState 通常 debounce 1-2s
  • 在 grace period 內 broadcast 進來 → 不能把 local pending 變動吃掉
  • (local 有 + remote 沒 + base 沒) 規則保留 local-only 新增

原則 5:sync 邏輯要可觀察

  • 關鍵分支都加 console.warn('[broadcast M-XX] ...') 帶分類 tag
  • 災難場景拒收時 console.error(...) + alert + 雲端快照備份
  • 不要 silent skip — 永遠記錄為什麼這次選擇 A 路徑

原則 6:CRDT 不是萬靈丹

  • Yjs 解決「字元級不丟字」
  • 但 Yjs 解決不了 schema 級的「我新增一個 IP 你刪一個 task」並行 — 那是 Layer 2-3 的工作
  • 不要為了用 CRDT 而 CRDT、textarea 之外的欄位用樂觀鎖 + merge 就夠了

技術棧建議(驗證過、能用)

用途選擇為什麼
雲端 db + auth + realtimeSupabasepostgres_changes 內建、免費 tier 夠用、RLS 強
部署Vercelgit push 自動 deploy
Frontend單一 HTML inline CSS+JS不需 build step、AI 看得到全貌、易 backup
CRDT(textarea)Yjs from esm.sh字元級不丟字、Awareness 提供 typing indicator
state 結構single jsonb in workspace tableupsert 一個 row、樂觀鎖容易
state 版本workspace.version integerUPDATE WHERE version=expected 樂觀鎖核心
歷史備份workspace_history table + trim trigger災難回復不可少

起手式(new project)

  1. 建 Supabase project:開 Auth (Google OAuth)、建 workspace table(id PK, data jsonb, version int, updated_at, updated_by)、建 workspace_history 同 schema + trim trigger
  2. 加 RLS policy:限定特定 email 域名(如 @yourcompany.com
  3. Frontend 接 Supabase JS:login → loadStateFromCloud → subscribe postgres_changes
  4. 先做 Layer 2(樂觀鎖)+ Layer 3(broadcast handler)
  5. 驗證 stress test:兩瀏覽器並行操作 30 次、確認資料無 loss
  6. 再考慮 Layer 1(鎖)+ Layer 4(Yjs)+ Presence

Layer 1:鎖機制(軟提醒)

核心概念

鎖 ≠ 阻止別人寫入。鎖是「軟提醒」— 讓 UserB 知道 UserA 正在這裡編輯、自己就不要進來、避免衝突。

真正避免資料覆蓋的是 Layer 2 樂觀鎖 + Layer 3 broadcast handler。Layer 1 的鎖只是 UX 層的禮貌機制。

兩層鎖(per-IP + per-task)

為什麼需要兩層

  • per-IP 鎖:UserA 在編「魔物獵人」整個 IP 設定 → sidebar 標示有人在編、勸退別人
  • per-task 鎖:UserA 在某 task inspector 打字 → 同 IP 內其他人可以編別的 task,只鎖這個

只做 per-IP 太粗(一個 PM 鎖住整個 IP、其他人完全無法協作)。只做 per-task 太細(同 IP 整體設定也要鎖)。

Schema

// 在 state.ips[i] 上
{
  ...其他欄位,
  editingBy: {
    email: '[email protected]',          // 誰在鎖
    until: '2026-06-01T12:35:00.000Z',  // ISO timestamp、TTL 5 分鐘後
  }
}
// 在 state.ips[i].tasks[j] 上同樣結構

editingBy = null 或省略 → 沒鎖。

acquireLock / releaseLock 實作

const IP_LOCK_TTL_MS = 5 * 60 * 1000;  // 5 分鐘

function acquireIPLock(ip, opts) {
  if (!ip || !currentUser || !USE_CLOUD) return false;
  opts = opts || {};
  if (isIPLockedByOther(ip) && !opts.force) return false;
  ip.editingBy = {
    email: currentUser.email,
    until: new Date(Date.now() + IP_LOCK_TTL_MS).toISOString(),
  };
  saveState(state);
  return true;
}

function isIPLockedByOther(ip) {
  if (!ip?.editingBy?.email) return false;
  if (ip.editingBy.email === currentUser?.email) return false;
  if (!ip.editingBy.until) return false;
  return new Date(ip.editingBy.until) > new Date();
}

function releaseIPLock(ip) {
  if (!ip || !currentUser) return;
  if (ip.editingBy?.email === currentUser.email) {
    ip.editingBy = null;
    saveState(state);
  }
}

時機(何時 acquire / release)

時機動作
點進某 IP(switch tab)acquireIPLock(newIp)
切離 IP釋放上個 IP 的鎖 + 任何 own task 鎖
點某 task inspectoracquireTaskLock(task)
切離 taskreleaseTaskLock(prev)
logout / 關 tab靠 TTL 自動失效

Heartbeat(防止鎖過期)

let _ipLockHeartbeatTimer = null;

function startIPLockHeartbeat() {
  if (_ipLockHeartbeatTimer) clearInterval(_ipLockHeartbeatTimer);
  _ipLockHeartbeatTimer = setInterval(() => {
    const curIp = getCurrentIP();
    if (!curIp || !curIp.editingBy || curIp.editingBy.email !== currentUser.email) return;
    curIp.editingBy.until = new Date(Date.now() + IP_LOCK_TTL_MS).toISOString();
    saveState(state);
  }, 30 * 1000);
}

⚠️ Heartbeat 容易觸發 conflict 迴圈 — 需要 _mergeEditingBy 同 email 採較晚 until + broadcast handler 偵測純鎖變動 silent path(見後面 Layer 2 / 3)。

sidebar 顯示誰在編輯

function renderIPEditorsHTML(ip) {
  const editors = [];
  // 鎖持有者(編輯中)
  if (ip.editingBy?.email && ip.editingBy.email !== currentUser?.email) {
    const until = new Date(ip.editingBy.until);
    if (until > new Date()) {
      editors.push({ email: ip.editingBy.email, role: 'editing' });
    }
  }
  // Presence 在此 IP 上(觀看中)
  for (const p of _presenceList) {
    if (p.email === currentUser?.email) continue;
    if (p.currentTabId !== ip.id) continue;
    if (editors.some(e => e.email === p.email)) continue;
    editors.push({ email: p.email, role: 'viewing' });
  }
  return editors.map(e => /* render 圓形 avatar + initial */).join('');
}

Layer 1 常見陷阱

  • Bug:自己鎖自己(heartbeat 跟自己 conflict)→ 解:_mergeEditingBy 同 email 採較晚 until
  • Bug:force 接手沒清舊鎖 holder → 解:UserB acquire force 時 broadcast、UserA 收到後比對「不是 me」就放手
  • Bug:lock-only 變動觸發完整 merge → 解:remoteIsOnlyLockChange diff function

Layer 2:樂觀鎖 + 3-way merge(資料完整性核心)

這是整個 sync 系統的最關鍵保護。Layer 1 鎖只是 UX,這層才是「資料真的不會丟」的擔保。

核心概念

樂觀鎖:每次寫雲端附帶「我以為的版本」,雲端比對符合才寫入。 3-way merge:寫入失敗(雲端比我新)→ 自動把雲端的新變動跟我的本地變動合併、再寫一次。

兩者合用 = 多人並行寫入不會互相覆蓋。

Schema

CREATE TABLE workspace (
  id INT PRIMARY KEY,
  data JSONB,
  version INT DEFAULT 1,
  updated_at TIMESTAMPTZ DEFAULT NOW(),
  updated_by TEXT
);

CREATE TABLE workspace_history (
  id BIGSERIAL PRIMARY KEY,
  data JSONB,
  saved_at TIMESTAMPTZ DEFAULT NOW(),
  saved_by TEXT,
  label TEXT
);

Client 端:

let _localVersion = 1;     // load 時設、save 成功後 +1
let _baseState = null;     // load / save 成功時的 deep copy(merge 的 base)

saveStateToCloud 主流程

async function saveStateToCloud(s) {
  if (_cloudLoadFailed) return;  // load 失敗時鎖定、絕不寫
  
  // 災難防護:寫空白前 cloud diff guard
  const cloudCheck = await sb.from('workspace').select('data').eq('id', 1).maybeSingle();
  if (cloudCheck?.data?.data?.ips?.length >= 3 && s.ips?.length === 0) {
    if (!confirm('⚠⚠ 警告:即將把【幾乎空白】寫入雲端、覆蓋現有 N 個 IP。確定?')) return;
  }
  
  // 樂觀鎖 UPDATE WHERE version=expected
  const { data, error } = await sb
    .from('workspace')
    .update({ data: s, version: _localVersion + 1, updated_at: new Date().toISOString(), updated_by: currentUser.email })
    .eq('id', 1)
    .eq('version', _localVersion)  // ← 樂觀鎖
    .select()
    .maybeSingle();
  
  if (!data) {
    console.warn('[saveStateToCloud] version conflict, entering 3-way merge');
    await handleVersionConflict(s, 1);
    return;
  }
  
  _localVersion = data.version;
  _baseState = JSON.parse(JSON.stringify(s));
}

handleVersionConflict(3-way merge)

const MERGE_MAX_RETRY = 3;

async function handleVersionConflict(localState, attempt) {
  if (attempt > MERGE_MAX_RETRY) {
    flashStatus(`合併超過 ${MERGE_MAX_RETRY} 次仍衝突、暫停寫入、請重新整理`, 'error');
    return;
  }
  
  const { data: row } = await sb.from('workspace').select('data, version').eq('id', 1).maybeSingle();
  if (!row) return;
  const remoteState = normalizeState(row.data);
  const remoteUpdater = row.updated_by;
  
  if (!_baseState) console.warn('[handleVersionConflict] _baseState=null、退化為 local-vs-remote');
  const result = threewayMerge(_baseState || remoteState, localState, remoteState, remoteUpdater);
  
  if (result.conflicts.length > 0) {
    showMergeToast(result.conflicts, result.autoMerged);
  }
  
  _baseState = JSON.parse(JSON.stringify(remoteState));
  _localVersion = row.version;
  
  await saveStateToCloudInternal(result.state, attempt + 1);
}

threewayMerge 邏輯

function threewayMerge(base, local, remote, remoteUpdater) {
  const conflicts = [];
  const ctx = { autoMerged: 0, remoteBy: (remoteUpdater || '').split('@')[0] || '對方' };
  const merged = _mergeObject(base || {}, local || {}, remote || {}, conflicts, ctx, '');
  return { state: merged, conflicts, autoMerged: ctx.autoMerged };
}

function _mergeObject(base, local, remote, conflicts, ctx, path) {
  const allKeys = new Set([...Object.keys(local), ...Object.keys(remote)]);
  const out = {};
  for (const k of allKeys) {
    const bV = base[k], lV = local[k], rV = remote[k];
    
    // 特殊欄位:editingBy — 採 until 較晚的
    if (k === 'editingBy') {
      out[k] = _mergeEditingBy(lV, rV);
      continue;
    }
    
    // 特殊欄位:currentTabId 永遠採 local(per-client)
    if (k === 'currentTabId') {
      out[k] = lV ?? bV;
      continue;
    }
    
    if (Array.isArray(lV) || Array.isArray(rV)) {
      out[k] = _mergeArrayById(Array.isArray(bV) ? bV : [], lV, rV, conflicts, ctx, path + '/' + k);
      continue;
    }
    
    if (typeof lV === 'object' && lV !== null && typeof rV === 'object' && rV !== null) {
      out[k] = _mergeObject(bV || {}, lV, rV, conflicts, ctx, path + '/' + k);
      continue;
    }
    
    // 純量
    if (lV === rV) { out[k] = lV; continue; }
    if (lV === bV) { out[k] = rV; ctx.autoMerged++; continue; }   // 我沒改、remote 改了 → 採 remote
    if (rV === bV) { out[k] = lV; continue; }                      // remote 沒改、我改了 → 採 local
    // 雙方都改了 → conflict,本地勝出
    out[k] = lV;
    conflicts.push({ path: path + '/' + k, localValue: lV, remoteValue: rV, remoteBy: ctx.remoteBy });
  }
  return out;
}

_mergeArrayById(陣列 merge)

最複雜的部分。處理「兩邊都新增、兩邊都刪、兩邊都改」的場景。

function _mergeArrayById(base, local, remote, conflicts, ctx, path) {
  const orderedIds = [];
  for (const item of local) if (item?.id != null && !orderedIds.includes(item.id)) orderedIds.push(item.id);
  for (const item of remote) if (item?.id != null && !orderedIds.includes(item.id)) orderedIds.push(item.id);
  
  const out = [];
  for (const id of orderedIds) {
    const bItem = base.find(x => x.id === id);
    const lItem = local.find(x => x.id === id);
    const rItem = remote.find(x => x.id === id);
    
    if (!lItem && !rItem) continue;                       // 雙方都刪
    if (!lItem && bItem)  continue;                        // 本地刪
    if (!rItem && bItem)  { ctx.autoMerged++; continue; }  // 遠端刪
    if (!lItem && rItem)  { out.push(rItem); ctx.autoMerged++; continue; }  // 遠端新增
    if (!rItem && lItem)  { out.push(lItem); continue; }                    // 本地新增
    if (lItem && rItem) {
      const name = lItem.name || rItem.name || ('#' + id);
      out.push(_mergeObject(bItem || {}, lItem, rItem, conflicts, ctx, path + '/' + name));
    }
  }
  return out;
}

_mergeEditingBy(鎖欄位特殊處理)

function _mergeEditingBy(local, remote) {
  if (!local && !remote) return null;
  if (!local) return remote;
  if (!remote) return local;
  
  // 同 email:採 until 較晚(heartbeat 不算 conflict)
  if (local.email === remote.email) {
    return new Date(local.until || 0) > new Date(remote.until || 0) ? local : remote;
  }
  
  // 不同 email:採後鎖的(較晚 until)
  return new Date(local.until || 0) > new Date(remote.until || 0) ? local : remote;
}

「衝突 toast」UI

merge 跑完後、把 conflicts 用 toast 顯示給 user:

⚠ 3 處衝突已用「你的版本」
   ips/魔物獵人/royaltyRate:你 8% 蓋過 Janet 的 9%
   ips/鬼滅之刃/notes:你「測試備註」蓋過 Michael 的「正式版」
+ 自動合併 12 處不衝突的變動

Layer 2 常見陷阱

  • Bug:_baseState 為 null 或太舊 → 解:每次 load + save 成功都 update
  • Bug:editingBy 觸發 conflict 迴圈 → 解:_mergeEditingBy 同 email 採較晚 until
  • Bug:retry 無限循環 → 解:MERGE_MAX_RETRY = 3 上限

Layer 3:Broadcast Handler(別人寫完後我怎麼接)

別人寫雲端後,Supabase Realtime 會 push postgres_changes event 給所有訂閱者。怎麼處理這個 event = sync 系統的成敗關鍵

subscribe 流程

let workspaceChannel = null;

function subscribeWorkspace() {
  if (workspaceChannel) return;
  workspaceChannel = sb.channel('workspace_changes');
  workspaceChannel
    .on('postgres_changes',
        { event: 'UPDATE', schema: 'public', table: 'workspace', filter: 'id=eq.1' },
        payload => handleBroadcast(payload))
    .on('presence', { event: 'sync' }, () => syncPresence())
    .subscribe(async (status) => {
      if (status === 'SUBSCRIBED') {
        await workspaceChannel.track({ email: currentUser.email, /* ... */ });
      }
    });
}

handleBroadcast 最終版(M-45 之後)

function handleBroadcast(payload) {
  const updater = payload.new.updated_by || '';
  const newData = payload.new.data;
  const newVersion = payload.new.version;
  if (!newData) return;
  
  // 災難防護:拒收大規模刪除
  if (state?.ips?.length >= 3 && (!newData.ips || newData.ips.length === 0)) {
    console.error('[broadcast guard] 拒收:大規模刪除 broadcast');
    showRefuseToast(updater);
    return;
  }
  
  // 1. 自己 echo skip
  if (currentUser && updater === currentUser.email) {
    if (_cloudHasVersionColumn && typeof newVersion === 'number') {
      _localVersion = newVersion;
    }
    return;
  }
  
  // 2. 純鎖變動 → silent 同步鎖、不重畫 main
  if (state && remoteIsOnlyLockChange(state, newData)) {
    syncLockOnly(newData);
    try { _baseState = JSON.parse(JSON.stringify(normalizeState(newData))); } catch (e) {}
    if (typeof newVersion === 'number') _localVersion = newVersion;
    try { renderTabs(state); } catch (e) {}  // 更新 sidebar avatar
    return;
  }
  
  // 3. 一般資料變動 → 只 reconcile sidebar、不 overwrite main
  const oldBaseIds = new Set((_baseState?.ips || []).map(x => x.id));
  try { _baseState = JSON.parse(JSON.stringify(normalizeState(newData))); } catch (e) {}
  
  try {
    const remoteIps = newData.ips || [];
    if (state?.ips && reconcileSidebarFromRemote(remoteIps, oldBaseIds)) {
      try { renderTabs(state); } catch (e) {}
    } else {
      try { renderTabs(state); } catch (e) {}
    }
  } catch (e) { console.warn('broadcast sync failed:', e); }
  
  try { showRemoteUpdateToast(updater); } catch (e) {}
}

三個關鍵分支

分支 1:自己 echo

  • updater === currentUser.email
  • 只更新 _localVersion
  • 千萬不要更新 _baseState — 不然永遠不會偵測 conflict
  • return

分支 2:純鎖變動

  • diff remote vs local,所有非 editingBy 欄位都一樣
  • 只同步 ip.editingBy + task.editingBy
  • 更新 _baseState + _localVersion
  • renderTabs(state) 重畫 sidebar 讓 avatar 反映新鎖
  • 不重畫 main(不破壞 user 正在打字)
function remoteIsOnlyLockChange(local, remote) {
  const lClean = stripLocks(local);
  const rClean = stripLocks(remote);
  return JSON.stringify(lClean) === JSON.stringify(rClean);
}

function stripLocks(s) {
  const c = JSON.parse(JSON.stringify(s));
  for (const ip of (c.ips || [])) {
    delete ip.editingBy;
    for (const t of (ip.tasks || [])) delete t.editingBy;
  }
  return c;
}

分支 3:一般資料變動 → 只 reconcile sidebar

  • oldBaseIds 在更新 _baseState 之前(重要!)
  • 更新 _baseState 到 remote
  • 不要 update _localVersion — 讓下次 save 觸發 conflict + 3-way merge
  • reconcileSidebarFromRemote 只動 sidebar IP 列表
  • showRemoteUpdateToast 通知 user

reconcileSidebarFromRemote

function reconcileSidebarFromRemote(remoteIps, oldBaseIds) {
  if (!state?.ips || !Array.isArray(remoteIps)) return false;
  const localIds = new Set(state.ips.map(x => x.id));
  const remoteIds = new Set(remoteIps.map(x => x.id));
  let changed = false;
  
  // 新增:雲端有但本地沒
  for (const remoteIp of remoteIps) {
    if (!localIds.has(remoteIp.id)) {
      const cloned = JSON.parse(JSON.stringify(remoteIp));
      state.ips.push(cloned);
      changed = true;
    }
  }
  
  // 刪除:本地有但雲端沒,且 oldBaseIds 也有(=「對方真的刪了」)
  // 必須用 oldBaseIds(更新 _baseState 之前的版本)— 順序錯了會變死碼
  const baseIds = oldBaseIds || new Set((_baseState?.ips || []).map(x => x.id));
  state.ips = state.ips.filter(ip => {
    if (remoteIds.has(ip.id)) return true;
    if (!baseIds.has(ip.id)) return true;  // base 沒 → 我剛加的、保留
    changed = true;
    return false;
  });
  
  return changed;
}

為什麼砍掉 auto-overwrite path

之前的版本有兩條 path:

  • hasActiveInput=true → reconcile-only
  • hasActiveInput=falsestate = newState; renderApp(state) — 整個 main 重畫

問題:user 不一定在打字、可能只是 scroll 看資料。auto-overwrite 重畫 main → scroll 跳回頂端

結論:永遠走 reconcile-only path。代價是別人改的 IP 內部數字不會自動反映在 main、但 toast 已通知。

Layer 3 不可不知的 gotchas

  • Gotcha 1:自己 echo skip 別忘了更新 _localVersion,不更新會 conflict 迴圈
  • Gotcha 2:oldBaseIds 要在更新 _baseState 之前抓
  • Gotcha 3:純鎖變動才更新 _localVersion,一般資料變動更新 → 你下次 save 不會 conflict → 直接覆蓋對方變動
  • Gotcha 4:renderTabs(state) 比 renderApp(state) 輕量、broadcast 只需更新 sidebar 時用前者

Layer 4:Yjs CRDT(textarea 字元級不丟字)

何時需要 CRDT

僅當 2+ 人同時編一個 textarea。其他欄位用 Layer 2 樂觀鎖 + 3-way merge 就夠。

為什麼 textarea 特殊

  • 樂觀鎖粒度太粗:UserA 改第 5 個字、UserB 改第 20 個字 → 兩人都正確、但 lock 機制會誤判 conflict
  • 3-way merge 對 textarea 是「整段字串對比」、結果不直觀
  • CRDT 是字元級 operational data — 兩人改不同位置、自然 merge

Yjs 載入(單 HTML 無 build)

<script type="module">
  (async () => {
    try {
      const yjs = await import('https://esm.sh/[email protected]');
      const awareness = await import('https://esm.sh/[email protected]/[email protected]');
      window._Y = yjs;
      window._YAwareness = awareness.Awareness;
      window._yjsReady = true;
      window.dispatchEvent(new Event('yjsready'));
    } catch (e) {
      console.error('[Yjs] load failed:', e);
    }
  })();
</script>

⚠️ [email protected] 不能漏。不加會讓 esm.sh 給你不同版的 Yjs、造成「Yjs was already imported」warning + subtle bugs。

textarea binding(字元級不丟字)

let ydoc = null;
let ytextMap = {};

function bindYTextToTextarea(textarea, path) {
  const ytext = ydoc.getText(path);
  ytextMap[path] = ytext;
  
  if (textarea.value !== ytext.toString()) {
    textarea.value = ytext.toString();
  }
  
  // ytext 變化 → 更新 textarea(保留 cursor)
  ytext.observe(event => {
    const newValue = ytext.toString();
    if (textarea.value === newValue) return;
    const oldStart = textarea.selectionStart;
    const oldEnd = textarea.selectionEnd;
    textarea.value = newValue;
    try { textarea.setSelectionRange(oldStart, oldEnd); } catch (e) {}
  });
  
  // textarea 變化 → 更新 ytext(字元級 diff)
  textarea.addEventListener('input', () => {
    const newValue = textarea.value;
    const oldValue = ytext.toString();
    if (newValue === oldValue) return;
    
    ydoc.transact(() => {
      const [start, deleteCount, insert] = diffTextLite(oldValue, newValue);
      if (deleteCount > 0) ytext.delete(start, deleteCount);
      if (insert.length > 0) ytext.insert(start, insert);
    });
  });
}

function diffTextLite(a, b) {
  let start = 0;
  while (start < a.length && start < b.length && a[start] === b[start]) start++;
  let endA = a.length, endB = b.length;
  while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) { endA--; endB--; }
  return [start, endA - start, b.slice(start, endB)];
}

broadcast YDoc updates

ydoc.on('update', (update, origin) => {
  if (origin === 'remote') return;  // 別 echo 回去
  workspaceChannel.send({
    type: 'broadcast',
    event: 'yjs-update',
    payload: { update: Array.from(update), email: currentUser.email },
  });
});

workspaceChannel.on('broadcast', { event: 'yjs-update' }, ({ payload }) => {
  if (payload.email === currentUser.email) return;
  const update = new Uint8Array(payload.update);
  window._Y.applyUpdate(ydoc, update, 'remote');
});

持久化(save 進 Supabase)

function syncYjsToState(state) {
  // 把 ydoc 內每個 Y.Text 寫回 state 對應路徑
  for (const [path, ytext] of Object.entries(ytextMap)) {
    setByPath(state, path, ytext.toString());
  }
}

async function saveStateToCloud(s) {
  syncYjsToState(s);  // 先把 ydoc 字元級內容寫回 state
  // ... 樂觀鎖 + version
}

Layer 4 常見陷阱

  • Gotcha 1:esm.sh deps 漏寫 → Yjs 雙載入 + constructor 不對
  • Gotcha 2:textarea.value 重設會跳 cursor → 記錄 + 還原 selectionStart/End
  • Gotcha 3:origin 標記不正確 → echo 迴圈
  • Gotcha 4:整段 replace 會丟別人的 op → 必須字元級 diff

同步策略

實務上不需要每個 textarea 都用 Yjs。Fandora 只對「任務描述」這種大段落且常多人同編的欄位用。一般 input / number 還是用 data-path + 樂觀鎖。


Side-channel:Presence(誰上線、誰在哪)

核心概念

Presence ≠ 鎖。鎖是「軟提醒誰在編輯」、Presence 是「誰在線、在哪個畫面」。

兩者搭配:

  • 鎖 (editingBy):實際有 input focus、正在打字
  • Presence:上線、navigation 到某個 IP(看 vs 編)

Supabase Realtime Presence

let _presenceList = [];

workspaceChannel
  .on('presence', { event: 'sync' }, () => {
    try {
      const stateMap = workspaceChannel.presenceState();
      const users = [];
      for (const key of Object.keys(stateMap)) {
        const arr = stateMap[key];
        if (arr && arr[0]) users.push(arr[0]);
      }
      _presenceList = users;
      renderPresenceIndicator();
      try { renderTabs(state); } catch (e) {}  // sidebar avatar 也要更新
    } catch (e) { console.warn('presence sync:', e); }
  })
  .subscribe(async (status) => {
    if (status === 'SUBSCRIBED') {
      await workspaceChannel.track({
        email: currentUser.email,
        displayName: currentUserDisplayName(),
        currentTabId: state?.currentTabId || null,
        activeTaskId: state?.ui?.activeTaskId || null,
        ts: new Date().toISOString(),
      });
    }
  });

更新自己的位置

function updatePresenceLocation() {
  if (!workspaceChannel || !currentUser) return;
  try {
    workspaceChannel.track({
      email: currentUser.email,
      displayName: currentUserDisplayName(),
      currentTabId: state?.currentTabId || null,
      activeTaskId: state?.ui?.activeTaskId || null,
      ts: new Date().toISOString(),
    });
  } catch (e) {}
}
// 在切 tab 時呼叫

右上「誰在線」indicator

function renderPresenceIndicator() {
  let el = document.getElementById('presenceIndicator');
  if (!el) {
    el = document.createElement('div');
    el.id = 'presenceIndicator';
    el.style.cssText = 'position:fixed; top:8px; right:88px; display:flex; gap:4px;';
    document.body.appendChild(el);
  }
  const others = _presenceList.filter(u => u.email !== currentUser?.email);
  el.innerHTML = others.slice(0, 6).map(u => {
    const initial = (u.displayName || '?')[0].toUpperCase();
    const color = _hashColorForEmail(u.email);
    return `<span title="${u.displayName}" style="...background:${color};">${initial}</span>`;
  }).join('');
}

function _hashColorForEmail(email) {
  if (!email) return 'hsl(0,0%,60%)';
  let h = 0;
  for (let i = 0; i < email.length; i++) h = ((h << 5) - h) + email.charCodeAt(i);
  return `hsl(${Math.abs(h) % 360}, 65%, 50%)`;
}

sidebar IP 旁顯示誰在編輯 / 看

function getEditorsOnIP(ip) {
  if (!ip) return [];
  const result = [];
  const myEmail = currentUser?.email || '';
  
  // 1. 編輯中
  if (ip.editingBy?.email && ip.editingBy.email !== myEmail) {
    const until = ip.editingBy.until ? new Date(ip.editingBy.until) : null;
    if (!until || until > new Date()) {
      result.push({ email: ip.editingBy.email, displayName: getNameFromEmail(ip.editingBy.email), role: 'editing' });
    }
  }
  
  // 2. 觀看中
  for (const p of _presenceList || []) {
    if (!p.email || p.email === myEmail) continue;
    if (p.currentTabId !== ip.id) continue;
    if (result.some(r => r.email === p.email)) continue;
    result.push({ email: p.email, displayName: p.displayName, role: 'viewing' });
  }
  return result;
}

顏色一致性技巧

用 email hash 算 HSL 色相 → 同個 email 永遠是同個顏色。User 視覺記憶:「橘色那個圈圈是 Janet」。 不要每次隨機。

Presence 限制

  • close tab 沒 graceful disconnect → presence 殘留幾十秒
  • 手機背景切換 → 連線斷、回前景重連
  • Supabase 內建 TTL、不用自己做

6 個歷史災難

這份是 Fandora 財估工具實戰踩過的 sync bug 清單。讀完這份、可以省下你 1-2 週的痛苦

每個 bug 都有:症狀、root cause、修法、Fandora M-XX 編號。


Bug #1:靜默同步覆蓋本地變動(M-15 → M-45)

症狀:UserA 改了 IP 數字、還沒按存(debounce 中)。UserB 在別處 save、broadcast 進來、UserA 的變動消失。

Root cause:broadcast handler 收到 update 時,無條件 state = newState。本地 pending 變動沒保護。

修法(M-45 最終版)直接砍掉 auto-overwrite 整條 path。broadcast 永遠只 reconcile sidebar、不動 main。

教訓:不要假設 user「沒在打字」就可以無腦覆蓋。他可能在 scroll、可能在按按鈕、可能 pending save。


Bug #2:刪除的 IP 又復活(M-31)

症狀:UserA 刪了 IP「新 IP 3」。UserB save 後、那個 IP 又出現在所有人 sidebar。

Root causereconcileSidebarFromRemote_baseState.ips 判斷「對方刪了」。但 broadcast handler 順序錯:

  1. 先更新 _baseState = remote
  2. 才呼叫 reconcileSidebarFromRemote

→ 函數內 baseIds === remoteIds、「base 有 + remote 沒 = 對方刪了」分支變死碼 → IP 沒被移除。 → 下次 UserB save 時、3-way merge 看到「local 還有 IP、base 沒 IP、remote 沒 IP」→ 視為「local 新增」→ push 回雲端。

修法:broadcast handler 在更新 _baseState 之前就抓 oldBaseIds snapshot、傳給 reconcile。

教訓時序很重要。要做 diff、先抓兩邊快照、再更新。順序錯了整個邏輯失效。


Bug #3:全資料變範例 IP 災難(M-33)

症狀:某 user 操作後、整個 workspace 變回「只有 1 個範例 IP(請改名或刪除)」。全公司資料消失。

Root cause:某個 edge case 讓 state.ips 變成空陣列。normalizeState() 看到空、自動補 default 範例 IP。後續 save 把這個「default」推到雲端 → 全公司資料覆蓋。

修法(雙重防護)

  • 保護 1:save 前 cloud diff guard — 「本地幾乎空、雲端有 ≥3 個 IP」→ 二次 confirm
  • 保護 2:broadcast 收到「大規模刪除」(local≥3, remote≤1)→ 直接拒收 + console.error

教訓destructive operations 必須可逆 + 有兩層 guard。寫入前 + 接收前都 check。


Bug #4:新建 IP 在 broadcast race 中消失(M-43 → M-45)

症狀:UserA 點「+ 新增 IP」、IP 出現在 sidebar、2 秒內 UserB save、UserA 的新 IP 消失。 雲端其實有那個 IP,但 UserA 螢幕看不到、要刷新才出現。

Root cause

  • T=0:UserA push X → saveState 排 2s debounce
  • T=1:UserB broadcast 到(沒 X)→ auto-overwrite → X 消失
  • T=2:debounce fire → save 雲端有 X
  • T=2.5:自己 echo skip → 仍看不到 X

修法:M-43 保留「local 有 + remote 沒 + oldBaseIds 沒」的 IPs/tasks。M-45 直接走 reconcile-only path、reconcile 函數內建「base 沒就 keep」邏輯。

教訓debounce save + broadcast race 是必發災難。任何 sync 系統都要設計 pending local 變動的 grace period。


Bug #5:scroll 跳回頂端(M-45)

症狀:我在某 IP scroll 到一半看資料、別人 save → 我的頁面跳回最頂端、很干擾。

Root cause:broadcast 進來 + 沒 input focus → 走 auto-overwrite → state = newState; renderApp(state) → main DOM 整個重畫 → scroll restore 對 broadcast 場景沒生效。

修法:同 Bug #1 — 砍掉 auto-overwrite path、永遠只 reconcile sidebar、main 不動。

教訓user UX > 自動同步功能。打斷 user 操作的代價、比「資料即時同步」的好處更大。


Bug #6:editingBy heartbeat 觸發 conflict 迴圈(M-19)

症狀:UserA 在編某 IP,每 30 秒 heartbeat 續鎖 → broadcast 出去 → 自己又收到 → merge → 又 conflict → retry → 無窮迴圈。

Root cause:heartbeat 更新 editingBy.until → save → broadcast。broadcast handler 把「editingBy 變了」當一般資料變動 → 跑完 reconcile → 又 save → 又 broadcast → 又收 → ...

修法

  1. remoteIsOnlyLockChange 偵測純鎖變動 → silent path
  2. _mergeEditingBy(local, remote) 同 email 採較晚 until → heartbeat 不算 conflict

教訓heartbeat 這類「自動觸發 save」的功能很容易跟 sync 邏輯互打。任何「rate-limited 自動寫入」都要有對應的「detect 純 X 變動」short-circuit。


通用教訓 summary

  1. 時序很重要 — 要 diff 就先抓 snapshot
  2. destructive op 必須可逆 + 雙層 guard
  3. pending 變動有 grace period 不可侵犯
  4. user UX > 自動同步(不要打斷 user)
  5. 自動觸發的寫入(heartbeat)要有 short-circuit
  6. broadcast handler 的每個分支都要明確:自己 echo / 純鎖 / 一般資料 / 災難拒收

加新 sync feature 前的 7 個必問

每次要加新功能(任何涉及讀寫 state、跟雲端互動的)前,先過這份 checklist。

不過 = 大概率會引入上面 6 個 bug 之一。

✅ 問題 1:這功能會修改 state 嗎?修哪些欄位?

具體欄位 list:「我要改 state.ips[i].royaltyRate」「我要 push 到 state.ips」這種粒度。

為什麼問:要知道改哪、才能想「broadcast handler 怎麼接」+「merge 邏輯怎麼處理」。

✅ 問題 2:寫入會 trigger save 嗎?怎麼 trigger?

  • ✅ 用 saveState(state) debounce 1-2 秒(標準)
  • ⚠️ 直接 saveStateToCloud(state) 立刻寫(少見、確認是必要)
  • ⚠️ 沒 trigger save(純 UI state、不需要存)

為什麼問:debounce 才有 race condition。

✅ 問題 3:別人 broadcast 進來、我這邊要怎麼反應?

我的功能broadcast 進來怎麼接
改 sidebar 列表reconcileSidebarFromRemote 自然會處理
改 IP 內部欄位toast 通知、user 想看自己刷新(M-45 後不自動重畫)
改純 UI state設計成 per-client、跟 broadcast 無關
改 editingBy純鎖變動 silent path

✅ 問題 4:兩個 user 同時修改這個欄位、怎麼解?

  • 純量:3-way merge — 本地贏 + conflict toast
  • 陣列_mergeArrayById — 雙方都新增 → 都保留;雙方都改同 id → recurse merge
  • 特殊欄位:editingBy / currentTabId 有 special case

如果是新「特殊欄位」、要在 _mergeObject 加 case。

✅ 問題 5:本地未存 pending 變動會被吞掉嗎?

debounce save 2 秒內 broadcast 進來:

  • ✅ 新增類:reconcileSidebarFromRemote 有 base 沒就 keep 保護
  • ⚠️ 修改類:M-45 後不再 overwrite main、安全
  • ⚠️ 刪除類:reconcile 的「本地刪、雲端有 → 雲端贏」邏輯會吃掉你的刪除 — 要注意

✅ 問題 6:會不會打斷 user 操作?

  • renderApp(state):整頁重畫、scroll 跳走
  • ⚠️ renderTabs(state):只重畫 sidebar、不影響 main 內 scroll
  • ✅ partial update:最不擾人

✅ 問題 7:destructive op 嗎?要不要加 guard?

  • 刪掉一筆資料、大量清空、Reset 到 default、寫進雲端的 state 比現有少 N+ 個 IP
  • 對應 guard:confirm() 二次確認 / cloud diff guard / broadcast 拒收

上線前 checklist

Stress test

  • 兩瀏覽器、兩帳號、同時點同一功能 — 沒爆?沒丟資料?
  • UserA 操作後 1 秒內 UserB 操作 — race 處理對?
  • UserA 點新增、UserB 同時 broadcast — 新增沒消失?
  • 任一 user 刷新後、看到的資料一致?

Console 觀察

  • console.error
  • 沒 silent skip(return; 沒 log)
  • 關鍵分支都有 console.warn('[broadcast XYZ] ...') 帶 tag

Cloud 狀態

  • Supabase Studio 看 workspace.data 內容對
  • workspace.version 隨 save 遞增
  • workspace_history 有自動快照
  • 沒 row 變空 / 變 default state

UX

  • 別人操作時、我這邊 scroll 不會跳
  • 別人操作時、我在打字不會中斷
  • toast 顯示 + 有 refresh 按鈕
  • 鎖 / presence avatar 即時更新

Code review

  • 改了 broadcast handler?回讀 Layer 3 章節對照
  • 改了 merge 邏輯?回讀 Layer 2 章節對照
  • 新增 schema 欄位?確認 normalizeState 有補 default
  • 新增 schema 欄位?確認 _mergeObject 有對應處理(特殊欄位?)

過了 = 提交。沒過 = 回去修。


何時 NOT 用這個 skill

  • 單機 / 單用戶 app — 用不到
  • 純讀資料、無寫入(看板 / Dashboard 唯讀) — 不用考慮 conflict
  • 寫入有自然 isolation(每個 user 寫自己的 row) — RLS 就解決
  • 不需要即時 — 走 polling / refresh 按鈕就行

不在範圍內

  • Operational Transformation (OT) — 比 CRDT 更老的方案、已被 CRDT 取代
  • WebRTC peer-to-peer 同步 — 用 server-mediated (Supabase) 即可
  • 離線優先 / Local-first — 需要更複雜的 conflict resolution
  • End-to-end encryption — 信任 server,沒做 E2EE

要做這些進階場景,回去看 Yjs 完整 docs + automerge.org。


範例 implementation

Fandora 財估工具:/Users/apple/Documents/Claude/Projects/年度規劃/財估forecasting/index.html (6 天 93+ commits、M-1 至 M-45 演進歷程都在 git log + .claude/skills/forecast-handoff/SKILL.md

What ships with it: 3 files

6.4 KB alongside SKILL.md

Keep looking

Skills are one crate of 326,499. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.