@panphora/clayjs 1.9.2 → 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 +3 -3
- package/dist/clay.standalone.js +9701 -6938
- package/entries/clay-router.js +38 -0
- package/entries/sap.js +6 -6
- package/package.json +2 -2
- package/packed-contract.json +1 -0
- package/src/attrs/add-from.js +72 -0
- package/src/attrs/save-freeze.js +12 -2
- 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/core/snapshot.js +4 -1
- package/src/core/source-map.js +1 -1
- package/src/lib/mutation.js +28 -4
- package/src/lib/person-face.js +16 -0
- package/src/lib/region-capabilities.js +1 -0
- package/src/lib/region-policy.js +10 -3
- package/src/lib/root-attrs.js +28 -0
- 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/plugins/people.js +3 -14
- package/src/plugins/sortable.js +3 -1
- package/src/route/route.js +188 -0
- package/src/standalone.js +1 -0
- package/src/sync/conflict-revert.js +2 -3
- package/src/sync/editing-marker.js +172 -0
- package/src/sync/editing-path.js +46 -0
- package/src/sync/editing-sender.js +100 -0
- package/src/sync/live-sync.js +85 -19
- package/src/sync/presence.js +16 -44
- package/src/sync/stream.js +16 -5
- package/src/vendor/hyper-morph.vendor.js +6 -4
- package/src/sync/section-notice.js +0 -254
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/core/snapshot.js
CHANGED
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
42
42
|
import { restoreAuthoredUrls } from '../lib/authored-url.js';
|
|
43
43
|
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
44
|
-
import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
|
|
44
|
+
import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
|
|
45
45
|
import { createContentView } from '../lib/content-dom.js';
|
|
46
46
|
|
|
47
47
|
// =============================================================================
|
|
@@ -277,6 +277,9 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
|
277
277
|
// snapshot so it never reaches a save, a comparison, or a live-sync broadcast.
|
|
278
278
|
stripExtensionNoise(clone);
|
|
279
279
|
|
|
280
|
+
// Router state says where this tab is, not what the document holds.
|
|
281
|
+
stripRouteAttrs(clone);
|
|
282
|
+
|
|
280
283
|
return clone;
|
|
281
284
|
}
|
|
282
285
|
|
package/src/core/source-map.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* source-map.js — save the file, not a serialization of it.
|
|
3
3
|
*
|
|
4
|
-
* Every program that edits a malleable
|
|
4
|
+
* Every program that edits a malleable document rewrites the whole file, because
|
|
5
5
|
* each one parses to a tree and serializes the tree back out, and a serializer does
|
|
6
6
|
* not reproduce its input. A no-edit save through `clone.outerHTML` rewrites about
|
|
7
7
|
* 88% of an authored document's lines: attributes reorder, quoting normalises, `&`
|
package/src/lib/mutation.js
CHANGED
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
|
|
70
70
|
import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
|
|
71
71
|
import { isPolicyAttribute } from './region-capabilities.js';
|
|
72
|
-
import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
|
|
72
|
+
import { ROOT_LIBRARY_ATTRS, ROUTE_ROOT_PREFIX, ROUTE_ACTIVE_ATTR } from './root-attrs.js';
|
|
73
73
|
|
|
74
74
|
import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
|
|
75
75
|
|
|
@@ -83,12 +83,15 @@ const dummyElem = document.createElement("div");
|
|
|
83
83
|
// instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
|
|
84
84
|
// without this every save would push a savestatus flip into undo's record stream
|
|
85
85
|
// and Cmd+Z after a save would undo that instead of the user's edit.
|
|
86
|
+
// Router state (route-* on the root, route-active anywhere) is dropped here for the same reason.
|
|
86
87
|
const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
|
|
87
88
|
|
|
88
89
|
function isLibraryRootAttr(record) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
if (record.type !== 'attributes') return false;
|
|
91
|
+
const name = record.attributeName;
|
|
92
|
+
if (name === ROUTE_ACTIVE_ATTR) return true;
|
|
93
|
+
return record.target === document.documentElement
|
|
94
|
+
&& (ROOT_LIBRARY_ATTR_SET.has(name) || name.startsWith(ROUTE_ROOT_PREFIX));
|
|
92
95
|
}
|
|
93
96
|
|
|
94
97
|
function withoutLibraryNoise(records) {
|
|
@@ -127,6 +130,7 @@ const localMutation = {
|
|
|
127
130
|
* Always pair with resume() in a try/finally block.
|
|
128
131
|
*/
|
|
129
132
|
pause() {
|
|
133
|
+
if (this._pauseDepth === 0) this._deliverBeforePause();
|
|
130
134
|
// Reference-counted so nested pauses (e.g. a programmatic pause wrapping a
|
|
131
135
|
// live-sync morph that also pauses) only resume on the outermost release.
|
|
132
136
|
this._pauseDepth++;
|
|
@@ -139,6 +143,26 @@ const localMutation = {
|
|
|
139
143
|
this._log('Paused', this._pauseDepth);
|
|
140
144
|
},
|
|
141
145
|
|
|
146
|
+
// Records the browser queued before this pause describe changes made outside
|
|
147
|
+
// it. Left in the queue, resume() would drain them as paused-time records and
|
|
148
|
+
// only non-pausable callbacks would see them, so autosave missed a change made
|
|
149
|
+
// just before a pause. Deliver them on a microtask, as the observer would have.
|
|
150
|
+
_deliverBeforePause() {
|
|
151
|
+
if (!this._observer) return;
|
|
152
|
+
const records = this._observer.takeRecords();
|
|
153
|
+
if (records.length) queueMicrotask(() => this._onRecords(records));
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Hand the records the browser has queued to raw subscribers' buffers and to
|
|
158
|
+
* non-pausable callbacks only, as if they had happened inside a pause. For a
|
|
159
|
+
* caller whose writes and their undoing cancel out, so the page's change feed
|
|
160
|
+
* has nothing to see.
|
|
161
|
+
*/
|
|
162
|
+
dropQueued() {
|
|
163
|
+
if (this._observer) this._drainBrowserQueue(this);
|
|
164
|
+
},
|
|
165
|
+
|
|
142
166
|
/**
|
|
143
167
|
* Resume mutation observation after a pause.
|
|
144
168
|
*/
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const PERSON_COLORS = Object.freeze(['#6b4fa8', '#2f6a9e', '#9a6a1c', '#a8463d', '#2f7a69', '#4f6178', '#3f7d3a', '#a2457a']);
|
|
2
|
+
|
|
3
|
+
export function colorIndexOf(id) {
|
|
4
|
+
let hash = 0;
|
|
5
|
+
for (const ch of String(id)) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
|
|
6
|
+
return hash % PERSON_COLORS.length;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function personColor(id) {
|
|
10
|
+
return PERSON_COLORS[colorIndexOf(id)];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function initialsOf(name) {
|
|
14
|
+
const letters = String(name).trim().split(/\s+/).filter(Boolean).slice(0, 2).map((word) => Array.from(word)[0]);
|
|
15
|
+
return letters.join("").toUpperCase() || "?";
|
|
16
|
+
}
|
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);
|
package/src/lib/root-attrs.js
CHANGED
|
@@ -116,3 +116,31 @@ export const TAB_LOCAL_ROOT_ATTRS = new Set([
|
|
|
116
116
|
export function isTabLocalRootAttr(name, element) {
|
|
117
117
|
return element === document.documentElement && TAB_LOCAL_ROOT_ATTRS.has(name);
|
|
118
118
|
}
|
|
119
|
+
|
|
120
|
+
// Router state. The router mirrors each URL-hash param onto the root as
|
|
121
|
+
// route-<name>, and stamps route-active on the record a param selects. Both say
|
|
122
|
+
// where this tab is in the document, not what the document holds, so they are
|
|
123
|
+
// tab-local like the library's own root state: never saved, never dirty, never
|
|
124
|
+
// undone, never sent to or taken from a peer. route-* is reserved on the root
|
|
125
|
+
// only; route-active is reserved on every element.
|
|
126
|
+
export const ROUTE_ROOT_PREFIX = "route-";
|
|
127
|
+
export const ROUTE_ACTIVE_ATTR = "route-active";
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* True when `name` on `el` belongs to this tab rather than to the document:
|
|
131
|
+
* every tab-local root attribute, route-* on a root, and route-active anywhere.
|
|
132
|
+
* Works on the live root and on a snapshot clone's or parsed frame's root.
|
|
133
|
+
*/
|
|
134
|
+
export function isTabLocalAttr(name, el) {
|
|
135
|
+
if (name === ROUTE_ACTIVE_ATTR) return true;
|
|
136
|
+
if (!el || el.localName !== "html" || el.parentElement) return false;
|
|
137
|
+
return TAB_LOCAL_ROOT_ATTRS.has(name) || name.startsWith(ROUTE_ROOT_PREFIX);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Remove router state from a snapshot clone: route-* on its root, route-active anywhere. */
|
|
141
|
+
export function stripRouteAttrs(root) {
|
|
142
|
+
for (const attr of [...root.attributes]) {
|
|
143
|
+
if (attr.name.startsWith(ROUTE_ROOT_PREFIX)) root.removeAttribute(attr.name);
|
|
144
|
+
}
|
|
145
|
+
for (const el of root.querySelectorAll(`[${ROUTE_ACTIVE_ATTR}]`)) el.removeAttribute(ROUTE_ACTIVE_ATTR);
|
|
146
|
+
}
|
|
@@ -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
|
+
}
|