PageSourceSearch

https://goblin.tools/js/todo-sync-replay.js?v=WGdcnEpCyPnS2B7Tj8ZY4a7C36LBEAjLhsMPWv3vPmc

js goblin.tools collected 2026-10-01 11:37:13 UTC 23,646 bytes, 502 lines download raw bytes

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.