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
npx -y skills add komashiromomo/multi-user-sync-skillAssembled 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+ 用戶同時讀寫同一份雲端狀態的功能,都應該先讀這份。
目錄
- 為什麼這個 skill 存在
- 4 層保護架構
- 全局原則
- 技術棧建議
- 起手式 SOP
- Layer 1 鎖機制
- Layer 2 樂觀鎖 + 3-way merge
- Layer 3 Broadcast Handler
- Layer 4 Yjs CRDT
- Side-channel Presence
- 6 個歷史災難
- 加新 sync feature 前的 7 個必問
- 上線前 checklist
- 何時 NOT 用這個 skill / 不在範圍內
為什麼這個 skill 存在
多人同編看起來「就是接個 realtime 就好」,但實際上有 6 個你不會想到的坑:
- 資料覆蓋 — UserA 還沒存完,UserB save 把 A 的變動吃掉
- 刪除復活 — A 刪了 IP、B 的舊狀態又把它推回雲端
- 新增消失 — A 點「新增」後 2 秒內,B 的 broadcast 把新項目蓋掉
- 打字丟字 — 兩人同編同一個 textarea、字元級交錯
- scroll 跳頂端 — 別人 save 觸發本地重畫、user 的捲軸位置歸零
- 災難覆蓋 — 某 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_historytable、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 + realtime | Supabase | postgres_changes 內建、免費 tier 夠用、RLS 強 |
| 部署 | Vercel | git 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 table | upsert 一個 row、樂觀鎖容易 |
| state 版本 | workspace.version integer | UPDATE WHERE version=expected 樂觀鎖核心 |
| 歷史備份 | workspace_history table + trim trigger | 災難回復不可少 |
起手式(new project)
- 建 Supabase project:開 Auth (Google OAuth)、建
workspacetable(id PK, data jsonb, version int, updated_at, updated_by)、建workspace_history同 schema + trim trigger - 加 RLS policy:限定特定 email 域名(如
@yourcompany.com) - Frontend 接 Supabase JS:login → loadStateFromCloud → subscribe postgres_changes
- 先做 Layer 2(樂觀鎖)+ Layer 3(broadcast handler)
- 驗證 stress test:兩瀏覽器並行操作 30 次、確認資料無 loss
- 再考慮 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 inspector | acquireTaskLock(task) |
| 切離 task | releaseTaskLock(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 → 解:
remoteIsOnlyLockChangediff 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-onlyhasActiveInput=false→state = 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 cause:reconcileSidebarFromRemote 用 _baseState.ips 判斷「對方刪了」。但 broadcast handler 順序錯:
- 先更新
_baseState = remote - 才呼叫
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 → 又收 → ...
修法:
remoteIsOnlyLockChange偵測純鎖變動 → silent path_mergeEditingBy(local, remote)同 email 採較晚 until → heartbeat 不算 conflict
教訓:heartbeat 這類「自動觸發 save」的功能很容易跟 sync 邏輯互打。任何「rate-limited 自動寫入」都要有對應的「detect 純 X 變動」short-circuit。
通用教訓 summary
- 時序很重要 — 要 diff 就先抓 snapshot
- destructive op 必須可逆 + 雙層 guard
- pending 變動有 grace period 不可侵犯
- user UX > 自動同步(不要打斷 user)
- 自動觸發的寫入(heartbeat)要有 short-circuit
- 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
- CHANGELOG.md857 B
- LICENSE1.1 KB
- README.md4.5 KB