@panphora/clayjs 1.13.1 → 1.15.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/THIRD-PARTY-NOTICES.md +19 -0
- package/dist/clay.standalone.js +69385 -40992
- package/dist/server/checkpoint.cjs +1 -0
- package/dist/server/checkpoint.mjs +8 -0
- package/dist/server/core.cjs +72210 -0
- package/dist/server/rooms.cjs +1 -0
- package/dist/server/rooms.mjs +66 -0
- package/docs/guide.md +127 -99
- package/entries/clay.js +22 -0
- package/entries/sap.js +5 -5
- package/package.json +16 -2
- package/packed-contract.json +18 -3
- package/src/core/boot-phase.js +6 -0
- package/src/core/conflict-download.js +12 -3
- package/src/core/host-meta.js +75 -0
- package/src/core/persist.js +11 -0
- package/src/core/save-core.js +266 -31
- package/src/core/save.js +154 -36
- package/src/core/snapshot.js +51 -13
- package/src/core/style-comparisons.js +37 -0
- package/src/dom/insert-style-tag.js +11 -12
- package/src/lib/derived.js +116 -0
- package/src/lib/effect.js +130 -0
- package/src/lib/mutation.js +3 -1
- package/src/lib/style-adoption.js +9 -0
- package/src/lib/style.js +47 -0
- package/src/live/fast-lane.js +347 -60
- package/src/live/local-lane.js +32 -6
- package/src/live/ops.js +77 -0
- package/src/live/provenance.js +129 -0
- package/src/live/save-history.js +188 -0
- package/src/live/server-lane.js +9 -2
- package/src/live/store.js +194 -16
- package/src/loader-logic.js +16 -4
- package/src/loader.js +37 -1
- package/src/page/address.js +20 -0
- package/src/page/adopt-boot.js +189 -0
- package/src/page/adopt.js +240 -0
- package/src/page/ai-tool.js +188 -0
- package/src/page/announce.js +21 -0
- package/src/page/blocks.js +1170 -0
- package/src/page/blocksel.js +269 -0
- package/src/page/board-drag.js +226 -0
- package/src/page/board-panel.js +353 -0
- package/src/page/board-style.js +146 -0
- package/src/page/board-view.js +447 -0
- package/src/page/board.js +41 -0
- package/src/page/chart.js +220 -0
- package/src/page/checklist.js +143 -0
- package/src/page/chrome.js +587 -0
- package/src/page/csv-import.js +169 -0
- package/src/page/drag.js +202 -0
- package/src/page/drop.js +112 -0
- package/src/page/editor.js +386 -0
- package/src/page/highlight.js +268 -0
- package/src/page/inline.js +60 -1
- package/src/page/items.js +159 -0
- package/src/page/link.js +247 -0
- package/src/page/markdown.js +71 -0
- package/src/page/media.js +570 -0
- package/src/page/merge.js +130 -0
- package/src/page/model.js +71 -9
- package/src/page/placeholder.js +23 -0
- package/src/page/projection.js +647 -0
- package/src/page/recover.js +193 -0
- package/src/page/rich/cell.js +16 -0
- package/src/page/rich/chart.js +104 -0
- package/src/page/rich/csv.js +118 -0
- package/src/page/rich/page-tool.js +86 -0
- package/src/page/rich/tool-runtime.js +195 -0
- package/src/page/save-projection.js +53 -0
- package/src/page/save-states.js +85 -0
- package/src/page/schema.js +12 -5
- package/src/page/table-kinds.js +20 -0
- package/src/page/table-paste.js +57 -0
- package/src/page/table-select.js +244 -0
- package/src/page/table-style.js +25 -0
- package/src/page/table.js +650 -0
- package/src/page/themes/content.css +46 -0
- package/src/page/themes/light-1.css +269 -0
- package/src/page/themes/light-2.css +211 -0
- package/src/page/themes/light-3.css +130 -0
- package/src/page/themes/light-4.css +299 -0
- package/src/page/tool-data.js +25 -0
- package/src/page/tool-view.js +68 -0
- package/src/page/tools.js +133 -0
- package/src/page/view.js +136 -0
- package/src/page/ybind/author.js +25 -0
- package/src/page/ybind/binding.js +92 -0
- package/src/page/ybind/board-commands.js +170 -0
- package/src/page/ybind/board.js +89 -0
- package/src/page/ybind/bridge.js +382 -0
- package/src/page/ybind/conversion.js +26 -0
- package/src/page/ybind/core.js +7 -0
- package/src/page/ybind/decode.js +208 -0
- package/src/page/ybind/doc.js +58 -0
- package/src/page/ybind/external.js +190 -0
- package/src/page/ybind/group.js +124 -0
- package/src/page/ybind/index.js +12 -0
- package/src/page/ybind/layout.js +27 -0
- package/src/page/ybind/marks.js +18 -0
- package/src/page/ybind/migrate.js +152 -0
- package/src/page/ybind/moves.js +288 -0
- package/src/page/ybind/origins.js +257 -0
- package/src/page/ybind/parts.js +44 -0
- package/src/page/ybind/projection-map.js +92 -0
- package/src/page/ybind/provider.js +285 -0
- package/src/page/ybind/recovery.js +411 -0
- package/src/page/ybind/seed.js +27 -0
- package/src/page/ybind/table.js +166 -0
- package/src/page/ybind/tokens.js +93 -0
- package/src/page/ybind/walk.js +333 -0
- package/src/page/ybind/writer.js +307 -0
- package/src/plugins/derive.js +12 -0
- package/src/plugins/indicator.js +16 -6
- package/src/plugins/live.js +742 -65
- package/src/plugins/page.js +547 -0
- package/src/plugins/undo.js +62 -4
- package/src/server/checkpoint/index.js +218 -0
- package/src/server/rooms/index.js +59 -0
- package/src/server/rooms/live-data-ops.js +369 -0
- package/src/server/rooms/live-data-rooms.js +965 -0
- package/src/server/rooms/live-data-tags.js +49 -0
- package/src/server/rooms/live-page-history.js +41 -0
- package/src/server/rooms/live-provenance.js +142 -0
- package/src/server/rooms/page-admission.js +79 -0
- package/src/server/rooms/page-conversion.js +62 -0
- package/src/server/rooms/page-rebase.js +108 -0
- package/src/server/rooms/page-state.js +91 -0
- package/src/server/rooms/page-write-admission.js +63 -0
- package/src/server/rooms/page-write-guard.js +23 -0
- package/src/server/rooms/recent.js +18 -0
- package/src/server/rooms/spec-wire.js +17 -0
- package/src/standalone.js +22 -0
- package/src/sync/conflict-revert.js +2 -0
- package/src/sync/conflicts.js +2 -2
- package/src/sync/live-sync.js +36 -8
- package/src/sync/stream.js +62 -16
- package/src/ui/index.js +4 -0
- package/src/ui/toast.js +21 -5
- package/src/vendor/pierre/pierre.vendor.js +3 -0
- package/src/vendor/sap-core.vendor.js +9 -0
- package/src/vendor/sap-ownership.vendor.js +111 -0
- package/src/vendor/yjs.vendor.js +7 -0
- package/src/page/binding.js +0 -647
- package/src/page/replay.js +0 -578
|
@@ -0,0 +1,965 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { parseJsonRelaxed } from 'hyper-morph/json-parse';
|
|
4
|
+
import { validateOp, checkOp, applyOp, applyTracked, undoTracked, sizeChange, cloneJson, serializeLive, textHash, diffOps } from './live-data-ops.js';
|
|
5
|
+
import { liveTags } from './live-data-tags.js';
|
|
6
|
+
import { checkPageConversion, PageConversionError } from './page-conversion.js';
|
|
7
|
+
import { checkPageInitial } from './page-write-admission.js';
|
|
8
|
+
import { inspectPage, isYPageValue, isHybridPageValue } from './page-admission.js';
|
|
9
|
+
import { makePageTransition, pageTransitionState } from './live-page-history.js';
|
|
10
|
+
export { liveTags } from './live-data-tags.js';
|
|
11
|
+
|
|
12
|
+
import { frameSet, contains, descendants, receiptHas, receiptBytes, saveReceipt, postReceipt, replaceReceipt, retireReceipt, RECEIPT_LIMIT } from './live-provenance.js';
|
|
13
|
+
|
|
14
|
+
const byteLength = (text) => Buffer.byteLength(text, 'utf8');
|
|
15
|
+
const sizeOf = (value) => byteLength(serializeLive(value));
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Rooms for clay.live stores: one shared copy of a document's live JSON while
|
|
19
|
+
* people are connected, ordered by one sequence number per room.
|
|
20
|
+
*
|
|
21
|
+
* A room starts from the saved file (the live tags in its HTML), never from a
|
|
22
|
+
* tab: a tab can hold a stale or altered copy. Every accepted frame bumps the
|
|
23
|
+
* room's seq; that order is the order every tab applies. A sender numbers its
|
|
24
|
+
* own frames per store (n), so a retried POST whose first attempt landed is
|
|
25
|
+
* answered as a duplicate instead of applied twice.
|
|
26
|
+
* A write of the file merges: the room takes the file's value and replays its
|
|
27
|
+
* own moves since the version the file descends from (the tag's live-at stamp
|
|
28
|
+
* names it). Per store the room keeps a log of its moves, `last` (the seq of the
|
|
29
|
+
* store's latest change) and `landed` (the seq from which the file plus the log
|
|
30
|
+
* rebuilds the room's value; -1 when it cannot, and the writer saves now).
|
|
31
|
+
*
|
|
32
|
+
* Pure state: no Express, no SSE, no timers. The caller owns time (now), fan-out
|
|
33
|
+
* and expiry scheduling.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
// liveBytes is the budget for every room together: each store's value and its
|
|
37
|
+
// log, as serialized bytes. Past it a new room is not opened and a frame that
|
|
38
|
+
// grows a store is answered busy. Real pages hold 2 to 4.5 bytes of heap per
|
|
39
|
+
// counted byte, so the rooms stay near 1 GB at most.
|
|
40
|
+
export const LIVE_LIMITS = Object.freeze({
|
|
41
|
+
opsPerFrame: 128,
|
|
42
|
+
storeBytes: 4 * 1024 * 1024,
|
|
43
|
+
roomBytes: 6 * 1024 * 1024,
|
|
44
|
+
liveBytes: 256 * 1024 * 1024,
|
|
45
|
+
storesPerRoom: 16,
|
|
46
|
+
rooms: 5000,
|
|
47
|
+
retainMs: 2 * 60 * 1000,
|
|
48
|
+
logEntries: 4096,
|
|
49
|
+
logBytes: 256 * 1024,
|
|
50
|
+
receipts: RECEIPT_LIMIT,
|
|
51
|
+
// A state read's moves, over every store: past this, a store's moves are null
|
|
52
|
+
// (the reader rebuilds from the value, as after the log is evicted).
|
|
53
|
+
movesBytes: 1024 * 1024,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const PAGE = /^page(\.[0-9]+)?$/;
|
|
57
|
+
const isContainer = (v) => v !== null && typeof v === 'object';
|
|
58
|
+
|
|
59
|
+
// The stores a room starts with. A store over the store cap is left out, and so
|
|
60
|
+
// is any store that would take the room past its cap: tabs then keep those at
|
|
61
|
+
// save speed instead of having every op refused.
|
|
62
|
+
function seedStores(html, { pageOnly = false, heldBytes = 0, heldCount = 0 } = {}) {
|
|
63
|
+
const stores = new Map();
|
|
64
|
+
let total = heldBytes;
|
|
65
|
+
for (const tag of liveTags(html)) {
|
|
66
|
+
if (pageOnly && !PAGE.test(tag.name)) continue;
|
|
67
|
+
if (stores.size + heldCount >= LIVE_LIMITS.storesPerRoom) break;
|
|
68
|
+
let value;
|
|
69
|
+
try {
|
|
70
|
+
value = cloneJson(parseJsonRelaxed(tag.text));
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!isContainer(value)) continue;
|
|
75
|
+
const bytes = byteLength(serializeLive(value));
|
|
76
|
+
if (bytes > LIVE_LIMITS.storeBytes || total + bytes > LIVE_LIMITS.roomBytes) continue;
|
|
77
|
+
total += bytes;
|
|
78
|
+
stores.set(tag.name, {
|
|
79
|
+
value,
|
|
80
|
+
ephemeral: tag.ephemeral,
|
|
81
|
+
bytes,
|
|
82
|
+
from: tag.at ? { epoch: tag.at.epoch, seq: tag.at.seq } : null,
|
|
83
|
+
last: 0,
|
|
84
|
+
landed: 0,
|
|
85
|
+
floor: 0,
|
|
86
|
+
log: [],
|
|
87
|
+
logBytes: 0,
|
|
88
|
+
// Seqs of logged merges whose edit the file on disk holds.
|
|
89
|
+
inFile: new Set(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return stores;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Over a cap the oldest entries go, and a file older than the newest one gone
|
|
96
|
+
// can no longer be merged (floor). The file holds the oldest entries, so a
|
|
97
|
+
// writer that keeps saving only ever loses the merge of a restore that old.
|
|
98
|
+
function evict(store) {
|
|
99
|
+
while (store.log.length > LIVE_LIMITS.logEntries || store.logBytes > LIVE_LIMITS.logBytes) {
|
|
100
|
+
const gone = store.log.shift();
|
|
101
|
+
store.logBytes -= gone.bytes;
|
|
102
|
+
store.floor = gone.seq;
|
|
103
|
+
store.inFile.delete(gone.seq);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// The log bytes evict() would leave after one more entry of `bytes`.
|
|
108
|
+
function logAfter(store, bytes) {
|
|
109
|
+
let count = store.log.length + 1;
|
|
110
|
+
let total = store.logBytes + bytes;
|
|
111
|
+
for (let i = 0; count > LIVE_LIMITS.logEntries || total > LIVE_LIMITS.logBytes; i++, count--) {
|
|
112
|
+
total -= i < store.log.length ? store.log[i].bytes : bytes;
|
|
113
|
+
}
|
|
114
|
+
return total;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The moves the file does not hold fill half the log: the writer should save
|
|
118
|
+
// now rather than at its deadline.
|
|
119
|
+
function pressed(store) {
|
|
120
|
+
let count = 0;
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
for (const entry of store.log) {
|
|
123
|
+
if (store.landed !== -1 && entry.seq <= store.landed) continue;
|
|
124
|
+
count += 1;
|
|
125
|
+
bytes += entry.bytes;
|
|
126
|
+
}
|
|
127
|
+
return bytes > LIVE_LIMITS.logBytes / 2 || count > LIVE_LIMITS.logEntries / 2;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The moves a store's log holds after since.seq, for a tab that applied
|
|
131
|
+
// everything through it. [] when nothing is newer; null when the log no longer
|
|
132
|
+
// reaches back that far (its floor is past since.seq). A log may start above
|
|
133
|
+
// since.seq + 1 with nothing missing: the seqs between went to other stores.
|
|
134
|
+
function movesSince(store, sinceSeq) {
|
|
135
|
+
if (sinceSeq >= store.last) return [];
|
|
136
|
+
if (sinceSeq < store.floor) return null;
|
|
137
|
+
return store.log.filter((entry) => entry.seq > sinceSeq).map(({ seq, ops }) => ({ seq, ops }));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Where in the room's history a written file starts. Its stamp's seq when the
|
|
141
|
+
// stamp is this room's. 0, before every move of this room, when it is a
|
|
142
|
+
// checkpoint of the room this one was seeded from, taken at or after the seed: a
|
|
143
|
+
// tab never sends a move of the old room to the new one, so none of this room's
|
|
144
|
+
// moves is in it. Anything else (no stamp, another lineage, a version older than
|
|
145
|
+
// the seed) descends from the last version the room knows the file held: saves
|
|
146
|
+
// are conditional.
|
|
147
|
+
function baseOf(room, store, at) {
|
|
148
|
+
if (at && at.epoch === room.epoch && at.seq <= room.seq) return at.seq;
|
|
149
|
+
if (at && store.from && at.epoch === store.from.epoch && at.seq >= store.from.seq) return 0;
|
|
150
|
+
return store.landed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// The file is a capture this room can place (baseOf's first two cases), not a
|
|
154
|
+
// descendant of the file on disk.
|
|
155
|
+
function placed(room, store, at) {
|
|
156
|
+
return !!at && ((at.epoch === room.epoch && at.seq <= room.seq) || (!!store.from && at.epoch === store.from.epoch && at.seq >= store.from.seq));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Exact sets are capture-bound. Legacy watermarks retain their old receipt check.
|
|
160
|
+
function provenBy(room, name, tag, stamped) {
|
|
161
|
+
if (!tag.by || !stamped) return null;
|
|
162
|
+
if (tag.by.v === 2) return tag.by;
|
|
163
|
+
if (tag.at.epoch !== room.epoch) return null;
|
|
164
|
+
const last = room.lastN.get(name + '\n' + tag.by.client);
|
|
165
|
+
return tag.by.n <= (last ? last.n : 0) + 1 ? tag.by : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* @param {{ now?: () => number, newEpoch?: () => string, liveBytes?: number }} [options]
|
|
170
|
+
*/
|
|
171
|
+
// How long a document refused for the budget is refused again without its
|
|
172
|
+
// file being read, while the rooms stay as full.
|
|
173
|
+
export const REFUSED_MS = 10 * 60 * 1000;
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether a room holds accepted state the file does not: at least one non-ephemeral store whose
|
|
177
|
+
* latest change (`last`) the file does not reach (`landed`), including `landed === -1`, where the
|
|
178
|
+
* log no longer reaches the file's base. Presence never dirties a room. Read against the room
|
|
179
|
+
* itself, so a sweep or a checkpoint decision never depends on a scheduled timer or a flag.
|
|
180
|
+
*/
|
|
181
|
+
export function roomIsDirty(room) {
|
|
182
|
+
for (const store of room.stores.values()) {
|
|
183
|
+
if (!store.ephemeral && store.last !== store.landed) return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export class PersistentReservationError extends Error {
|
|
189
|
+
constructor(code) {
|
|
190
|
+
super(`Persistent reservation refused: ${code}`);
|
|
191
|
+
this.name = 'PersistentReservationError';
|
|
192
|
+
this.code = code;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function createRooms({ now = Date.now, newEpoch = randomUUID, liveBytes = LIVE_LIMITS.liveBytes } = {}) {
|
|
197
|
+
const rooms = new Map();
|
|
198
|
+
const pageReservations = new Map();
|
|
199
|
+
const reservedNodes = new Map();
|
|
200
|
+
const persistentReservations = new Map();
|
|
201
|
+
const persistentTokens = new Map();
|
|
202
|
+
const releasedTokens = new WeakSet();
|
|
203
|
+
// What every room holds, against LIVE_LIMITS.liveBytes.
|
|
204
|
+
let used = 0;
|
|
205
|
+
// Documents whose room did not fit the budget: key -> { held, at }.
|
|
206
|
+
const refused = new Map();
|
|
207
|
+
|
|
208
|
+
const build = (key, html, nodeId = null) => ({
|
|
209
|
+
key,
|
|
210
|
+
nodeId,
|
|
211
|
+
fileHash: typeof html === 'string' ? textHash(html) : null,
|
|
212
|
+
epoch: newEpoch(),
|
|
213
|
+
seq: 0,
|
|
214
|
+
stores: seedStores(html),
|
|
215
|
+
writer: null,
|
|
216
|
+
lastN: new Map(),
|
|
217
|
+
lastSentAt: new Map(),
|
|
218
|
+
seen: new Map(),
|
|
219
|
+
emptySince: null,
|
|
220
|
+
...(inspectPage(html).kind === 'yjs' && { pageTransition: makePageTransition() }),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const roomBytes = (room) => {
|
|
224
|
+
let total = 0;
|
|
225
|
+
for (const s of room.stores.values()) total += s.bytes;
|
|
226
|
+
return total;
|
|
227
|
+
};
|
|
228
|
+
const heldBy = (room) => {
|
|
229
|
+
let total = (room.receiptBytes ?? 0) + (room.pageTransition?.bytes ?? 0);
|
|
230
|
+
for (const s of room.stores.values()) total += s.bytes + s.logBytes;
|
|
231
|
+
return total;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const pageSource = (key, nodeId) => {
|
|
235
|
+
if (nodeId == null) throw new PageConversionError('invalid-document');
|
|
236
|
+
if (pageReservations.has(key) || persistentReservations.has(key) || reservedNodes.has(String(nodeId))) throw new PageConversionError('page-converting');
|
|
237
|
+
let room = rooms.get(key) ?? null;
|
|
238
|
+
if (room && String(room.nodeId) !== String(nodeId)) {
|
|
239
|
+
used -= heldBy(room);
|
|
240
|
+
rooms.delete(key);
|
|
241
|
+
room = null;
|
|
242
|
+
}
|
|
243
|
+
return room;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const reservePageTarget = (key, nodeId, room, html, details) => {
|
|
247
|
+
const receipts = new Set([...(room?.lastN.keys() ?? [])].filter((key) => PAGE.test(key.slice(0, key.indexOf('\n')))));
|
|
248
|
+
if (receipts.size >= LIVE_LIMITS.receipts ||
|
|
249
|
+
(room?.pageTransition?.receiptCount ?? 0) + receipts.size > LIVE_LIMITS.receipts) throw new PageConversionError('receipt-capacity');
|
|
250
|
+
const names = details.names ?? [...new Set([
|
|
251
|
+
...[...(room?.stores.keys() ?? [])].filter((name) => PAGE.test(name)),
|
|
252
|
+
...[...receipts].map((key) => key.slice(0, key.indexOf('\n'))),
|
|
253
|
+
])];
|
|
254
|
+
const token = Object.freeze({ key, nodeId });
|
|
255
|
+
let target = null;
|
|
256
|
+
let budget = 0;
|
|
257
|
+
if (room) {
|
|
258
|
+
const sourceReceipts = new Map([...receipts].map((key) => [key, room.lastN.get(key)]));
|
|
259
|
+
const transition = makePageTransition({ room, format: details.format, sources: names, receipts: sourceReceipts });
|
|
260
|
+
const others = [...room.stores].filter(([name]) => !PAGE.test(name));
|
|
261
|
+
const stores = seedStores(html, {
|
|
262
|
+
pageOnly: true,
|
|
263
|
+
heldBytes: others.reduce((sum, [, store]) => sum + store.bytes, 0),
|
|
264
|
+
heldCount: others.length,
|
|
265
|
+
});
|
|
266
|
+
for (const store of stores.values()) store.from = null;
|
|
267
|
+
budget = transition.bytes - (room.pageTransition?.historyBytes ?? 0) +
|
|
268
|
+
[...stores.values()].reduce((sum, store) => sum + store.bytes, 0);
|
|
269
|
+
const replacedBytes = [...room.stores].filter(([name]) => PAGE.test(name))
|
|
270
|
+
.reduce((sum, [, store]) => sum + store.bytes + store.logBytes, 0);
|
|
271
|
+
const growth = details.source === 'replacement' ? budget - replacedBytes : budget;
|
|
272
|
+
if (details.source === 'replacement' ? growth > 0 && used + growth > liveBytes : used + budget > liveBytes) {
|
|
273
|
+
throw new PageConversionError('budget');
|
|
274
|
+
}
|
|
275
|
+
target = { stores, transition };
|
|
276
|
+
}
|
|
277
|
+
used += budget;
|
|
278
|
+
pageReservations.set(key, { token, room, html, ...details, names, receipts, target, budget, committed: false, drop: false });
|
|
279
|
+
reservedNodes.set(String(nodeId), token);
|
|
280
|
+
return token;
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const reconcileFile = (room, key, html, { restored = false } = {}) => {
|
|
284
|
+
// A tab's word that it merged the file's article holds for that file only:
|
|
285
|
+
// the next write that changes it, outside or checkpoint, ends it.
|
|
286
|
+
const seen = textHash(html);
|
|
287
|
+
if (room.fileHash !== seen) {
|
|
288
|
+
room.adopted = null;
|
|
289
|
+
room.fileHash = seen;
|
|
290
|
+
}
|
|
291
|
+
const heldBefore = heldBy(room);
|
|
292
|
+
let valueChanged = false;
|
|
293
|
+
const landed = [];
|
|
294
|
+
for (const tag of liveTags(html)) {
|
|
295
|
+
if (pageReservations.has(key) && PAGE.test(tag.name)) continue;
|
|
296
|
+
const store = room.stores.get(tag.name);
|
|
297
|
+
// A never-saved store's tag is never written by a tab: the file is not its source.
|
|
298
|
+
if (!store || store.ephemeral) continue;
|
|
299
|
+
let value;
|
|
300
|
+
try {
|
|
301
|
+
value = cloneJson(parseJsonRelaxed(tag.text));
|
|
302
|
+
} catch {
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (!isContainer(value) || Array.isArray(value) !== Array.isArray(store.value)) continue;
|
|
306
|
+
if (PAGE.test(tag.name)) {
|
|
307
|
+
if (room.pageTransition?.format === 'yjs') {
|
|
308
|
+
if (tag.name !== 'page' || !isYPageValue(value)) continue;
|
|
309
|
+
} else if (isYPageValue(value, { complete: false })) continue;
|
|
310
|
+
}
|
|
311
|
+
const before = store.landed;
|
|
312
|
+
const base = baseOf(room, store, tag.at);
|
|
313
|
+
// A capture (stamped, and still the value its stamp names) holds no merge
|
|
314
|
+
// past its stamp. Anything else descends from the file on disk, which
|
|
315
|
+
// holds the merges in inFile: they are not replayed over it.
|
|
316
|
+
const stamped = placed(room, store, tag.at) && tag.at.hash === textHash(serializeLive(value));
|
|
317
|
+
if (base < store.floor) {
|
|
318
|
+
store.landed = -1;
|
|
319
|
+
if (before !== -1) landed.push(tag.name);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
// A restore proves neither frame inclusion nor descent from a capture.
|
|
323
|
+
const by = restored ? null : provenBy(room, tag.name, tag, stamped);
|
|
324
|
+
const frames = tag.at?.epoch === room.epoch ? frameSet(by) : [];
|
|
325
|
+
const ancestry = descendants(by);
|
|
326
|
+
const own = (entry) => {
|
|
327
|
+
if (!by) return false;
|
|
328
|
+
if (!entry.merge) return contains(frames, entry.from, entry.n);
|
|
329
|
+
const p = entry.provenance;
|
|
330
|
+
return by.v === 2 && p && p.at.epoch === tag.at.epoch && p.at.seq === tag.at.seq && contains(ancestry, ...p.capture);
|
|
331
|
+
};
|
|
332
|
+
if (stamped) store.inFile.clear();
|
|
333
|
+
const entries = [];
|
|
334
|
+
for (const entry of store.log) {
|
|
335
|
+
if (entry.seq <= base) continue;
|
|
336
|
+
if (own(entry)) store.inFile.add(entry.seq);
|
|
337
|
+
else if (stamped || !store.inFile.has(entry.seq)) entries.push(entry);
|
|
338
|
+
}
|
|
339
|
+
for (const entry of entries) {
|
|
340
|
+
for (const op of entry.ops) {
|
|
341
|
+
try {
|
|
342
|
+
checkOp(value, op);
|
|
343
|
+
value = applyOp(value, op);
|
|
344
|
+
} catch {}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
const text = serializeLive(value);
|
|
348
|
+
if (text !== serializeLive(store.value)) {
|
|
349
|
+
const bytes = byteLength(text);
|
|
350
|
+
if (bytes > LIVE_LIMITS.storeBytes || roomBytes(room) - store.bytes + bytes > LIVE_LIMITS.roomBytes) {
|
|
351
|
+
room.stores.delete(tag.name);
|
|
352
|
+
valueChanged = true;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
// Logged like a frame, so a capture stamped before this merge gets it
|
|
356
|
+
// replayed on top (leaf ops: a capture that holds it already is unchanged).
|
|
357
|
+
const ops = diffOps(store.value, value);
|
|
358
|
+
store.value = value;
|
|
359
|
+
store.bytes = bytes;
|
|
360
|
+
room.seq += 1;
|
|
361
|
+
store.last = room.seq;
|
|
362
|
+
const provenance = by?.v === 2 ? { at: { epoch: tag.at.epoch, seq: tag.at.seq }, by: by.frames, capture: by.capture } : null;
|
|
363
|
+
const logged = byteLength(JSON.stringify(provenance ? { ops, provenance } : ops));
|
|
364
|
+
store.log.push({ seq: room.seq, ops, bytes: logged, merge: true, ...(provenance && { provenance }) });
|
|
365
|
+
store.inFile.add(room.seq);
|
|
366
|
+
store.logBytes += logged;
|
|
367
|
+
evict(store);
|
|
368
|
+
valueChanged = true;
|
|
369
|
+
}
|
|
370
|
+
store.landed = entries.length ? base : store.last;
|
|
371
|
+
if (store.landed !== before) landed.push(tag.name);
|
|
372
|
+
// Saves record exact inclusion; only legacy captures advance a watermark.
|
|
373
|
+
if (by?.v === 2) {
|
|
374
|
+
for (const [client, ranges] of frames) {
|
|
375
|
+
if (!saveReceipt(room, tag.name + '\n' + client, ranges, store.last, LIVE_LIMITS.receipts, pageReservations.get(key)?.receipts)) {
|
|
376
|
+
room.receiptGeneration = (room.receiptGeneration ?? 0) + 1;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
} else if (by) {
|
|
380
|
+
const nKey = tag.name + '\n' + by.client;
|
|
381
|
+
const last = room.lastN.get(nKey);
|
|
382
|
+
if ((!last || last.n < by.n) && (last || room.lastN.size < LIVE_LIMITS.receipts)) {
|
|
383
|
+
replaceReceipt(room, nKey, postReceipt(last, by.n, store.last));
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
// A merge is never refused: it can take the rooms past the budget, and
|
|
388
|
+
// then only frames that shrink a store land until they are under it.
|
|
389
|
+
const heldDelta = heldBy(room) - heldBefore;
|
|
390
|
+
if (!valueChanged && !landed.length) return { result: null, heldDelta };
|
|
391
|
+
return { result: { room, valueChanged, landed }, heldDelta };
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const heldRecord = (token) => {
|
|
395
|
+
const record = persistentTokens.get(token);
|
|
396
|
+
if (!record || record.token !== token) throw new PersistentReservationError('invalid-reservation');
|
|
397
|
+
return record;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
return {
|
|
401
|
+
/**
|
|
402
|
+
* Whether a room for key may fit the budget, without reading its file: false
|
|
403
|
+
* while the last build of key was refused for the budget, in the last
|
|
404
|
+
* REFUSED_MS, and it still would not fit. Cheap, so a document refused once
|
|
405
|
+
* costs a file read at most once in that time while the rooms stay full.
|
|
406
|
+
*/
|
|
407
|
+
mayFit(key) {
|
|
408
|
+
const last = refused.get(key);
|
|
409
|
+
if (!last) return true;
|
|
410
|
+
if (now() - last.at > REFUSED_MS || used + last.held <= liveBytes) {
|
|
411
|
+
refused.delete(key);
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
return false;
|
|
415
|
+
},
|
|
416
|
+
|
|
417
|
+
reservePage(key, { nodeId, header, currentHtml, html, ifMatch }) {
|
|
418
|
+
const room = pageSource(key, nodeId);
|
|
419
|
+
const conversion = checkPageConversion({ header, currentHtml, html, ifMatch, room });
|
|
420
|
+
if (!conversion) throw new PageConversionError('invalid-header');
|
|
421
|
+
const { source, base, epoch, names } = conversion;
|
|
422
|
+
return reservePageTarget(key, nodeId, room, html, { source, base, epoch, names, format: 'yjs' });
|
|
423
|
+
},
|
|
424
|
+
|
|
425
|
+
reservePageInitial(key, { nodeId, currentHtml, html, ifMatch }) {
|
|
426
|
+
const room = pageSource(key, nodeId);
|
|
427
|
+
checkPageInitial({ currentHtml, html, ifMatch, room });
|
|
428
|
+
return reservePageTarget(key, nodeId, room, html, { source: 'initial', names: [], format: 'yjs' });
|
|
429
|
+
},
|
|
430
|
+
|
|
431
|
+
reservePageReplacement(key, { nodeId, html }) {
|
|
432
|
+
const room = pageSource(key, nodeId);
|
|
433
|
+
if (typeof html !== 'string') throw new PageConversionError('invalid-capture');
|
|
434
|
+
return reservePageTarget(key, nodeId, room, html, { source: 'replacement', format: inspectPage(html).kind });
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
pageReserved(key) {
|
|
438
|
+
return pageReservations.has(key);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
commitPage(token) {
|
|
442
|
+
const reservation = pageReservations.get(token?.key);
|
|
443
|
+
if (!reservation || reservation.token !== token) throw new PageConversionError('invalid-reservation');
|
|
444
|
+
if (reservation.committed) return reservation.result;
|
|
445
|
+
const room = rooms.get(token.key) ?? null;
|
|
446
|
+
if (room !== reservation.room) throw new PageConversionError('room-changed');
|
|
447
|
+
if (!room) {
|
|
448
|
+
reservation.committed = true;
|
|
449
|
+
reservation.result = null;
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
const before = heldBy(room);
|
|
453
|
+
const { stores, transition } = reservation.target;
|
|
454
|
+
for (const name of room.stores.keys()) if (PAGE.test(name)) room.stores.delete(name);
|
|
455
|
+
for (const key of reservation.receipts) {
|
|
456
|
+
room.receiptBytes = (room.receiptBytes ?? 0) - receiptBytes(room.lastN.get(key));
|
|
457
|
+
room.lastN.delete(key);
|
|
458
|
+
}
|
|
459
|
+
room.seq += 1;
|
|
460
|
+
let bytes = roomBytes(room);
|
|
461
|
+
for (const [name, store] of stores) {
|
|
462
|
+
if (room.stores.size >= LIVE_LIMITS.storesPerRoom || bytes + store.bytes > LIVE_LIMITS.roomBytes) continue;
|
|
463
|
+
store.last = store.landed = store.floor = room.seq;
|
|
464
|
+
room.stores.set(name, store);
|
|
465
|
+
bytes += store.bytes;
|
|
466
|
+
}
|
|
467
|
+
room.pageTransition = transition;
|
|
468
|
+
reservation.target = null;
|
|
469
|
+
used += heldBy(room) - before - reservation.budget;
|
|
470
|
+
reservation.budget = 0;
|
|
471
|
+
reservation.committed = true;
|
|
472
|
+
reservation.result = { room, valueChanged: true, landed: [] };
|
|
473
|
+
return reservation.result;
|
|
474
|
+
},
|
|
475
|
+
|
|
476
|
+
releasePage(token) {
|
|
477
|
+
const reservation = pageReservations.get(token?.key);
|
|
478
|
+
if (!reservation || reservation.token !== token) return false;
|
|
479
|
+
used -= reservation.budget;
|
|
480
|
+
pageReservations.delete(token.key);
|
|
481
|
+
reservedNodes.delete(String(token.nodeId));
|
|
482
|
+
if (reservation.drop) {
|
|
483
|
+
const room = rooms.get(token.key);
|
|
484
|
+
if (room) used -= heldBy(room);
|
|
485
|
+
rooms.delete(token.key);
|
|
486
|
+
}
|
|
487
|
+
return true;
|
|
488
|
+
},
|
|
489
|
+
|
|
490
|
+
holdPersistent(key, { nodeId, requireClean = false } = {}) {
|
|
491
|
+
const room = rooms.get(key);
|
|
492
|
+
if (!room || nodeId == null || room.nodeId == null || String(room.nodeId) !== String(nodeId)) {
|
|
493
|
+
throw new PersistentReservationError('room-changed');
|
|
494
|
+
}
|
|
495
|
+
if (persistentReservations.has(key) || pageReservations.has(key) || reservedNodes.has(String(nodeId))) {
|
|
496
|
+
throw new PersistentReservationError('checkpointing');
|
|
497
|
+
}
|
|
498
|
+
if (requireClean) {
|
|
499
|
+
for (const store of room.stores.values()) {
|
|
500
|
+
if (store.ephemeral) continue;
|
|
501
|
+
if (store.landed === -1 || store.landed !== store.last) throw new PersistentReservationError('unlanded');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
const token = Object.freeze({});
|
|
505
|
+
const record = { token, room, key, nodeId, html: null, preclaim: 0, result: undefined, written: false, drop: false };
|
|
506
|
+
persistentReservations.set(key, record);
|
|
507
|
+
persistentTokens.set(token, record);
|
|
508
|
+
reservedNodes.set(String(nodeId), token);
|
|
509
|
+
return token;
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
checkPersistent(token, { html } = {}) {
|
|
513
|
+
const record = heldRecord(token);
|
|
514
|
+
if (record.html !== null) throw new PersistentReservationError('invalid-reservation');
|
|
515
|
+
if (rooms.get(record.key) !== record.room) throw new PersistentReservationError('room-changed');
|
|
516
|
+
if (typeof html !== 'string') throw new PersistentReservationError('invalid-target');
|
|
517
|
+
const candidate = new Map();
|
|
518
|
+
const counts = new Map();
|
|
519
|
+
for (const tag of liveTags(html, { includeDuplicates: true })) {
|
|
520
|
+
counts.set(tag.name, (counts.get(tag.name) ?? 0) + 1);
|
|
521
|
+
if (!candidate.has(tag.name)) candidate.set(tag.name, tag);
|
|
522
|
+
}
|
|
523
|
+
for (const [name, store] of record.room.stores) {
|
|
524
|
+
if (store.ephemeral) continue;
|
|
525
|
+
const tag = candidate.get(name);
|
|
526
|
+
if (!tag || tag.ephemeral || counts.get(name) !== 1) throw new PersistentReservationError('invalid-target');
|
|
527
|
+
let value;
|
|
528
|
+
try {
|
|
529
|
+
value = cloneJson(parseJsonRelaxed(tag.text));
|
|
530
|
+
} catch {
|
|
531
|
+
throw new PersistentReservationError('invalid-target');
|
|
532
|
+
}
|
|
533
|
+
if (!isContainer(value) || Array.isArray(value) !== Array.isArray(store.value)) {
|
|
534
|
+
throw new PersistentReservationError('invalid-target');
|
|
535
|
+
}
|
|
536
|
+
if (PAGE.test(name)) {
|
|
537
|
+
if (record.room.pageTransition?.format === 'yjs') {
|
|
538
|
+
if (name !== 'page' || !isYPageValue(value) || value.g !== store.value.g) {
|
|
539
|
+
throw new PersistentReservationError('invalid-target');
|
|
540
|
+
}
|
|
541
|
+
} else if (isYPageValue(value, { complete: false })) throw new PersistentReservationError('invalid-target');
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
for (const tag of candidate.values()) {
|
|
545
|
+
if (tag.ephemeral) continue;
|
|
546
|
+
const store = record.room.stores.get(tag.name);
|
|
547
|
+
if (!store || store.ephemeral) throw new PersistentReservationError('invalid-target');
|
|
548
|
+
}
|
|
549
|
+
const currentHeld = heldBy(record.room);
|
|
550
|
+
const projected = structuredClone(record.room);
|
|
551
|
+
reconcileFile(projected, record.key, html, {});
|
|
552
|
+
for (const [name, store] of record.room.stores) {
|
|
553
|
+
if (!store.ephemeral && !projected.stores.has(name)) throw new PersistentReservationError('budget');
|
|
554
|
+
}
|
|
555
|
+
let projectedRoomBytes = 0;
|
|
556
|
+
for (const store of projected.stores.values()) {
|
|
557
|
+
if (store.bytes > LIVE_LIMITS.storeBytes) throw new PersistentReservationError('budget');
|
|
558
|
+
projectedRoomBytes += store.bytes;
|
|
559
|
+
}
|
|
560
|
+
if (projectedRoomBytes > LIVE_LIMITS.roomBytes) throw new PersistentReservationError('budget');
|
|
561
|
+
const projectedHeld = heldBy(projected);
|
|
562
|
+
if (used - currentHeld + projectedHeld > liveBytes) throw new PersistentReservationError('budget');
|
|
563
|
+
const preclaim = Math.max(0, projectedHeld - currentHeld);
|
|
564
|
+
used += preclaim;
|
|
565
|
+
record.html = html;
|
|
566
|
+
record.preclaim = preclaim;
|
|
567
|
+
return Object.freeze({ html });
|
|
568
|
+
},
|
|
569
|
+
|
|
570
|
+
writtenPersistent(token, { html } = {}) {
|
|
571
|
+
const record = heldRecord(token);
|
|
572
|
+
if (record.html === null || html !== record.html) throw new PersistentReservationError('not-prepared');
|
|
573
|
+
if (record.written) return record.result;
|
|
574
|
+
if (rooms.get(record.key) !== record.room) throw new PersistentReservationError('room-changed');
|
|
575
|
+
const { result, heldDelta } = reconcileFile(record.room, record.key, html, {});
|
|
576
|
+
used += heldDelta;
|
|
577
|
+
used -= record.preclaim;
|
|
578
|
+
record.preclaim = 0;
|
|
579
|
+
record.written = true;
|
|
580
|
+
record.result = result;
|
|
581
|
+
return result;
|
|
582
|
+
},
|
|
583
|
+
|
|
584
|
+
rekeyPersistent(token, newKey) {
|
|
585
|
+
const record = heldRecord(token);
|
|
586
|
+
if (typeof newKey !== 'string' || newKey === '' || newKey === record.key) {
|
|
587
|
+
throw new PersistentReservationError('invalid-target');
|
|
588
|
+
}
|
|
589
|
+
if (rooms.has(newKey) || pageReservations.has(newKey) || persistentReservations.has(newKey)) {
|
|
590
|
+
throw new PersistentReservationError('destination-exists');
|
|
591
|
+
}
|
|
592
|
+
rooms.delete(record.key);
|
|
593
|
+
rooms.set(newKey, record.room);
|
|
594
|
+
refused.delete(newKey);
|
|
595
|
+
persistentReservations.delete(record.key);
|
|
596
|
+
persistentReservations.set(newKey, record);
|
|
597
|
+
record.key = newKey;
|
|
598
|
+
record.room.key = newKey;
|
|
599
|
+
},
|
|
600
|
+
|
|
601
|
+
releasePersistent(token) {
|
|
602
|
+
const record = persistentTokens.get(token);
|
|
603
|
+
if (!record || record.token !== token) {
|
|
604
|
+
if (releasedTokens.has(token)) return false;
|
|
605
|
+
throw new PersistentReservationError('invalid-reservation');
|
|
606
|
+
}
|
|
607
|
+
persistentTokens.delete(token);
|
|
608
|
+
releasedTokens.add(token);
|
|
609
|
+
persistentReservations.delete(record.key);
|
|
610
|
+
if (reservedNodes.get(String(record.nodeId)) === token) reservedNodes.delete(String(record.nodeId));
|
|
611
|
+
used -= record.preclaim;
|
|
612
|
+
record.preclaim = 0;
|
|
613
|
+
if (record.drop) {
|
|
614
|
+
const room = rooms.get(record.key);
|
|
615
|
+
if (room) used -= heldBy(room);
|
|
616
|
+
rooms.delete(record.key);
|
|
617
|
+
}
|
|
618
|
+
return true;
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/** The room for key, or null. */
|
|
622
|
+
get(key) {
|
|
623
|
+
return rooms.get(key) || null;
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
/** The room for key, created from html when absent; null when the registry is full. */
|
|
627
|
+
open(key, html, { nodeId = null } = {}) {
|
|
628
|
+
let room = rooms.get(key);
|
|
629
|
+
if (room) {
|
|
630
|
+
room.emptySince = null;
|
|
631
|
+
return room;
|
|
632
|
+
}
|
|
633
|
+
if (pageReservations.has(key) || persistentReservations.has(key) || rooms.size >= LIVE_LIMITS.rooms) return null;
|
|
634
|
+
room = build(key, html, nodeId);
|
|
635
|
+
const held = heldBy(room);
|
|
636
|
+
if (used + held > liveBytes) {
|
|
637
|
+
refused.delete(key);
|
|
638
|
+
refused.set(key, { held, at: now() });
|
|
639
|
+
if (refused.size > LIVE_LIMITS.rooms) refused.delete(refused.keys().next().value);
|
|
640
|
+
return null;
|
|
641
|
+
}
|
|
642
|
+
refused.delete(key);
|
|
643
|
+
used += held;
|
|
644
|
+
rooms.set(key, room);
|
|
645
|
+
return room;
|
|
646
|
+
},
|
|
647
|
+
|
|
648
|
+
/** Start an existing room over from html (an outside edit changed a live tag): new epoch, seq 0. */
|
|
649
|
+
reset(key, html, { nodeId } = {}) {
|
|
650
|
+
if (pageReservations.has(key) || persistentReservations.has(key)) return null;
|
|
651
|
+
const previous = rooms.get(key);
|
|
652
|
+
if (!previous) return null;
|
|
653
|
+
const room = build(key, html, nodeId === undefined ? previous.nodeId : nodeId);
|
|
654
|
+
used -= heldBy(previous);
|
|
655
|
+
const held = heldBy(room);
|
|
656
|
+
if (used + held > liveBytes) {
|
|
657
|
+
rooms.delete(key);
|
|
658
|
+
return null;
|
|
659
|
+
}
|
|
660
|
+
used += held;
|
|
661
|
+
rooms.set(key, room);
|
|
662
|
+
return room;
|
|
663
|
+
},
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The file for key was written. Per saved store, the file's value with the
|
|
667
|
+
* room's moves since its base (baseOf) replayed on top becomes the store's
|
|
668
|
+
* value, logged as a diff at the next seq, and `landed` says how far the
|
|
669
|
+
* file now reaches. A base the log no longer reaches leaves the value alone
|
|
670
|
+
* and asks for a rewrite (landed -1).
|
|
671
|
+
* A value that no longer fits takes the store out of the room. Returns null
|
|
672
|
+
* when nothing changed, else { room, valueChanged, landed: names whose
|
|
673
|
+
* landed alone moved }.
|
|
674
|
+
*/
|
|
675
|
+
fileWritten(key, html, { restored = false } = {}) {
|
|
676
|
+
if (persistentReservations.has(key)) throw new PersistentReservationError('checkpointing');
|
|
677
|
+
const room = rooms.get(key);
|
|
678
|
+
if (!room) return null;
|
|
679
|
+
const { result, heldDelta } = reconcileFile(room, key, html, { restored });
|
|
680
|
+
used += heldDelta;
|
|
681
|
+
return result;
|
|
682
|
+
},
|
|
683
|
+
|
|
684
|
+
drop(key) {
|
|
685
|
+
const reservation = pageReservations.get(key);
|
|
686
|
+
if (reservation) {
|
|
687
|
+
reservation.drop = true;
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const persistent = persistentReservations.get(key);
|
|
691
|
+
if (persistent) {
|
|
692
|
+
persistent.drop = true;
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const room = rooms.get(key);
|
|
696
|
+
if (room) used -= heldBy(room);
|
|
697
|
+
rooms.delete(key);
|
|
698
|
+
},
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Apply one frame { name, n, ops, epoch? } from clientId, atomically.
|
|
702
|
+
* connected: client ids on the stream; when given, only a connected sender may take the writer slot.
|
|
703
|
+
* @returns {{ seq: number, epoch: string, name: string, ops: object[], writer: string, checkpoint: boolean }
|
|
704
|
+
* | { duplicate: true, seq: number, epoch: string }
|
|
705
|
+
* | { busy: true, reason?: 'budget' }
|
|
706
|
+
* | { stale: true, epoch: string }
|
|
707
|
+
* | { error: 'bad-frame' | 'unknown-store' | 'bad-op' | 'too-large', message?: string }}
|
|
708
|
+
*/
|
|
709
|
+
apply(room, clientId, frame, { connected } = {}) {
|
|
710
|
+
const current = rooms.get(room.key);
|
|
711
|
+
if (current === room && persistentReservations.has(room.key)) return { error: 'checkpointing' };
|
|
712
|
+
if (!isContainer(frame)) return { error: 'bad-frame' };
|
|
713
|
+
if (current !== room) return { stale: true, epoch: current?.epoch ?? null };
|
|
714
|
+
if (pageReservations.has(room.key) && PAGE.test(frame.name)) return { error: 'page-converting' };
|
|
715
|
+
// Made for an earlier room: the tab resends its ops once it has this room's state.
|
|
716
|
+
if ((PAGE.test(frame.name) || frame.epoch !== undefined) && frame.epoch !== room.epoch) return { stale: true, epoch: room.epoch };
|
|
717
|
+
const transition = PAGE.test(frame.name) ? room.pageTransition : null;
|
|
718
|
+
if (PAGE.test(frame.name) && (transition?.format === 'yjs' || frame.pageGeneration !== undefined) &&
|
|
719
|
+
(frame.pageGeneration ?? null) !== (transition?.id ?? null)) return { error: 'page-generation' };
|
|
720
|
+
const converted = transition?.format === 'yjs';
|
|
721
|
+
if (converted && frame.name !== 'page') return { error: 'page-format' };
|
|
722
|
+
const store = room.stores.get(frame.name);
|
|
723
|
+
if (!store) return { error: 'unknown-store' };
|
|
724
|
+
if (!Number.isInteger(frame.n) || frame.n < 1) return { error: 'bad-frame' };
|
|
725
|
+
if (!Array.isArray(frame.ops) || frame.ops.length === 0 || frame.ops.length > LIVE_LIMITS.opsPerFrame) {
|
|
726
|
+
return { error: 'bad-frame' };
|
|
727
|
+
}
|
|
728
|
+
const hybridSource = PAGE.test(frame.name) && isHybridPageValue(store.value);
|
|
729
|
+
let ops;
|
|
730
|
+
if (converted) {
|
|
731
|
+
try {
|
|
732
|
+
ops = frame.ops.map(validateOp);
|
|
733
|
+
} catch (err) {
|
|
734
|
+
return { error: 'bad-op', message: err.message };
|
|
735
|
+
}
|
|
736
|
+
if (ops.some((op) => op.path.length !== 2 || op.path[0] !== 'y' ||
|
|
737
|
+
!(op.op === 'del' || (op.op === 'set' && typeof op.value === 'string')))) return { error: 'page-format' };
|
|
738
|
+
}
|
|
739
|
+
const nKey = frame.name + '\n' + clientId;
|
|
740
|
+
const last = room.lastN.get(nKey);
|
|
741
|
+
// Answered with the seq the sender's last frame landed at, so the tab knows
|
|
742
|
+
// which applied state holds it.
|
|
743
|
+
if (receiptHas(last, frame.n)) return { duplicate: true, seq: frame.n <= last.n ? last.seq : last.savedSeq, epoch: room.epoch };
|
|
744
|
+
if ((room.retiredReceipts?.get(nKey) ?? 0) > (frame.receiptGeneration ?? 0)) return { error: 'receipt-expired' };
|
|
745
|
+
// A receipt is kept while its sender is connected. Full, the least recently
|
|
746
|
+
// used receipt of a sender no longer connected makes room; with none, a new
|
|
747
|
+
// sender waits.
|
|
748
|
+
let victim = null;
|
|
749
|
+
if (!last && room.lastN.size >= LIVE_LIMITS.receipts) {
|
|
750
|
+
for (const key of room.lastN.keys()) {
|
|
751
|
+
if (pageReservations.get(room.key)?.receipts.has(key)) continue;
|
|
752
|
+
const sender = key.slice(key.indexOf('\n') + 1);
|
|
753
|
+
if (!connected || !connected.includes(sender)) {
|
|
754
|
+
victim = key;
|
|
755
|
+
break;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
if (victim === null) return { busy: true };
|
|
759
|
+
}
|
|
760
|
+
// In place, never a copy: each op's change is sized from the one slot it
|
|
761
|
+
// touched, and a frame that is refused is taken back op by op.
|
|
762
|
+
let value = store.value;
|
|
763
|
+
let bytes = store.bytes;
|
|
764
|
+
const done = [];
|
|
765
|
+
const undo = () => {
|
|
766
|
+
for (let i = done.length - 1; i >= 0; i--) undoTracked(done[i]);
|
|
767
|
+
};
|
|
768
|
+
try {
|
|
769
|
+
ops ??= frame.ops.map(validateOp);
|
|
770
|
+
for (const op of ops) {
|
|
771
|
+
const rec = applyTracked(value, op);
|
|
772
|
+
if (!rec) continue;
|
|
773
|
+
if (op.op === 'set' && isDeepStrictEqual(rec.old, rec.at === null ? rec.root : rec.at[rec.key])) {
|
|
774
|
+
undoTracked(rec);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
done.push(rec);
|
|
778
|
+
bytes += sizeChange(rec, sizeOf, bytes);
|
|
779
|
+
value = rec.root;
|
|
780
|
+
}
|
|
781
|
+
} catch (err) {
|
|
782
|
+
undo();
|
|
783
|
+
return { error: 'bad-op', message: err.message };
|
|
784
|
+
}
|
|
785
|
+
if (converted && !isYPageValue(value, { complete: false })) {
|
|
786
|
+
undo();
|
|
787
|
+
return { error: 'page-format' };
|
|
788
|
+
}
|
|
789
|
+
if (!converted && PAGE.test(frame.name) && (isYPageValue(value, { complete: false }) ||
|
|
790
|
+
(hybridSource && (Object.hasOwn(value, 'y') || Object.hasOwn(value, 'v'))))) {
|
|
791
|
+
undo();
|
|
792
|
+
return { error: 'page-conversion-required' };
|
|
793
|
+
}
|
|
794
|
+
if (bytes > LIVE_LIMITS.storeBytes || roomBytes(room) - store.bytes + bytes > LIVE_LIMITS.roomBytes) {
|
|
795
|
+
undo();
|
|
796
|
+
return { error: 'too-large' };
|
|
797
|
+
}
|
|
798
|
+
const logged = done.length ? byteLength(JSON.stringify(ops)) : 0;
|
|
799
|
+
const grows = done.length ? bytes - store.bytes + logAfter(store, logged) - store.logBytes : 0;
|
|
800
|
+
const retiredBytes = victim === null ? 0 : receiptBytes(room.lastN.get(victim));
|
|
801
|
+
if (grows > 0 && used + grows - retiredBytes > liveBytes) {
|
|
802
|
+
undo();
|
|
803
|
+
return { busy: true, reason: 'budget' };
|
|
804
|
+
}
|
|
805
|
+
if (victim !== null) used += retireReceipt(room, victim);
|
|
806
|
+
used += grows;
|
|
807
|
+
store.value = value;
|
|
808
|
+
store.bytes = bytes;
|
|
809
|
+
if (done.length) {
|
|
810
|
+
room.seq += 1;
|
|
811
|
+
store.last = room.seq;
|
|
812
|
+
store.log.push({ seq: room.seq, ops, bytes: logged, from: clientId, n: frame.n });
|
|
813
|
+
store.logBytes += logged;
|
|
814
|
+
evict(store);
|
|
815
|
+
}
|
|
816
|
+
// Least recently used first, so the cap forgets a sender long gone.
|
|
817
|
+
used += replaceReceipt(room, nKey, postReceipt(last, frame.n, room.seq));
|
|
818
|
+
room.retiredReceipts?.delete(nKey);
|
|
819
|
+
room.lastSentAt.set(clientId, now());
|
|
820
|
+
room.seen.set(clientId, now());
|
|
821
|
+
if (PAGE.test(frame.name) && typeof frame.adopted === 'string' && frame.adopted.length <= 64) room.adopted = frame.adopted;
|
|
822
|
+
const writerGone = room.writer && connected && !connected.includes(room.writer);
|
|
823
|
+
if ((!room.writer || writerGone) && (!connected || connected.includes(clientId))) room.writer = clientId;
|
|
824
|
+
return { seq: room.seq, epoch: room.epoch, name: frame.name, ops, writer: room.writer, checkpoint: pressed(store), receiptGeneration: room.receiptGeneration ?? 0, ...(transition && { pageGeneration: transition.id }) };
|
|
825
|
+
},
|
|
826
|
+
|
|
827
|
+
/** Everything a joining tab needs, with clientId's own receipt per store. Values are the room's own objects: serialize, never mutate. With since in this room's epoch, moves carries each store's log entries after since.seq (null where the log no longer reaches back that far). */
|
|
828
|
+
state(room, clientId = null, since = undefined) {
|
|
829
|
+
const stores = {};
|
|
830
|
+
for (const [name, s] of room.stores) {
|
|
831
|
+
const seen = {};
|
|
832
|
+
const mine = clientId ? room.lastN.get(name + '\n' + clientId) : null;
|
|
833
|
+
if (mine) seen[clientId] = mine.n;
|
|
834
|
+
stores[name] = { value: s.value, ephemeral: s.ephemeral, from: s.from, landed: s.landed, floor: s.floor, last: s.last, seen, ...(mine?.saved?.length && { saved: mine.saved }) };
|
|
835
|
+
}
|
|
836
|
+
// A client's own read (since is its { epoch, seq }, or null for a first
|
|
837
|
+
// read) carries moves: those after its seq in its own epoch, else this
|
|
838
|
+
// epoch's from its start, so a tab rejoining after a room loss places the
|
|
839
|
+
// heals other tabs already sent by their steps. A broadcast passes no since
|
|
840
|
+
// and carries none.
|
|
841
|
+
const provenance = { provenanceVersion: 2, receiptGeneration: room.receiptGeneration ?? 0 };
|
|
842
|
+
if (room.pageTransition) {
|
|
843
|
+
provenance.pageTransition = pageTransitionState(room.pageTransition, clientId);
|
|
844
|
+
}
|
|
845
|
+
if (since === undefined) return { epoch: room.epoch, seq: room.seq, writer: room.writer, stores, ...provenance };
|
|
846
|
+
const from = since && since.epoch === room.epoch && Number.isInteger(since.seq) ? since.seq : 0;
|
|
847
|
+
const moves = Object.fromEntries([...room.stores.keys()].map((name) => [name, null]));
|
|
848
|
+
let budget = LIVE_LIMITS.movesBytes - byteLength(JSON.stringify(moves));
|
|
849
|
+
for (const [name, s] of room.stores) {
|
|
850
|
+
const list = movesSince(s, from);
|
|
851
|
+
const bytes = list ? 2 + list.reduce((sum, move) => sum + byteLength(JSON.stringify(move)) + 1, 0) - (list.length ? 1 : 0) - 4 : 0;
|
|
852
|
+
if (list && bytes > budget) {
|
|
853
|
+
moves[name] = null;
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
budget -= bytes;
|
|
857
|
+
moves[name] = list;
|
|
858
|
+
}
|
|
859
|
+
return { epoch: room.epoch, seq: room.seq, writer: room.writer, stores, moves, ...provenance };
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
/** clientId read the room's state: it takes part, and may be elected writer. */
|
|
863
|
+
touch(room, clientId) {
|
|
864
|
+
if (clientId) room.seen.set(clientId, now());
|
|
865
|
+
},
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* clientId left. When it was the writer, elect the remaining participant (a
|
|
869
|
+
* connected client that posted or read the state) that sent most recently
|
|
870
|
+
* (ties: the first in remaining), or nobody.
|
|
871
|
+
* @param {string[]} remaining - client ids of editors still connected
|
|
872
|
+
* @returns {{ writerChanged: boolean, writer: string | null }}
|
|
873
|
+
*/
|
|
874
|
+
leave(room, clientId, remaining) {
|
|
875
|
+
if (!remaining.length) room.emptySince = now();
|
|
876
|
+
if (room.writer !== clientId) return { writerChanged: false, writer: room.writer };
|
|
877
|
+
let best = null;
|
|
878
|
+
let bestAt = -1;
|
|
879
|
+
for (const id of remaining.filter((candidate) => room.seen.has(candidate))) {
|
|
880
|
+
const at = room.lastSentAt.get(id) ?? 0;
|
|
881
|
+
if (at > bestAt) {
|
|
882
|
+
best = id;
|
|
883
|
+
bestAt = at;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
room.writer = best;
|
|
887
|
+
return { writerChanged: true, writer: best };
|
|
888
|
+
},
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Delete rooms that have been empty for retainMs or longer, and forget the
|
|
892
|
+
* election records of senders that are no longer connected and have not been
|
|
893
|
+
* seen for that long. Their receipts stay as long as the room: a sender that
|
|
894
|
+
* comes back and resends gets a duplicate, never a second apply.
|
|
895
|
+
* @param {(key: string) => string[]} [connectedOf] - client ids on a room's stream
|
|
896
|
+
* @param {{ mayExpire?: (room: object) => boolean }} [options] - mayExpire refuses one room's
|
|
897
|
+
* deletion; a dirty room is retained for its checkpoint rather than retired unsaved
|
|
898
|
+
*/
|
|
899
|
+
sweep(connectedOf = () => [], { mayExpire = () => true } = {}) {
|
|
900
|
+
const t = now();
|
|
901
|
+
for (const [key, room] of rooms) {
|
|
902
|
+
if (!roomIsDirty(room) && mayExpire(room) && !pageReservations.has(key) && !persistentReservations.has(key) && room.emptySince !== null && t - room.emptySince >= LIVE_LIMITS.retainMs) {
|
|
903
|
+
used -= heldBy(room);
|
|
904
|
+
rooms.delete(key);
|
|
905
|
+
continue;
|
|
906
|
+
}
|
|
907
|
+
const connected = connectedOf(key);
|
|
908
|
+
for (const [id, at] of room.seen) {
|
|
909
|
+
if (id === room.writer || connected.includes(id) || t - at < LIVE_LIMITS.retainMs) continue;
|
|
910
|
+
room.seen.delete(id);
|
|
911
|
+
room.lastSentAt.delete(id);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
},
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* The document this room holds now lives at another path (it was moved): the room follows it, so
|
|
918
|
+
* its frames, its dirty state and its checkpoint keep naming one document instead of a stale
|
|
919
|
+
* address. Refused when there is no room at `from`, when `to` is already another room's, or when
|
|
920
|
+
* `from` is under a persistent reservation (that owner moves it with rekeyPersistent).
|
|
921
|
+
*/
|
|
922
|
+
rekey(from, to) {
|
|
923
|
+
const room = rooms.get(from);
|
|
924
|
+
if (!room) return false;
|
|
925
|
+
if (from === to) return true;
|
|
926
|
+
if (rooms.has(to) || persistentReservations.has(from)) return false;
|
|
927
|
+
rooms.delete(from);
|
|
928
|
+
room.key = to;
|
|
929
|
+
rooms.set(to, room);
|
|
930
|
+
return true;
|
|
931
|
+
},
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* The file was confirmed to hold this capture's values (`isCheckpointCurrent`), so per captured
|
|
935
|
+
* store `landed` catches up to the seq the capture pinned. A store with later frames keeps them
|
|
936
|
+
* dirty: only what the file really holds stops being unsaved.
|
|
937
|
+
*/
|
|
938
|
+
markSaved(room, capture) {
|
|
939
|
+
if (rooms.get(room.key) !== room) return false;
|
|
940
|
+
let changed = false;
|
|
941
|
+
for (const { name } of capture.stores) {
|
|
942
|
+
const store = room.stores.get(name);
|
|
943
|
+
if (!store || store.ephemeral || store.last > capture.seq) continue;
|
|
944
|
+
if (store.landed === store.last) continue;
|
|
945
|
+
store.landed = store.last;
|
|
946
|
+
changed = true;
|
|
947
|
+
}
|
|
948
|
+
return changed;
|
|
949
|
+
},
|
|
950
|
+
|
|
951
|
+
/** Every room's key. */
|
|
952
|
+
keys() {
|
|
953
|
+
return [...rooms.keys()];
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
size() {
|
|
957
|
+
return rooms.size;
|
|
958
|
+
},
|
|
959
|
+
|
|
960
|
+
/** The bytes every room holds, counted against LIVE_LIMITS.liveBytes. */
|
|
961
|
+
used() {
|
|
962
|
+
return used;
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|