@panphora/clayjs 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/clay.standalone.js +6070 -4017
- package/package.json +1 -1
- package/src/attrs/add-from.js +72 -0
- package/src/core/conflict-notice.js +2 -5
- package/src/core/conflict-presentation.js +42 -0
- package/src/core/save-core.js +22 -0
- package/src/core/save.js +11 -2
- package/src/lib/region-capabilities.js +1 -0
- package/src/lib/region-policy.js +10 -3
- package/src/live/checkpoint.js +106 -0
- package/src/live/fast-lane.js +590 -0
- package/src/live/local-lane.js +582 -0
- package/src/live/ops.js +291 -0
- package/src/live/server-lane.js +84 -0
- package/src/live/store.js +291 -0
- package/src/live/stream-bridge.js +60 -0
- package/src/loader-logic.js +7 -2
- package/src/loader.js +2 -0
- package/src/plugins/live.js +446 -0
- package/src/sync/conflict-revert.js +1 -1
- package/src/sync/live-sync.js +21 -5
- package/src/sync/presence.js +4 -10
- package/src/sync/stream.js +2 -1
- package/src/vendor/hyper-morph.vendor.js +6 -4
- package/src/sync/section-notice.js +0 -254
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [add-from] Custom Attribute
|
|
3
|
+
*
|
|
4
|
+
* Adds a new item from a <template> on click. Edit mode only: this module is in
|
|
5
|
+
* the edit-only wave, so a visitor's page never loads it.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <button add-from="#task-tpl" add-to="#tasks">Add task</button>
|
|
9
|
+
*
|
|
10
|
+
* The template's first element is cloned, given a fresh data-id unless it already
|
|
11
|
+
* has one (templates carry none), and appended to the add-to element. Its first
|
|
12
|
+
* editable field is focused with its text selected, so typing replaces the
|
|
13
|
+
* placeholder. Ids are built without crypto.randomUUID, which is missing outside
|
|
14
|
+
* a secure context.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const FIELD = '[contenteditable]:not([contenteditable="false"]), input, textarea';
|
|
18
|
+
|
|
19
|
+
export function newId() {
|
|
20
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function find(selector) {
|
|
24
|
+
if (!selector) return null;
|
|
25
|
+
try {
|
|
26
|
+
return document.querySelector(selector);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function focusField(item) {
|
|
33
|
+
const field = item.matches(FIELD) ? item : item.querySelector(FIELD);
|
|
34
|
+
if (!field) return;
|
|
35
|
+
field.focus();
|
|
36
|
+
if (typeof field.select === "function") return field.select();
|
|
37
|
+
const range = document.createRange();
|
|
38
|
+
range.selectNodeContents(field);
|
|
39
|
+
const selection = window.getSelection();
|
|
40
|
+
selection.removeAllRanges();
|
|
41
|
+
selection.addRange(range);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function addFrom(trigger) {
|
|
45
|
+
const template = find(trigger.getAttribute("add-from"));
|
|
46
|
+
const list = find(trigger.getAttribute("add-to"));
|
|
47
|
+
const blueprint = template?.content?.firstElementChild;
|
|
48
|
+
if (!blueprint || !list) {
|
|
49
|
+
console.warn("[add-from] add-from must name a <template> and add-to the element to add into", trigger);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
const item = blueprint.cloneNode(true);
|
|
53
|
+
if (!item.getAttribute("data-id")) item.setAttribute("data-id", newId());
|
|
54
|
+
list.append(item);
|
|
55
|
+
focusField(item);
|
|
56
|
+
return item;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onClick(event) {
|
|
60
|
+
const trigger = event.target.closest?.("[add-from]");
|
|
61
|
+
if (!trigger) return;
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
addFrom(trigger);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function init() {
|
|
67
|
+
document.addEventListener("click", onClick);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
init();
|
|
71
|
+
|
|
72
|
+
export default init;
|
|
@@ -37,6 +37,7 @@ const MINIMIZE_SVG = '<svg viewBox="0 0 12 12" width="12" height="12" aria-hidde
|
|
|
37
37
|
const plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
38
38
|
|
|
39
39
|
const BLOCKED_ROW = "This spot changed again. Review the latest edit or download your copy.";
|
|
40
|
+
const DATA_BLOCKED_ROW = "A data value can't be put back from here. Download your copy to keep yours.";
|
|
40
41
|
|
|
41
42
|
let view = "hidden";
|
|
42
43
|
let listOpen = false;
|
|
@@ -173,10 +174,6 @@ function render() {
|
|
|
173
174
|
root.append(drawPanel(m));
|
|
174
175
|
}
|
|
175
176
|
set(root, "display", toastText || view !== "hidden" ? "flex" : "none");
|
|
176
|
-
if (view !== "hidden") {
|
|
177
|
-
const section = document.querySelector("[data-clay-section-notice]");
|
|
178
|
-
if (section) set(section, "display", "none");
|
|
179
|
-
}
|
|
180
177
|
if (wellEl) wellEl.scrollTop = scroll;
|
|
181
178
|
|
|
182
179
|
const want = pendingFocus || focusKey;
|
|
@@ -514,7 +511,7 @@ async function onRevert() {
|
|
|
514
511
|
document.removeEventListener("focus", track, true);
|
|
515
512
|
}
|
|
516
513
|
busy = null;
|
|
517
|
-
for (const id of result.blockedIds) blockedRows.set(id, BLOCKED_ROW);
|
|
514
|
+
for (const id of result.blockedIds) blockedRows.set(id, ledger()?.get(id)?.kind === "json" ? DATA_BLOCKED_ROW : BLOCKED_ROW);
|
|
518
515
|
view = mode() ? "bar" : "hidden";
|
|
519
516
|
pendingFocus = stayed ? "review" : null;
|
|
520
517
|
const saving = result.saveResult && (result.saveResult.ok || result.saveResult.msg === "Save already in progress");
|
|
@@ -262,6 +262,41 @@ function recoveryRow(record, rv) {
|
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
264
|
|
|
265
|
+
// A JSON conflict path ('players[id="alice"].x', 'grid[0][1]', '["a b"]') as the words
|
|
266
|
+
// a person reads: players → alice → x. A keyed segment shows its key's value.
|
|
267
|
+
export function jsonPathWords(path) {
|
|
268
|
+
const words = [];
|
|
269
|
+
let i = 0;
|
|
270
|
+
while (i < path.length) {
|
|
271
|
+
if (path[i] === ".") { i++; continue; }
|
|
272
|
+
if (path[i] === "[") {
|
|
273
|
+
let j = i + 1;
|
|
274
|
+
let inString = false;
|
|
275
|
+
for (; j < path.length; j++) {
|
|
276
|
+
const ch = path[j];
|
|
277
|
+
if (inString) {
|
|
278
|
+
if (ch === "\\") j++;
|
|
279
|
+
else if (ch === '"') inString = false;
|
|
280
|
+
} else if (ch === '"') inString = true;
|
|
281
|
+
else if (ch === "]") break;
|
|
282
|
+
}
|
|
283
|
+
let inner = path.slice(i + 1, j);
|
|
284
|
+
const keyed = /^[A-Za-z_$][\w$]*=/.exec(inner);
|
|
285
|
+
if (keyed) inner = inner.slice(keyed[0].length);
|
|
286
|
+
try { words.push(String(JSON.parse(inner))); } catch { words.push(inner); }
|
|
287
|
+
i = j + 1;
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
let j = i;
|
|
291
|
+
while (j < path.length && path[j] !== "." && path[j] !== "[") j++;
|
|
292
|
+
words.push(path.slice(i, j));
|
|
293
|
+
i = j;
|
|
294
|
+
}
|
|
295
|
+
return words.join(" → ");
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const jsonValue = (v) => (v === undefined ? "(deleted)" : JSON.stringify(v));
|
|
299
|
+
|
|
265
300
|
export function rowOf(record) {
|
|
266
301
|
const id = record.id;
|
|
267
302
|
if (record.recovery?.version === 1) return recoveryRow(record, record.recovery);
|
|
@@ -283,6 +318,13 @@ export function rowOf(record) {
|
|
|
283
318
|
yours: both ? side(plain(record.local)) : null, now: both ? side(plain(record.remote)) : null, sentence,
|
|
284
319
|
};
|
|
285
320
|
}
|
|
321
|
+
if (record.kind === "json") {
|
|
322
|
+
const where = jsonPathWords(record.path || "");
|
|
323
|
+
return {
|
|
324
|
+
id, kind: "json", name: where ? `${record.name} → ${where}` : record.name, target: null,
|
|
325
|
+
yours: side(jsonValue(record.local)), now: side(jsonValue(record.resolved)), sentence: null,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
286
328
|
const host = hostOf(record.node);
|
|
287
329
|
const el = elementOf(record.node);
|
|
288
330
|
const name = el && HIDDEN.has(el.localName) ? kindName(el)
|
package/src/core/save-core.js
CHANGED
|
@@ -43,6 +43,20 @@ let nextSaveKeepalive = false;
|
|
|
43
43
|
// CustomEvent would put it on a bus any script on the page can listen to.
|
|
44
44
|
const saveAcceptedHooks = [];
|
|
45
45
|
|
|
46
|
+
// Headers another module asks every save to carry, by name.
|
|
47
|
+
const extraSaveHeaders = new Map();
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Add a header to every save from now on, or remove it with a null value.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} name
|
|
53
|
+
* @param {string|null} value
|
|
54
|
+
*/
|
|
55
|
+
export function setSaveHeader(name, value) {
|
|
56
|
+
if (value == null) extraSaveHeaders.delete(name);
|
|
57
|
+
else extraSaveHeaders.set(name, String(value));
|
|
58
|
+
}
|
|
59
|
+
|
|
46
60
|
/**
|
|
47
61
|
* Send the next save with `keepalive`, so a request whose page is going away (a tab
|
|
48
62
|
* closing, a navigation) is still finished by the transport.
|
|
@@ -238,6 +252,11 @@ function errorResult(err) {
|
|
|
238
252
|
// Live sync compares it with the frames it merges to know when the refusal
|
|
239
253
|
// is answered.
|
|
240
254
|
conflictEtag: conflicted ? (err.etag ?? null) : null,
|
|
255
|
+
// The host's answer, null when none came (a lost connection or a timeout).
|
|
256
|
+
status: Number.isInteger(err.status) ? err.status : null,
|
|
257
|
+
// An attempt of this save timed out, so it may have landed whatever the
|
|
258
|
+
// final answer says.
|
|
259
|
+
hadTimeout: timedOut || err.hadTimeout === true,
|
|
241
260
|
etag: null
|
|
242
261
|
};
|
|
243
262
|
}
|
|
@@ -312,6 +331,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
|
|
|
312
331
|
body: content
|
|
313
332
|
};
|
|
314
333
|
|
|
334
|
+
for (const [name, value] of extraSaveHeaders) options.headers[name] = value;
|
|
335
|
+
|
|
315
336
|
if (etag) options.headers['If-Match'] = etag;
|
|
316
337
|
|
|
317
338
|
if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
|
|
@@ -559,6 +580,7 @@ async function sendSave(content) {
|
|
|
559
580
|
try {
|
|
560
581
|
return await sendOnce(content, saveId, etag);
|
|
561
582
|
} catch (err) {
|
|
583
|
+
if (reSent) err.hadTimeout = true;
|
|
562
584
|
if (err.name === 'AbortError') {
|
|
563
585
|
if (reSent) throw err;
|
|
564
586
|
const { landed, resend, etag: original } = await recoverUnknownSave();
|
package/src/core/save.js
CHANGED
|
@@ -221,6 +221,13 @@ export function resumeAutosave() {
|
|
|
221
221
|
savePageThrottled();
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
// Whether an agent's request currently holds autosave off. A caller that forces a
|
|
225
|
+
// save on its own schedule (live data checkpoints) asks this first, because a
|
|
226
|
+
// forced save would write the browser's bytes over the agent's.
|
|
227
|
+
export function autosaveIsSuspended() {
|
|
228
|
+
return autosaveSuspended > 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
224
231
|
// ============================================
|
|
225
232
|
// THE ALL-SAVE HOLD
|
|
226
233
|
// ============================================
|
|
@@ -372,15 +379,17 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
372
379
|
} else if (result.msgType === 'conflict') {
|
|
373
380
|
holdForConflict(result.conflictEtag);
|
|
374
381
|
setSaveState('conflict', result.msg, result.msgType, {
|
|
382
|
+
hadTimeout: result.hadTimeout === true,
|
|
375
383
|
changedBy: result.changedBy ?? null,
|
|
376
384
|
afterTimeout: result.afterTimeout === true,
|
|
377
385
|
etag: result.conflictEtag ?? null,
|
|
378
386
|
});
|
|
379
387
|
} else if (result.msgType !== 'skipped') {
|
|
388
|
+
const extra = { status: result.status ?? null, hadTimeout: result.hadTimeout === true };
|
|
380
389
|
if (!navigator.onLine) {
|
|
381
|
-
setSaveState('offline', result.msg);
|
|
390
|
+
setSaveState('offline', result.msg, '', extra);
|
|
382
391
|
} else {
|
|
383
|
-
setSaveState('error', result.msg);
|
|
392
|
+
setSaveState('error', result.msg, '', extra);
|
|
384
393
|
}
|
|
385
394
|
}
|
|
386
395
|
}
|
package/src/lib/region-policy.js
CHANGED
|
@@ -49,7 +49,7 @@ import { capabilitySelector, expandsTo, hasCapability, hasPolicyToken, POLICY_TO
|
|
|
49
49
|
export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
|
|
50
50
|
|
|
51
51
|
// The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
|
|
52
|
-
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui'];
|
|
52
|
+
export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui', 'live'];
|
|
53
53
|
|
|
54
54
|
// Every canonical token spellable inside the space-separated `clay` attribute.
|
|
55
55
|
// Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
|
|
@@ -83,7 +83,7 @@ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
|
|
|
83
83
|
// mutations-ignore footgun (their content stays in the saved file, but is no
|
|
84
84
|
// longer counted as a change).
|
|
85
85
|
export const STRIP_FROM_COMPARISON =
|
|
86
|
-
'[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
|
|
86
|
+
'[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
|
|
87
87
|
|
|
88
88
|
// Every spelling of no-trigger-autosave: the ONE difference between the autosave
|
|
89
89
|
// domain and the dirty domain. A document containing none of these has identical
|
|
@@ -94,6 +94,10 @@ export const STRIP_FROM_COMPARISON =
|
|
|
94
94
|
export const NO_TRIGGER_AUTOSAVE_SELECTOR =
|
|
95
95
|
'[clay~="no-trigger-autosave"], [no-trigger-autosave]';
|
|
96
96
|
|
|
97
|
+
// Live JSON stores (clay="live" on a merge= JSON script). Saved, stripped from both
|
|
98
|
+
// comparison domains, and ignored by the live-sync merge: the live plugin owns the value.
|
|
99
|
+
export const LIVE_SELECTOR = '[clay~="live"]';
|
|
100
|
+
|
|
97
101
|
// Disposable regions: saved, but their content is explicitly not work. Stripped
|
|
98
102
|
// from BOTH comparison domains, skipped by the merge on both sides, and never
|
|
99
103
|
// counted by the close warning. This is the declared signal that lets the merge
|
|
@@ -108,7 +112,7 @@ export const NO_DIRTY_SELECTOR =
|
|
|
108
112
|
// dirty-tracked either, and counting it would re-freeze the live-sync baseline
|
|
109
113
|
// the way the mounted-tool bug did (see dirty-gate.js).
|
|
110
114
|
export const STRIP_FROM_DIRTY_CHECK =
|
|
111
|
-
'[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
|
|
115
|
+
'[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
|
|
112
116
|
|
|
113
117
|
// Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
|
|
114
118
|
// dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
|
|
@@ -163,6 +167,9 @@ export function resolveRegionPolicy(node) {
|
|
|
163
167
|
if (expandsTo(element, 'no-watch')) watched = false;
|
|
164
168
|
if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
|
|
165
169
|
if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
|
|
170
|
+
// A live JSON store: saved, but its value lives in memory and the live plugin
|
|
171
|
+
// writes it into each save clone, so the tag itself is never work to track.
|
|
172
|
+
if (hasRegionToken(element, 'live')) { autosaveOff = true; dirtyOff = true; undoable = false; }
|
|
166
173
|
if (expandsTo(element, 'no-undo')) undoable = false;
|
|
167
174
|
if (expandsTo(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
|
|
168
175
|
if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Checkpoints for clay.live stores. A live change never marks the page unsaved
|
|
2
|
+
// (live tags are outside the comparison domains), so nothing else would ever save
|
|
3
|
+
// it. This does: at a fixed deadline CHECKPOINT_MS after the first unsaved change,
|
|
4
|
+
// so a steady stream of moves still saves on time, and once more, right away,
|
|
5
|
+
// when the tab is hidden or closed.
|
|
6
|
+
|
|
7
|
+
export const CHECKPOINT_MS = 8000;
|
|
8
|
+
export const RETRY_MS = 1000;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {object} deps
|
|
12
|
+
* @param {() => boolean} deps.needsSave - is there a live change no accepted save holds
|
|
13
|
+
* @param {() => boolean} deps.blocked - must a save wait (settling, conflict, agent editing)
|
|
14
|
+
* @param {() => Promise<object>} deps.save - the checkpoint save; resolves { ok, msgType }
|
|
15
|
+
* @param {() => unknown} deps.saveNow - the hide/close save, sent with keepalive
|
|
16
|
+
*/
|
|
17
|
+
export function createCheckpoints({ needsSave, blocked, save, saveNow, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout }) {
|
|
18
|
+
let deadline = null;
|
|
19
|
+
let timer = null;
|
|
20
|
+
let running = false;
|
|
21
|
+
let flushing = false;
|
|
22
|
+
// After a save that did not land, no save before this time (soon included).
|
|
23
|
+
let retryAt = 0;
|
|
24
|
+
|
|
25
|
+
function after(result) {
|
|
26
|
+
const skipped = result && !result.ok && result.msgType === "skipped";
|
|
27
|
+
retryAt = result?.ok || skipped ? 0 : now() + CHECKPOINT_MS;
|
|
28
|
+
return skipped ? RETRY_MS : CHECKPOINT_MS;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function arm(at) {
|
|
32
|
+
deadline = at;
|
|
33
|
+
if (timer !== null) clearTimer(timer);
|
|
34
|
+
timer = setTimer(run, Math.max(0, at - now()));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function disarm() {
|
|
38
|
+
if (timer !== null) clearTimer(timer);
|
|
39
|
+
timer = null;
|
|
40
|
+
deadline = null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function run() {
|
|
44
|
+
timer = null;
|
|
45
|
+
deadline = null;
|
|
46
|
+
if (!needsSave()) return;
|
|
47
|
+
if (blocked()) {
|
|
48
|
+
arm(now() + RETRY_MS);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
running = true;
|
|
52
|
+
let result = null;
|
|
53
|
+
try {
|
|
54
|
+
result = await save();
|
|
55
|
+
} catch {
|
|
56
|
+
result = null;
|
|
57
|
+
} finally {
|
|
58
|
+
running = false;
|
|
59
|
+
}
|
|
60
|
+
const wait = after(result);
|
|
61
|
+
if (!needsSave()) return;
|
|
62
|
+
// A save skipped because another was on the wire is retried soon; anything
|
|
63
|
+
// else waits a full interval, so a failing host is not asked every second.
|
|
64
|
+
arm(now() + wait);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
// A live change happened. The first one after a save starts the clock; later
|
|
69
|
+
// ones do not move it.
|
|
70
|
+
changed() {
|
|
71
|
+
if (deadline === null && !running) arm(now() + CHECKPOINT_MS);
|
|
72
|
+
},
|
|
73
|
+
// The tab is going away or out of sight: save now if anything is unsaved.
|
|
74
|
+
// A save that did not land arms the clock again, as run() does.
|
|
75
|
+
flush() {
|
|
76
|
+
if (running || !needsSave() || blocked()) return;
|
|
77
|
+
disarm();
|
|
78
|
+
running = true;
|
|
79
|
+
flushing = true;
|
|
80
|
+
Promise.resolve()
|
|
81
|
+
.then(saveNow)
|
|
82
|
+
.catch(() => null)
|
|
83
|
+
.then((result) => {
|
|
84
|
+
running = false;
|
|
85
|
+
flushing = false;
|
|
86
|
+
const wait = after(result);
|
|
87
|
+
if (!needsSave()) return;
|
|
88
|
+
arm(now() + wait);
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
// Save as soon as possible: the room asks for it (its log is half full, or
|
|
92
|
+
// the file holds a version the room cannot rebuild from).
|
|
93
|
+
soon() {
|
|
94
|
+
if (running) return;
|
|
95
|
+
const at = Math.max(now(), retryAt);
|
|
96
|
+
if (deadline === null || at < deadline) arm(at);
|
|
97
|
+
},
|
|
98
|
+
// True while the hide/close save is being made.
|
|
99
|
+
get flushing() {
|
|
100
|
+
return flushing;
|
|
101
|
+
},
|
|
102
|
+
get deadline() {
|
|
103
|
+
return deadline;
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|