oficina / slides Public

Clone
git clone https://amadan.net/oficina/slides

Plain git — no account needed to clone.

Download

Download this file

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.
35export 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