oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

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