| 1 | // Copied verbatim from Docs (oficina/docs/fixtures/save-queue.mjs), the first |
| 2 | // implementation of the live-sync client; Slides is the second, and the |
| 3 | // extraction to a shared module is slice 4. Only the header name differs: |
| 4 | // Docs-Seq is Slides-Seq here. |
| 5 | // fixtures/save-queue.mjs — the editor's save queue, factored out of |
| 6 | // fixtures/editor-entry.js so its retry/drop logic can be exercised |
| 7 | // under plain Node with no DOM, no ProseMirror, and no real Y.Doc — |
| 8 | // see fixtures/save-queue.test.mjs. |
| 9 | // |
| 10 | // This is fix round 1's F1 fix. The original inline version in |
| 11 | // editor-entry.js requeued a failed batch unconditionally: right for |
| 12 | // a network blip or a 500 (worth retrying), catastrophic for a 4xx |
| 13 | // (the core has already looked at these exact bytes and refused |
| 14 | // them — retrying is futile, and worse, the NEXT flush would run |
| 15 | // Y.mergeUpdates(queue) over the requeued batch plus whatever the |
| 16 | // person typed since, merging every later edit into a batch that can |
| 17 | // never be accepted. A ~1.2MB paste that gets 413 would then also |
| 18 | // carry away everything typed afterwards, forever, with nothing |
| 19 | // shown on screen — reproduced for real against a running server |
| 20 | // before this fix went in. |
| 21 | // |
| 22 | // The fix: classify the response. A network failure (post() rejects) |
| 23 | // or a 5xx is retryable — our own fault or a transient one, so the |
| 24 | // bytes go back on the queue and a retry is scheduled. A 4xx is |
| 25 | // permanent — the bytes themselves are the problem, so the batch is |
| 26 | // dropped (never requeued, never merged into) and the failure is |
| 27 | // reported instead of hidden. Ordinary edits keep flowing through the |
| 28 | // same queue immediately afterwards, unaffected. |
| 29 | // |
| 30 | // post/mergeUpdates/setTimeoutFn are injected rather than imported so |
| 31 | // the test can drive this with tiny byte arrays, a stub network call, |
| 32 | // and a fake clock instead of real Yjs updates, a real fetch, and |
| 33 | // real timers — the control flow being tested does not depend on any |
| 34 | // of those being real. |
| 35 | export function createSaveQueue({ post, mergeUpdates, onStatus, debounceMs = 2000, setTimeoutFn = setTimeout }) { |
| 36 | let queue = [] |
| 37 | // Bytes currently in flight (already merged and sent, response not |
| 38 | // back yet) live here, separately from queue — not just so a |
| 39 | // concurrent enqueue() during the await doesn't get folded into a |
| 40 | // request already on the wire, but so pendingBytes() below can |
| 41 | // include them: bytes an in-flight fetch is carrying are exactly |
| 42 | // as at-risk from a same-tick tab close as bytes still sitting in |
| 43 | // queue (fix round 1's LOW finding), so beforeunload must be able |
| 44 | // to beacon both. |
| 45 | let inFlight = null |
| 46 | let timer = null |
| 47 | let status = 'ok' |
| 48 | |
| 49 | function report(next) { |
| 50 | // 'failed' is sticky: once bytes are known dropped, a later |
| 51 | // successful save must not quietly erase that history from |
| 52 | // view — the person still needs to know something did not make |
| 53 | // it, even after the next paragraph saves fine. |
| 54 | if (status === 'failed' && next !== 'failed') return |
| 55 | status = next |
| 56 | if (onStatus) onStatus(status) |
| 57 | } |
| 58 | |
| 59 | function scheduleRetry() { |
| 60 | if (!timer) timer = setTimeoutFn(flush, debounceMs) |
| 61 | } |
| 62 | |
| 63 | async function flush() { |
| 64 | timer = null |
| 65 | if (!queue.length) return |
| 66 | const merged = mergeUpdates(queue) |
| 67 | queue = [] |
| 68 | inFlight = merged |
| 69 | |
| 70 | let r |
| 71 | try { |
| 72 | r = await post(merged) |
| 73 | } catch (err) { |
| 74 | inFlight = null |
| 75 | queue.unshift(merged) |
| 76 | report('retrying') |
| 77 | scheduleRetry() |
| 78 | return |
| 79 | } |
| 80 | inFlight = null |
| 81 | |
| 82 | if (r.ok) { |
| 83 | report('ok') |
| 84 | return |
| 85 | } |
| 86 | if (r.status >= 400 && r.status < 500) { |
| 87 | // Permanent: drop it. Do not requeue, do not merge later edits |
| 88 | // into it — see this module's own header comment. |
| 89 | report('failed') |
| 90 | return |
| 91 | } |
| 92 | // 5xx: not the client's fault, worth retrying. |
| 93 | queue.unshift(merged) |
| 94 | report('retrying') |
| 95 | scheduleRetry() |
| 96 | } |
| 97 | |
| 98 | function enqueue(update) { |
| 99 | queue.push(update) |
| 100 | if (timer) return |
| 101 | timer = setTimeoutFn(flush, debounceMs) |
| 102 | } |
| 103 | |
| 104 | // pendingBytes merges whatever is still owed to the server — |
| 105 | // queued-but-unsent and in-flight-but-unconfirmed alike — into one |
| 106 | // update, for beforeunload to beacon. null when there is nothing |
| 107 | // to send. |
| 108 | function pendingBytes() { |
| 109 | const parts = [] |
| 110 | if (inFlight) parts.push(inFlight) |
| 111 | if (queue.length) parts.push(mergeUpdates(queue)) |
| 112 | if (!parts.length) return null |
| 113 | return parts.length === 1 ? parts[0] : mergeUpdates(parts) |
| 114 | } |
| 115 | |
| 116 | return { |
| 117 | enqueue, |
| 118 | flush, |
| 119 | pendingBytes, |
| 120 | get status() { return status }, |
| 121 | } |
| 122 | } |
| 123 | |