1// Goblin Tools Pro â Magic ToDo Sync v2 replay core. 2// 3// The pure, testable heart of the v2 client contract (design §6.0). Given the 4// last-known-server SHADOW and a batch of outcome ENTRIES from the feed, it 5// replays them into the shadow (pure assignment â the server already arbitrated) 6// and reconciles the live TodoStore, keeping unpushed local work. It reproduces 7// the server's materialized document on replay (design I9). 8// 9// Deliberately depends ONLY on TodoStore (+ Task when present) â no jQuery, no 10// DOM, no network â so tests/sync-replay.test.js can load it into jsdom and 11// drive it directly. All network/lifecycle glue lives in todo-pro-sync.js. 12// 13// Shadow shape (same as the v1 commitShadow): 14// { tabs: { [tabId]: { name, order, timestamp, items: {[id]: wire}, orderList: [id,...] } } } 15 16var TodoSyncReplay = (function () { 17 'use strict'; 18 19 // ---- Small self-contained helpers (v1 code isn't loaded in tests) ------ 20 21 function arraysEqual(a, b) { 22 if (!a || !b || a.length !== b.length) return false; 23 for (var i = 0; i < a.length; i++) if (String(a[i]) !== String(b[i])) return false; 24 return true; 25 } 26 27 /** Sparse wire projection of a live todo â mirrors the server's stored shape. */ 28 function toWire(todo) { 29 var item = { 30 id: String(todo.id), 31 parentId: todo.parentId || null, 32 text: todo.text || '' 33 }; 34 if (todo.isCompleted === true) item.isCompleted = true; 35 if (todo.category) item.category = todo.category; 36 if (typeof todo.estimate === 'number') item.estimate = todo.estimate; 37 if (todo.timestamp) item.timestamp = todo.timestamp; 38 if (todo.deadline) item.deadline = todo.deadline; 39 if (typeof todo.elapsed === 'number' && todo.elapsed > 0) item.elapsed = todo.elapsed; 40 return item; 41 } 42 43 function itemsEqual(a, b) { 44 if (!a || !b) return false; 45 return (a.text || '') === (b.text || '') 46 && !!a.isCompleted === !!b.isCompleted 47 && (a.category || '') === (b.category || '') 48 && (a.estimate == null ? null : a.estimate) === (b.estimate == null ? null : b.estimate) 49 && (a.deadline || '') === (b.deadline || '') 50 && (a.elapsed == null ? null : a.elapsed) === (b.elapsed == null ? null : b.elapsed) 51 && (a.parentId || null) === (b.parentId || null); 52 } 53 54 /** 55 * Apply the same normalizations the SERVER applies on write, so a client can 56 * tell "the server clamped my own value" (adopt it, don't loop) from "a peer 57 * changed it" (a real remote edit). Keep this list in lockstep with the 58 * server's "Server-side transformations" (design §3.1): text clamp, number 59 * rounding. (Ordering/dedup/cascade are structural, not per-item content.) 60 */ 61 function serverNormalize(wire) { 62 var w = Object.assign({}, wire); 63 if (typeof w.text === 'string' && w.text.length > 4000) w.text = w.text.slice(0, 4000); 64 if (typeof w.estimate === 'number') w.estimate = Math.round(w.estimate); 65 if (typeof w.elapsed === 'number') w.elapsed = Math.round(w.elapsed); 66 return w; 67 } 68 69 function makeTask(wire, tabId) { 70 var obj = Object.assign({}, wire, { tabId: tabId }); 71 return (typeof Task !== 'undefined' && Task.fromObject) ? Task.fromObject(obj) : obj; 72 } 73 74 function emptyTab() { return { name: '', order: 0, timestamp: 0, items: {}, orderList: [] }; } 75 76 // ---- Order merge (design §6.0) ----------------------------------------- 77 // 78 // The server's `order` entry carries the FULL stored sequence for the items 79 // it knows â authoritative, nesting included. Adopt it verbatim for those 80 // items, and hold a LOCAL-ONLY item (one not in the server sequence â an 81 // unpushed add) in the live slot it occupies. 82 // 83 // A single server-order queue does exactly that: walk live order, and each 84 // time we pass a server-known item, emit the NEXT item from the server 85 // sequence (so server-known items come out in server order regardless of 86 // where they sit in live), while a local-only item is emitted where it is. 87 // (An earlier per-sibling-group split corrupted nesting â server order 88 // [B,A,c] came out [B,c,A], a child before its parent. Never re-introduce it.) 89 // Returns the merged list (a permutation of liveForTab) or null when equal. 90 function mergeOrder(liveForTab, serverIds) { 91 var byId = {}; 92 liveForTab.forEach(function (t) { byId[String(t.id)] = t; }); 93 94 var named = {}; 95 serverIds.forEach(function (id) { named[String(id)] = true; }); 96 97 // The server sequence, restricted to items we actually hold, in order. 98 var queue = [];
99 serverIds.forEach(function (id) { if (byId[String(id)]) queue.push(byId[String(id)]); }); 100 101 var qi = 0; 102 var merged = []; 103 liveForTab.forEach(function (t) { 104 if (named[String(t.id)]) { 105 if (qi < queue.length) merged.push(queue[qi++]); 106 } else { 107 merged.push(t); // local-only item keeps its live slot 108 } 109 }); 110 while (qi < queue.length) merged.push(queue[qi++]); 111 112 var liveIds = liveForTab.map(function (t) { return String(t.id); }); 113 var mergedIds = merged.map(function (t) { return String(t.id); }); 114 return arraysEqual(liveIds, mergedIds) ? null : merged; 115 } 116 117 function liveTodosForTab(tabId) { 118 return (TodoStore.getTodos() || []).filter(function (t) { return t.tabId === tabId; }); 119 } 120 121 // ---- Replay ------------------------------------------------------------ 122 123 /** 124 * Replay a batch of feed entries (in list order) into the shadow and the live 125 * store. Returns { changed, pushNeeded }: `changed` = the live store was 126 * mutated (caller repaints/persists); `pushNeeded` = a local value was kept 127 * over the server's and must be re-uploaded. 128 * 129 * `opts.genId` â required for the `deleted`-rejection rescue (re-add under a 130 * fresh id). `opts.defaultTabName` â for an invented survivor tab. 131 */ 132 function applyEntries(shadow, entries, rejections, opts) { 133 opts = opts || {}; 134 shadow.tabs = shadow.tabs || {}; 135 var state = { shadow: shadow, changed: false, pushNeeded: false }; 136 137 // A tab whose orderTab was rejected this response defers its LIVE order 138 // decision to applyRejection (which knows what we pushed). applyOrder still 139 // adopts the winning sequence into the SHADOW, but leaves live alone for 140 // these tabs â otherwise its stale-shadow keep-local rule would fight the 141 // rejection handler (design §6.0, stale-order clause). 142 state.rejectedOrderTabs = {}; 143 (rejections || []).forEach(function (r) { 144 if (r && r.reason === 'stale-order' && r.tabId) state.rejectedOrderTabs[r.tabId] = true; 145 }); 146 state.pushedOrders = opts.pushedOrders || {}; 147 148 (entries || []).forEach(function (e) { 149 if (!e || !e.op) return; 150 if (e.op === 'replace') { applyReplace(state, e, opts); return; } 151 152 var tabId = e.tabId; 153 if (e.op === 'deleteTab') { applyDeleteTab(state, tabId, opts); return; } 154 if (!tabId) return; 155 156 var ts = shadow.tabs[tabId] || (shadow.tabs[tabId] = emptyTab()); 157 158 if (e.op === 'tab') applyTabMeta(state, tabId, ts, e); 159 else if (e.op === 'items') applyItems(state, tabId, ts, e); 160 else if (e.op === 'deleteItems') applyDeleteItems(state, tabId, ts, e); 161 else if (e.op === 'order') applyOrder(state, tabId, ts, e); 162 }); 163 164 (rejections || []).forEach(function (r) { applyRejection(state, r, opts); }); 165 166 return { changed: state.changed, pushNeeded: state.pushNeeded }; 167 } 168 169 function applyTabMeta(state, tabId, ts, e) { 170 // Pre-replay shadow snapshot, then assign into the shadow. 171 var pre = { name: ts.name, order: ts.order }; 172 ts.name = e.name != null ? e.name : ts.name; 173 ts.order = e.order != null ? e.order : ts.order; 174 ts.timestamp = e.timestamp != null ? e.timestamp : ts.timestamp; 175 176 var existing = (TodoStore.getTabs() || []).filter(function (t) { return t.id === tabId; })[0]; 177 if (!existing) { 178 TodoStore.dispatch('ADD_TAB', { 179 tab: { id: tabId, name: ts.name, order: ts.order, timestamp: ts.timestamp }, 180 skipHistory: true 181 }); 182 state.changed = true; 183 return; 184 } 185 // Keep a local rename/reorder that the server hasn't got (live differs from 186 // the pre-replay shadow); the next diff re-pushes it on the fresh base. 187 if (existing.name !== pre.name || existing.order !== pre.order) { 188 state.pushNeeded = true; 189 return; 190 } 191 if (existing.name !== ts.name || existing.order !== ts.order) { 192 TodoStore.dispatch('UPDATE_TAB', { 193 tabId: tabId, 194 updates: { name: ts.name, order: ts.order, timestamp: ts.timestamp }, 195 skipHistory: true 196 }); 197 state.changed = true; 198 } 199 } 200 201 function applyItems(state, tabId, ts, e) {
202 (e.items || []).forEach(function (item) { 203 var id = String(item.id); 204 var pre = ts.items[id]; // per-entry pre-replay snapshot 205 ts.items[id] = item; // shadow adopts unconditionally 206 if (ts.orderList.indexOf(id) === -1) ts.orderList.push(id); // append at end (mirror server) 207 208 var live = TodoStore.getTodo(id); 209 210 if (!live) { 211 // Absent locally: an unpushed local DELETE if the pre-replay shadow 212 // had it (don't re-add â the next diff re-emits the delete and wins); 213 // otherwise a genuine peer add / legit re-add â adopt. 214 if (pre) { state.pushNeeded = true; return; } 215 TodoStore.dispatch('ADD_TODO', { todo: makeTask(item, tabId), skipHistory: true }); 216 state.changed = true; 217 return; 218 } 219 220 var liveWire = toWire(live); 221 if (itemsEqual(liveWire, item)) { 222 // Already matches the stored value; just ensure tab membership. 223 if (live.tabId !== tabId) { live.tabId = tabId; state.changed = true; } 224 return; 225 } 226 227 // The server merely clamped OUR OWN value (e.g. text > 4000): adopt it 228 // rather than reading it as an unpushed edit and looping (R18). 229 if (itemsEqual(serverNormalize(liveWire), item)) { 230 adoptItem(state, id, item, tabId); 231 return; 232 } 233 234 // Live differs from the pre-replay shadow (unpushed edit), or live is 235 // strictly newer than the entry (a revert during the round trip): keep 236 // local; the next diff re-pushes on the fresh base and wins under rev 237 // arbitration. 238 var localNewer = live.timestamp != null && item.timestamp != null && live.timestamp > item.timestamp; 239 if ((pre && !itemsEqual(liveWire, pre)) || (!pre) || localNewer) { 240 state.pushNeeded = true; 241 if (live.tabId !== tabId) { live.tabId = tabId; state.changed = true; } 242 return; 243 } 244 245 adoptItem(state, id, item, tabId); 246 }); 247 } 248 249 function adoptItem(state, id, item, tabId) { 250 TodoStore.dispatch('UPDATE_TODO', { 251 todoId: id, 252 updates: { 253 text: item.text, 254 isCompleted: !!item.isCompleted, 255 category: item.category, 256 estimate: item.estimate, 257 deadline: item.deadline, 258 elapsed: item.elapsed, 259 parentId: item.parentId, 260 tabId: tabId, 261 timestamp: (item.timestamp != null ? item.timestamp : 0) 262 }, 263 skipHistory: true 264 }); 265 state.changed = true; 266 } 267 268 function applyDeleteItems(state, tabId, ts, e) { 269 var ids = (e.itemIds || []).map(String); 270 var idSet = {}; 271 ids.forEach(function (id) { idSet[id] = true; delete ts.items[id]; }); 272 ts.orderList = ts.orderList.filter(function (id) { return !idSet[String(id)]; }); 273 274 ids.forEach(function (id) { 275 if (TodoStore.getTodo(id)) { 276 TodoStore.dispatch('DELETE_TODO', { todoId: id, skipHistory: true }); 277 state.changed = true; 278 } 279 }); 280 281 // Re-root a locally-added, never-pushed child of a just-deleted parent â 282 // the server cascade can only reach server-known descendants (design R19). 283 (TodoStore.getTodos() || []).forEach(function (t) { 284 if (t.tabId === tabId && t.parentId && idSet[String(t.parentId)] && !idSet[String(t.id)]) { 285 TodoStore.dispatch('UPDATE_TODO', { todoId: t.id, updates: { parentId: null }, skipHistory: true }); 286 state.changed = true; 287 state.pushNeeded = true; 288 } 289 }); 290 } 291 292 function applyOrder(state, tabId, ts, e) { 293 var serverIds = (e.itemIds || []).map(String); 294 var preOrder = ts.orderList.slice(); 295 ts.orderList = serverIds.slice(); // shadow always adopts the winning sequence 296 297 // This tab's order was rejected this response â the rejection handler owns 298 // the live decision (it knows what we pushed). Shadow is set above; stop. 299 if (state.rejectedOrderTabs[tabId]) return; 300 301 var liveForTab = liveTodosForTab(tabId); 302 var liveIds = liveForTab.map(function (t) { return String(t.id); }); 303 304 // Keep a local reorder the server hasn't got: live order differs from the 305 // pre-replay shadow order (on the ids they share). Re-push on the fresh base. 306 var sharedLive = liveIds.filter(function (id) { return preOrder.indexOf(id) !== -1; }); 307 var sharedPre = preOrder.filter(function (id) { return liveIds.indexOf(id) !== -1; }); 308 if (!arraysEqual(sharedLive, sharedPre)) { state.pushNeeded = true; return; } 309 310 var merged = mergeOrder(liveForTab, serverIds); 311 if (merged) { 312 TodoStore.dispatch('REORDER', { todos: merged, skipHistory: true }); 313 state.changed = true; 314 } 315 } 316 317 function applyDeleteTab(state, tabId, opts) { 318 var known = state.shadow.tabs[tabId]; 319 var inTab = (TodoStore.getTodos() || []).filter(function (t) { return t.tabId === tabId; }); 320 321 // Rescue items carrying unpushed work (server never had them, or the local 322 // copy differs from the shadow) onto a survivor tab, inventing one if none 323 // remains â deleting the last tab must not drop unpushed work. 324 var rescued = {}; 325 inTab.forEach(function (t) { 326 var ack = known && known.items && known.items[String(t.id)]; 327 if (!ack || !itemsEqual(ack, toWire(t))) rescued[String(t.id)] = true; 328 }); 329 var rescuedIds = Object.keys(rescued); 330 331 if (rescuedIds.length > 0) { 332 var survivor = (TodoStore.getTabs() || []) 333 .filter(function (t) { return t.id !== tabId; }) 334 .sort(function (a, b) { return (a.order || 0) - (b.order || 0); })[0]; 335 if (!survivor && opts.genId) { 336 survivor = { id: opts.genId(), name: opts.defaultTabName || 'ToDo', order: 0, timestamp: Date.now && 0 }; 337 TodoStore.dispatch('ADD_TAB', { tab: survivor, skipHistory: true }); 338 } 339 if (survivor) { 340 inTab.forEach(function (t) { 341 if (!rescued[String(t.id)]) return; 342 var updates = { tabId: survivor.id }; 343 if (t.parentId && !rescued[String(t.parentId)]) updates.parentId = null; 344 TodoStore.dispatch('UPDATE_TODO', { todoId: t.id, updates: updates, skipHistory: true }); 345 }); 346 state.pushNeeded = true; 347 } 348 } 349 350 TodoStore.dispatch('DELETE_TAB', { tabId: tabId, skipHistory: true }); 351 delete state.shadow.tabs[tabId]; 352 state.changed = true; 353 } 354 355 // ---- Replace (full resync) --------------------------------------------- 356 357 function applyReplace(state, e, opts) { 358 var priorTabs = state.shadow.tabs || {}; 359 var nextTabs = {}; 360 361 var snapshotTabIds = {};
362 (e.tabs || []).forEach(function (tabDoc) { 363 var tabId = tabDoc.tabId || tabDoc.TabId || tabDoc.id; 364 if (!tabId) return; 365 snapshotTabIds[tabId] = true; 366 367 var itemsById = {}; 368 var orderList = []; 369 (tabDoc.items || []).forEach(function (i) { 370 var id = String(i.id); 371 itemsById[id] = i; 372 orderList.push(id); 373 }); 374 nextTabs[tabId] = { 375 name: tabDoc.name, order: tabDoc.order, timestamp: tabDoc.timestamp, 376 items: itemsById, orderList: orderList 377 }; 378 379 var existing = (TodoStore.getTabs() || []).filter(function (t) { return t.id === tabId; })[0]; 380 if (!existing) { 381 TodoStore.dispatch('ADD_TAB', { tab: { id: tabId, name: tabDoc.name, order: tabDoc.order, timestamp: tabDoc.timestamp }, skipHistory: true }); 382 state.changed = true; 383 } else if (existing.name !== tabDoc.name || existing.order !== tabDoc.order) { 384 TodoStore.dispatch('UPDATE_TAB', { tabId: tabId, updates: { name: tabDoc.name, order: tabDoc.order, timestamp: tabDoc.timestamp }, skipHistory: true }); 385 state.changed = true; 386 } 387 388 reconcileReplaceItems(state, tabId, tabDoc, priorTabs[tabId]); 389 }); 390 391 // A local item present in the PRIOR shadow but absent from the snapshot was 392 // deleted server-side; drop it. A tab likewise absent is a remote tab 393 // delete. Items/tabs the prior shadow never had are local-only â keep and 394 // re-push. (Pairing-failure resyncs pass opts.staleShadow to fall back to 395 // per-item timestamp; not modelled in this pure core beyond keeping local.) 396 (TodoStore.getTabs() || []).slice().forEach(function (tab) { 397 if (snapshotTabIds[tab.id]) return; 398 if (priorTabs[tab.id]) { applyDeleteTab(state, tab.id, opts); } 399 else { state.pushNeeded = true; } // local-only tab â keep, re-push 400 }); 401 402 state.shadow.tabs = nextTabs; 403 } 404 405 function reconcileReplaceItems(state, tabId, tabDoc, priorTab) { 406 var incoming = {}; 407 (tabDoc.items || []).forEach(function (i) { incoming[String(i.id)] = i; }); 408 409 // Adopt/add each incoming item unless a strictly-newer local copy exists. 410 (tabDoc.items || []).forEach(function (item) { 411 var id = String(item.id); 412 var live = TodoStore.getTodo(id); 413 if (!live) { TodoStore.dispatch('ADD_TODO', { todo: makeTask(item, tabId), skipHistory: true }); state.changed = true; return; } 414 if (itemsEqual(toWire(live), item)) { if (live.tabId !== tabId) { live.tabId = tabId; state.changed = true; } return; } 415 var localNewer = live.timestamp != null && item.timestamp != null && live.timestamp > item.timestamp; 416 if (localNewer) { state.pushNeeded = true; if (live.tabId !== tabId) { live.tabId = tabId; state.changed = true; } return; } 417 adoptItem(state, id, item, tabId); 418 }); 419 420 // Delete a local item this tab's prior shadow had but the snapshot omits. 421 var priorItems = (priorTab && priorTab.items) || {}; 422 liveTodosForTab(tabId).forEach(function (t) { 423 var id = String(t.id); 424 if (incoming[id]) return; 425 if (priorItems[id]) { TodoStore.dispatch('DELETE_TODO', { todoId: id, skipHistory: true }); state.changed = true; } 426 else { state.pushNeeded = true; } // local-only add â keep, re-push 427 }); 428 429 // Adopt the snapshot order. 430 var ordered = []; 431 (tabDoc.items || []).forEach(function (item) { var t = TodoStore.getTodo(item.id); if (t) ordered.push(t); }); 432 if (ordered.length > 0) { TodoStore.dispatch('REORDER', { todos: ordered, skipHistory: true }); state.changed = true; } 433 } 434 435 // ---- Rejections (design §6.0) ------------------------------------------ 436 437 function applyRejection(state, r, opts) { 438 if (!r || !r.reason) return; 439 440 if (r.reason === 'stale-order') { 441 // The winning order already arrived as an `order` entry and is in the 442 // shadow (applyOrder set it). Snap live to it ONLY IF live still equals 443 // the order we pushed (we genuinely lost the race). If the user dragged 444 // again mid-flight, live differs from what we pushed â keep it and let 445 // the next diff re-push the newer order on the fresh (shadow) base. 446 var ts = state.shadow.tabs[r.tabId]; 447 if (!ts) return; 448 var liveForTab = liveTodosForTab(r.tabId); 449 var liveIds = liveForTab.map(function (t) { return String(t.id); }); 450 var pushed = state.pushedOrders[r.tabId]; 451 452 if (pushed && arraysEqual(liveIds, pushed.map(String))) { 453 var merged = mergeOrder(liveForTab, ts.orderList); 454 if (merged) { TodoStore.dispatch('REORDER', { todos: merged, skipHistory: true }); state.changed = true; } 455 } else { 456 state.pushNeeded = true; // live moved on â re-push the newer order 457 } 458 return; 459 } 460 461 if (r.reason === 'deleted') {
462 (r.itemIds || []).forEach(function (id) { 463 id = String(id); 464 var ts = state.shadow.tabs[r.tabId]; 465 // A v1 resurrection may have re-added it: if the shadow now holds it, 466 // it's a live server item â keep. Otherwise rescue the local copy 467 // under a FRESH id so it survives without re-triggering the delete. 468 if (ts && ts.items && ts.items[id]) return; 469 var live = TodoStore.getTodo(id); 470 if (!live || !opts.genId) return; 471 var fresh = makeTask(Object.assign(toWire(live), { id: opts.genId() }), live.tabId); 472 TodoStore.dispatch('DELETE_TODO', { todoId: id, skipHistory: true }); 473 TodoStore.dispatch('ADD_TODO', { todo: fresh, skipHistory: true }); 474 state.changed = true; 475 state.pushNeeded = true; 476 }); 477 return; 478 } 479 480 if (r.reason === 'cap') { 481 // Keep the item/tab local and dirty; the caller surfaces the toast and 482 // stops self-arming. Nothing to mutate here. 483 state.capRefused = state.capRefused || []; 484 state.capRefused.push(r); 485 return; 486 } 487 // 'resync' is handled by the caller (it triggers a /feed?since=0 replace). 488 } 489 490 return { 491 applyEntries: applyEntries, 492 // exposed for the glue layer + tests 493 toWire: toWire, 494 itemsEqual: itemsEqual, 495 mergeOrder: mergeOrder, 496 serverNormalize: serverNormalize, 497 _emptyTab: emptyTab 498 }; 499})(); 500 501if (typeof window !== 'undefined') window.TodoSyncReplay = TodoSyncReplay; 502if (typeof module !== 'undefined' && module.exports) module.exports = TodoSyncReplay;
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.