@panphora/clayjs 1.13.0 → 1.14.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.
Files changed (137) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69739 -41784
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +95 -94
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/plugins/visitor-drafts/prompt.js +4 -6
  114. package/src/server/checkpoint/index.js +218 -0
  115. package/src/server/rooms/index.js +59 -0
  116. package/src/server/rooms/live-data-ops.js +369 -0
  117. package/src/server/rooms/live-data-rooms.js +965 -0
  118. package/src/server/rooms/live-data-tags.js +49 -0
  119. package/src/server/rooms/live-page-history.js +41 -0
  120. package/src/server/rooms/live-provenance.js +142 -0
  121. package/src/server/rooms/page-admission.js +79 -0
  122. package/src/server/rooms/page-conversion.js +62 -0
  123. package/src/server/rooms/page-rebase.js +108 -0
  124. package/src/server/rooms/page-state.js +91 -0
  125. package/src/server/rooms/page-write-admission.js +63 -0
  126. package/src/server/rooms/page-write-guard.js +23 -0
  127. package/src/server/rooms/recent.js +18 -0
  128. package/src/server/rooms/spec-wire.js +17 -0
  129. package/src/standalone.js +22 -0
  130. package/src/sync/live-sync.js +13 -2
  131. package/src/sync/stream.js +62 -16
  132. package/src/ui/index.js +4 -0
  133. package/src/ui/toast.js +21 -5
  134. package/src/vendor/pierre/pierre.vendor.js +3 -0
  135. package/src/vendor/yjs.vendor.js +7 -0
  136. package/src/page/binding.js +0 -647
  137. package/src/page/replay.js +0 -578
@@ -4,7 +4,8 @@
4
4
  // the same frames through a transport:
5
5
  // start({ open, close, frame, state, writer }) -> stop function
6
6
  // send(clientId, { name, n, ops, epoch }) -> Promise<{ status, body, retryAfter }>
7
- // fetchState(clientId) -> Promise<{ epoch, seq, writer, stores } | { status } | null>
7
+ // fetchState(clientId, since, { signal }) -> Promise<{ epoch, seq, writer, stores } | { status } | null>
8
+ // answered within STATE_TIMEOUT_MS or abandoned: its signal aborts and the read is made again
8
9
  // A new room (a new epoch) holds some of this tab's frames still in flight; see
9
10
  // sortAwaiting for how each is sorted.
10
11
  //
@@ -13,12 +14,17 @@
13
14
  // the room's log rebuilds the value; -1: rewrite now). Both are numbers in the
14
15
  // current epoch, taken from the room and never carried across epochs.
15
16
 
16
- import { applyOps, cloneJson } from "./ops.js";
17
+ import { unionRanges, contains, frameSet, hasNumber } from "./provenance.js";
17
18
 
18
19
  export const MIN_GAP_MS = 40;
19
20
  export const MAX_OPS = 128;
20
21
  export const RETRY_MS = 500;
21
- export const FRAME_BYTES = 15 * 1024;
22
+ export const STATE_TIMEOUT_MS = 10_000;
23
+ export const FRAME_BYTES = 63 * 1024;
24
+ // What a host still on the old 16 KB body cap takes: a multi-op frame it
25
+ // answers 413 is packed again at this size for the rest of the session.
26
+ export const OLD_FRAME_BYTES = 15 * 1024;
27
+ const PAGE_STORE = /^page(\.[0-9]+)?$/;
22
28
  const BUFFER_MAX = 512;
23
29
  const RETRY_MAX_MS = 8000;
24
30
  const REVOKED = new Set([401, 403]);
@@ -42,6 +48,12 @@ const unhandled = (status) => status === 429 || status === 503;
42
48
  * @param {(name: string) => boolean} [options.held] - a save still on the wire carries some of
43
49
  * this store's pending ops in file mode: nothing of the store is framed until release(name),
44
50
  * so those ops are framed in order if the save does not land, and never if it does
51
+ * @param {(name: string) => number[]} [options.heldIds] - the ids of those ops: a room state
52
+ * cannot say whether they are in its value, so it is not applied to the store until the
53
+ * room's receipts hold their frames or hold(name, ids) lets go of them
54
+ * @param {(name: string, ids: number[]) => boolean} [options.hold] - true to keep waiting for
55
+ * the save's answer; false when the ops left pending (the caller gave up on them)
56
+ * @param {(name: string) => void} [options.taken] - the store took a room state
45
57
  * @param {() => void} [options.onStop] - the lane stopped for good
46
58
  */
47
59
  export function createFastLane({
@@ -54,6 +66,9 @@ export function createFastLane({
54
66
  onLanded = () => {},
55
67
  onCheckpoint = () => {},
56
68
  held = () => false,
69
+ heldIds = () => [],
70
+ hold = () => true,
71
+ taken = () => {},
57
72
  onStop = () => {},
58
73
  now = () => Date.now(),
59
74
  setTimer = setTimeout,
@@ -63,6 +78,9 @@ export function createFastLane({
63
78
  let connected = false;
64
79
  let synced = false;
65
80
  let epoch = null;
81
+ let receiptGeneration = null;
82
+ let pageGeneration = null;
83
+ let provenanceVersion = 0;
66
84
  let seq = 0;
67
85
  let writer = null;
68
86
  let roomNames = new Set();
@@ -75,7 +93,9 @@ export function createFastLane({
75
93
  let lastSentAt = -Infinity;
76
94
  let holdUntil = 0;
77
95
  let timer = null;
96
+ let readTimer = null;
78
97
  let retryDelay = RETRY_MS;
98
+ let frameBytes = FRAME_BYTES;
79
99
  let resyncDelay = RETRY_MS;
80
100
  // Bumped when the stream opens or closes, and when a state is taken: a state
81
101
  // request answered after either is obsolete.
@@ -85,13 +105,19 @@ export function createFastLane({
85
105
  let conn;
86
106
  let stateCount = 0;
87
107
  let fetchAgain = false;
108
+ let reads = 0;
109
+ // A store left the room, so frames went by that its value never took: state
110
+ // reads ask for every store whole, never for the moves since seq, until a
111
+ // state is applied.
112
+ let missed = false;
88
113
  let unsubscribe = null;
89
114
  // Per store: the next n, ops not yet in a frame, frames sent but not yet seen
90
115
  // back (each { n, ops, through, seq, sent, refused, uncertain }), and the
91
116
  // newest op id ever put in a frame.
92
117
  const lanes = new Map();
118
+ const abandoned = new Set();
93
119
 
94
- const tell = (name, event) => entries.get(name)?.store.deliver?.({ code: null, n: null, seq: null, through: null, retryAfter: null, ...event });
120
+ const tell = (name, event) => entries.get(name)?.store.deliver?.({ code: null, n: null, seq: null, through: null, retryAfter: null, client: clientId, ...event });
95
121
 
96
122
  function laneOf(name) {
97
123
  let lane = lanes.get(name);
@@ -100,30 +126,49 @@ export function createFastLane({
100
126
  }
101
127
 
102
128
  // The store's ops still to frame: pending, past what was framed.
129
+ // The first frame that reaches an adoption's last op carries its marker.
130
+ function markAdopted(lane, frame) {
131
+ if (!lane.adopted || frame.through < lane.adopted.through) return;
132
+ frame.adopted = lane.adopted.rev;
133
+ lane.adopted = null;
134
+ }
135
+
103
136
  function unframed(name, lane) {
104
137
  const store = entries.get(name)?.store;
105
138
  if (!store) return [];
106
139
  return store.pendingEntries().filter((pending) => pending.id > lane.framed);
107
140
  }
108
141
 
142
+ // A frame none of whose ops is pending any more (a save at save speed took
143
+ // them, and the room merges that file) leaves the lane: never sent again.
144
+ function prune(name, lane) {
145
+ const store = entries.get(name)?.store;
146
+ if (!store) return;
147
+ const live = new Set(store.pendingEntries().map((pending) => pending.id));
148
+ lane.awaiting = lane.awaiting.filter((frame) => frame === flying || !frame.entries || frame.entries.some((e) => live.has(e.id)));
149
+ }
150
+
109
151
  // Never applied: every attempt was refused before the room handled it.
110
152
  const unapplied = (frame) => frame.refused === true && !frame.uncertain;
111
153
 
112
- // Stop sending this store: it leaves the room. A frame the room may have
113
- // applied is dropped from pending (never replayed over a file that may hold
114
- // it); a frame refused unapplied, and ops never framed, stay pending for file
115
- // mode. Refused frames come last: a refusal stops sending until the next state.
116
- function leaveRoom(name, lane) {
154
+ // Stop sending this store: it leaves the room. Every op the room has not
155
+ // confirmed stays pending, so the next save writes it: an op never framed, a
156
+ // frame refused, a frame that landed but has not come back, and the frame on
157
+ // the wire, whose answer decides it (decide). The frames stay as records: a
158
+ // rejoin settles each by the room's answer or receipt, or sends it again.
159
+ // Leaving wakes the checkpoints (onApplied): what is pending now saves at
160
+ // save speed. kept: the room still holds the store, so its frames go by
161
+ // while this tab is out, and the rejoin reads it whole (missed). A store the
162
+ // room does not hold has no frames to miss.
163
+ function leaveRoom(name, lane, kept = true) {
117
164
  const store = entries.get(name)?.store;
118
- let through = 0;
119
- for (const frame of lane.awaiting) if (!unapplied(frame)) through = frame.through;
120
- if (store && through) store.rebase(store.authoritative, { source: "state", through, cause: "leave" });
165
+ for (const frame of lane.awaiting) if (frame === flying) frame.undecided = true;
166
+ if (kept) missed = true;
167
+ lane.out = true;
121
168
  onLeave(name, { epoch, seq });
122
169
  lane.unsent = [];
123
- lane.awaiting = [];
124
- // What is left pending is framed afresh if the store comes back.
125
- lane.framed = through;
126
170
  store?.setMode("file");
171
+ onApplied();
127
172
  }
128
173
 
129
174
  function schedule() {
@@ -140,30 +185,25 @@ export function createFastLane({
140
185
  // oldest unsent ops of one store; stores take turns.
141
186
  function nextFrame() {
142
187
  for (const [name, lane] of lanes) {
188
+ if (!roomNames.has(name) || held(name)) continue;
143
189
  const retry = lane.awaiting.find((frame) => !frame.sent);
144
190
  if (retry) return { name, lane, frame: retry };
145
191
  }
146
192
  for (const [name, lane] of lanes) {
147
193
  if (!lane.unsent.length || !roomNames.has(name) || held(name)) continue;
148
- // Packed by bytes as well as count: the host refuses a body over 16 KB.
194
+ // Packed by bytes as well as count, so a host still on a 64 KB body cap
195
+ // takes every frame; one op larger than a frame goes in a frame of its own.
149
196
  let size = byteSize({ name, n: lane.n + 1, ops: [], epoch });
150
197
  let count = 0;
151
198
  while (count < lane.unsent.length && count < MAX_OPS) {
152
199
  const more = byteSize(lane.unsent[count].op) + 1;
153
- if (size + more > FRAME_BYTES) break;
200
+ if (count && size + more > frameBytes) break;
154
201
  size += more;
155
202
  count++;
156
203
  }
157
- if (count === 0) {
158
- const [entry] = lane.unsent.splice(0, 1);
159
- console.warn(`clay.live("${name}"): one update was larger than ${FRAME_BYTES} bytes, so it was undone`);
160
- const store = entries.get(name)?.store;
161
- store?.rebase(store.authoritative, { source: "state", through: entry.id, cause: "undone" });
162
- tell(name, { phase: "undone", code: "op-too-large", through: entry.id });
163
- continue;
164
- }
165
204
  const take = lane.unsent.splice(0, count);
166
- const frame = { n: ++lane.n, ops: take.map((entry) => entry.op), through: take[take.length - 1].id, seq: null, sent: false };
205
+ const frame = { n: ++lane.n, ops: take.map((entry) => entry.op), entries: take, first: take[0].id, through: take[take.length - 1].id, seq: null, sent: false, receiptGeneration, pageGeneration };
206
+ markAdopted(lane, frame);
167
207
  lane.framed = Math.max(lane.framed, frame.through);
168
208
  lane.awaiting.push(frame);
169
209
  return { name, lane, frame };
@@ -179,9 +219,11 @@ export function createFastLane({
179
219
  lanes.delete(name);
180
220
  lanes.set(name, lane);
181
221
  const sentEpoch = epoch;
222
+ frame.epoch = sentEpoch;
223
+ frame.client = clientId;
182
224
  frame.sentOn = connection;
183
225
  frame.sent = true;
184
- tell(name, { phase: "sent", n: frame.n, through: frame.through });
226
+ tell(name, { phase: "sent", n: frame.n, first: frame.first, through: frame.through });
185
227
  // Refusal is evidence about earlier attempts only: this one may apply.
186
228
  frame.refused = false;
187
229
  inFlight = true;
@@ -189,7 +231,7 @@ export function createFastLane({
189
231
  lastSentAt = now();
190
232
  let result;
191
233
  try {
192
- result = await transport.send(clientId, { name, n: frame.n, ops: frame.ops, epoch: sentEpoch, ...(conn !== undefined && { conn }) });
234
+ result = await transport.send(clientId, { name, n: frame.n, ops: frame.ops, epoch: sentEpoch, ...(conn !== undefined && { conn }), ...(frame.receiptGeneration !== null && { receiptGeneration: frame.receiptGeneration }), ...(frame.pageGeneration !== null && PAGE_STORE.test(name) && { pageGeneration: frame.pageGeneration }), ...(frame.adopted && { adopted: frame.adopted }) });
193
235
  } catch {
194
236
  result = null;
195
237
  }
@@ -212,6 +254,19 @@ export function createFastLane({
212
254
  frame.refused = true;
213
255
  frame.sent = false;
214
256
  frame.seq = null;
257
+ if (frame.epoch !== epoch) frame.receiptGeneration = receiptGeneration;
258
+ if (frame.epoch !== epoch) frame.pageGeneration = pageGeneration;
259
+ return;
260
+ }
261
+ // Its store left the room meanwhile: its ops stay pending for the save,
262
+ // and the frame waits for the rejoin, which settles it by its seq or the
263
+ // room's receipt.
264
+ if (!roomNames.has(name)) {
265
+ if (status >= 200 && status < 300 && Number.isInteger(body?.seq) && !body?.stale) frame.seq = body.seq;
266
+ else {
267
+ frame.uncertain = true;
268
+ frame.uncertainSince ??= frame.sentOn;
269
+ }
215
270
  return;
216
271
  }
217
272
  lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
@@ -223,6 +278,7 @@ export function createFastLane({
223
278
  // authoritative values now, whether its answer or its event said so.
224
279
  function settleAwaiting() {
225
280
  for (const [name, lane] of lanes) {
281
+ if (!roomNames.has(name)) continue;
226
282
  let through;
227
283
  while (lane.awaiting.length && lane.awaiting[0].seq !== null && lane.awaiting[0].seq <= seq) {
228
284
  through = lane.awaiting.shift().through;
@@ -303,24 +359,41 @@ export function createFastLane({
303
359
  console.warn(`clay.live("${name}"): the room has no such store, so it stays at save speed (${reason})`);
304
360
  frame.refused = true;
305
361
  roomNames.delete(name);
306
- leaveRoom(name, lane);
362
+ leaveRoom(name, lane, false);
307
363
  return;
308
364
  }
309
365
  lane.awaiting.splice(lane.awaiting.indexOf(frame), 1);
366
+ // A host still on the old body cap: pack its ops again, smaller, and send
367
+ // them again. Only an op too large on its own is refused. That host's 413
368
+ // comes from its body parser, with no code; the room's own size refusal
369
+ // (a store or room cap, code "too-large") is a refusal like any other.
370
+ if (status === 413 && !body?.code && frame.entries?.length > 1 && frameBytes > OLD_FRAME_BYTES) {
371
+ frameBytes = OLD_FRAME_BYTES;
372
+ console.warn(`clay.live("${name}"): the host takes smaller updates, so they go in frames of ${frameBytes} bytes`);
373
+ lane.unsent.unshift(...frame.entries);
374
+ if (frame.adopted) lane.adopted = { rev: frame.adopted, through: frame.through };
375
+ // Unframed again: a new room's state frames them afresh (unframed()).
376
+ lane.framed = frame.first - 1;
377
+ return;
378
+ }
310
379
  const store = entries.get(name)?.store;
311
- tell(name, { phase: "refused", code: body?.code ?? "refused", n: frame.n, through: frame.through });
380
+ const code = body?.code ?? (status === 413 && frame.entries?.length === 1 ? "too-large" : "refused");
381
+ tell(name, { phase: "refused", code, n: frame.n, from: frame.first, through: frame.through, ...(body?.code === "receipt-expired" && { ops: frame.ops }) });
312
382
  console.warn(`clay.live("${name}"): the host refused an update, so it was undone (${reason})`);
313
- store?.rebase(store.authoritative, { source: "state", through: frame.through, cause: "refused" });
383
+ store?.rebase(store.authoritative, { source: "state", drop: frame.first === undefined ? undefined : [frame.first, frame.through], through: frame.first === undefined ? frame.through : undefined, cause: "refused" });
384
+ if (body?.code === "receipt-expired") resync();
314
385
  }
315
386
 
316
387
  function apply(frame) {
317
388
  seq = frame.seq;
318
389
  const store = entries.get(frame.name)?.store;
319
- if (!store || !roomNames.has(frame.name)) return;
320
- let next;
321
- try {
322
- next = applyOps(cloneJson(store.authoritative), frame.ops);
323
- } catch {
390
+ if (!store || abandoned.has(frame.name)) return;
391
+ if (!roomNames.has(frame.name)) {
392
+ missed = true;
393
+ return;
394
+ }
395
+ const ops = store.checkOps(frame.ops);
396
+ if (!ops) {
324
397
  resync();
325
398
  return;
326
399
  }
@@ -335,7 +408,7 @@ export function createFastLane({
335
408
  lane.awaiting.splice(0, i + 1);
336
409
  }
337
410
  }
338
- store.rebase(next, { source: own ? "state" : "remote", through, frame: { seq: frame.seq, from: frame.from, n: frame.n, ops: frame.ops } });
411
+ store.rebaseOps(ops, { source: own ? "state" : "remote", through, frame: { seq: frame.seq, from: frame.from, n: frame.n, ops: frame.ops } });
339
412
  entries.get(frame.name).last = frame.seq;
340
413
  onApplied();
341
414
  if (frame.checkpoint === true) onCheckpoint();
@@ -374,20 +447,37 @@ export function createFastLane({
374
447
  function sortAwaiting(awaiting, room) {
375
448
  const seen = room.seen ? (room.seen[clientId] ?? 0) : null;
376
449
  const resend = [];
377
- let through = 0;
450
+ const dropIds = [];
378
451
  for (const frame of awaiting) {
379
- if (seen !== null && frame.n <= seen) through = frame.through;
452
+ if ((seen !== null && frame.n <= seen) || hasNumber(room.saved, frame.n)) dropIds.push(...frame.entries.map((e) => e.id));
380
453
  else if (frame.sent && frame === flying) {
381
454
  // Still on the wire: its answer decides.
382
455
  frame.undecided = true;
383
456
  resend.push(frame);
384
457
  } else if (unapplied(frame)) resend.push(frame);
385
- else through = frame.through;
458
+ else dropIds.push(...frame.entries.map((e) => e.id));
459
+ }
460
+ return { resend, dropIds };
461
+ }
462
+
463
+ function waiting(name, lane, room, fresh) {
464
+ const ids = heldIds(name);
465
+ if (!ids.length) return false;
466
+ if (!fresh) {
467
+ const covered = new Set();
468
+ for (const frame of lane.awaiting) {
469
+ if (hasNumber(room.saved, frame.n)) for (const op of frame.entries ?? []) covered.add(op.id);
470
+ }
471
+ if (ids.every((id) => covered.has(id))) return false;
386
472
  }
387
- return { resend, through };
473
+ if (!hold(name, ids)) {
474
+ prune(name, lane);
475
+ return false;
476
+ }
477
+ return true;
388
478
  }
389
479
 
390
- function applyState(state) {
480
+ function applyState(state, read = 0) {
391
481
  if (stopped || !state || typeof state.epoch !== "string") return;
392
482
  stateCount++;
393
483
  // Older than what this tab already applied: keep what it has.
@@ -396,44 +486,103 @@ export function createFastLane({
396
486
  return;
397
487
  }
398
488
  const fresh = state.epoch !== epoch;
489
+ provenanceVersion = Number.isInteger(state.provenanceVersion) ? state.provenanceVersion : 0;
490
+ receiptGeneration = Number.isSafeInteger(state.receiptGeneration) ? state.receiptGeneration : null;
491
+ const priorGeneration = pageGeneration;
492
+ pageGeneration = typeof state.pageTransition?.id === "string" ? state.pageTransition.id : null;
493
+ const replaced = pageGeneration !== priorGeneration;
399
494
  let joined = false;
495
+ // Every store is read whole here.
496
+ missed = false;
400
497
  epoch = state.epoch;
401
498
  seq = Number.isInteger(state.seq) ? state.seq : 0;
402
499
  writer = state.writer ?? null;
403
500
  if ("conn" in state) conn = state.conn;
404
501
  const stores = state.stores || {};
405
502
  roomNames = new Set(Object.keys(stores));
503
+ for (const name of abandoned) roomNames.delete(name);
504
+ let skipped = false;
505
+ let readDue = false;
406
506
  for (const [name, entry] of entries) {
507
+ if (abandoned.has(name)) continue;
407
508
  const room = stores[name];
408
509
  const lane = laneOf(name);
510
+ prune(name, lane);
511
+ const held = lane.room;
512
+ lane.room = !!room;
409
513
  if (!room) {
410
- leaveRoom(name, lane);
514
+ // Not in the room (too large, too many stores, or opened after it):
515
+ // there are no frames of it to miss. Already out: its value still
516
+ // descends from where it left. A store the room held and has now
517
+ // dropped was replaced by a document without it, which a 404 to this
518
+ // tab's next frame would also say. A replacement always installs a new page
519
+ // transition; a store dropped for size leaves it, and leaves quietly.
520
+ if (!lane.out) {
521
+ if (held && replaced) tell(name, { phase: "left", code: "no-store" });
522
+ leaveRoom(name, lane, false);
523
+ }
524
+ // A read the barrier waited for says no room holds this store: the file is its authority.
525
+ if (lane.after !== undefined && read >= lane.after) {
526
+ lane.after = undefined;
527
+ lane.deferred = false;
528
+ taken(name);
529
+ }
530
+ continue;
531
+ }
532
+ const behind = lane.after !== undefined && read < lane.after;
533
+ if (behind || waiting(name, lane, room, fresh || lane.stale === true)) {
534
+ roomNames.delete(name);
535
+ lane.deferred = true;
536
+ if (fresh) lane.stale = true;
537
+ if (behind) readDue = true;
538
+ skipped = true;
411
539
  continue;
412
540
  }
541
+ const wholly = fresh || lane.stale === true;
542
+ lane.deferred = false;
543
+ lane.stale = false;
544
+ lane.after = undefined;
413
545
  const rejoined = entry.store.handle.status.mode === "file";
546
+ lane.out = false;
414
547
  entry.store.setMode("fast");
415
548
  entry.last = Number.isInteger(room.last) ? room.last : 0;
416
549
  entry.landed = Number.isInteger(room.landed) ? room.landed : 0;
417
- if (fresh) {
418
- const { resend, through } = sortAwaiting(lane.awaiting, room);
550
+ const expired = [];
551
+ if (!wholly && receiptGeneration !== null) {
552
+ const seen = room.seen?.[clientId];
553
+ lane.awaiting = lane.awaiting.filter((frame) => {
554
+ const known = hasNumber(room.saved, frame.n) || (seen !== undefined && frame.n <= seen) || (frame.seq !== null && frame.seq <= seq);
555
+ if (known || (frame.receiptGeneration ?? 0) === receiptGeneration) return true;
556
+ expired.push(...frame.entries.map((e) => e.id));
557
+ tell(name, { phase: "refused", code: "receipt-expired", n: frame.n, from: frame.first, through: frame.through, ops: frame.ops });
558
+ console.warn(`clay.live("${name}"): the room retired an update receipt, so the update was not replayed. Check the saved value before restoring it.`);
559
+ return false;
560
+ });
561
+ }
562
+ if (wholly) {
563
+ const { resend, dropIds } = sortAwaiting(lane.awaiting, room);
419
564
  for (const frame of resend) {
420
565
  if (frame.undecided) continue;
421
566
  frame.sent = false;
422
567
  frame.seq = null;
568
+ frame.receiptGeneration = receiptGeneration;
569
+ frame.pageGeneration = pageGeneration;
423
570
  }
424
571
  lane.awaiting = resend;
425
- entry.store.rebase(room.value, { source: "state", through: through || undefined, room: { seq, fresh: true, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
572
+ entry.store.rebase(room.value, { source: "state", dropIds, room: { seq, fresh: true, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
426
573
  lane.unsent = unframed(name, lane);
427
574
  } else {
428
575
  const seen = room.seen ? room.seen[clientId] : undefined;
429
576
  const next = [];
430
- let through;
577
+ const dropIds = [...expired];
431
578
  for (const frame of lane.awaiting) {
432
- if (frame === flying) {
579
+ if (hasNumber(room.saved, frame.n)) {
580
+ dropIds.push(...frame.entries.map((e) => e.id));
581
+ } else if (frame === flying) {
433
582
  next.push(frame);
434
583
  } else if ((frame.seq !== null && frame.seq <= seq) || (seen !== undefined && frame.n <= seen)) {
435
584
  // In this state, by its answer or by the room's receipt.
436
- through = frame.through;
585
+ dropIds.push(...frame.entries.map((e) => e.id));
437
586
  } else if (frame.seq !== null) {
438
587
  // Answered as applied after this state was read: its event is on its way.
439
588
  next.push(frame);
@@ -446,22 +595,25 @@ export function createFastLane({
446
595
  next.push(frame);
447
596
  } else {
448
597
  // Unanswered, and the stream reconnected since: its receipt may be gone.
449
- through = frame.through;
598
+ dropIds.push(...frame.entries.map((e) => e.id));
450
599
  }
451
600
  }
452
601
  lane.awaiting = next;
453
- entry.store.rebase(room.value, { source: "state", through, room: { seq, fresh: false, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
602
+ entry.store.rebase(room.value, { source: "state", dropIds, room: { seq, fresh: false, moves: state.moves?.[name] ?? null, floor: Number.isInteger(room.floor) ? room.floor : null } });
454
603
  // Back in the room after a spell at save speed: frame what was made meanwhile.
455
604
  if (rejoined) {
456
605
  lane.unsent = unframed(name, lane);
457
606
  joined = true;
458
607
  }
459
608
  }
609
+ taken(name);
460
610
  }
611
+ if (skipped) missed = true;
461
612
  if (fresh || joined) onEpoch(epoch);
462
613
  onApplied();
463
614
  onWriter();
464
615
  drain();
616
+ if (readDue) resync();
465
617
  }
466
618
 
467
619
  // Back in step: apply what arrived while the state was being read.
@@ -484,11 +636,21 @@ export function createFastLane({
484
636
  fetching = true;
485
637
  fetchAgain = false;
486
638
  synced = false;
487
- const asked = { connection, stateCount };
639
+ const asked = { connection, stateCount, read: ++reads };
488
640
  let state = null;
641
+ const abort = typeof AbortController === "function" ? new AbortController() : null;
642
+ const expired = new Promise((resolve) => {
643
+ readTimer = setTimer(() => {
644
+ readTimer = null;
645
+ abort?.abort();
646
+ resolve(null);
647
+ }, STATE_TIMEOUT_MS);
648
+ });
489
649
  try {
490
- state = await transport.fetchState(clientId, epoch !== null && seq > 0 ? { epoch, seq } : null);
650
+ state = await Promise.race([transport.fetchState(clientId, epoch !== null && seq > 0 && !missed ? { epoch, seq } : null, abort ? { signal: abort.signal } : {}), expired]);
491
651
  } catch {}
652
+ if (readTimer !== null) clearTimer(readTimer);
653
+ readTimer = null;
492
654
  fetching = false;
493
655
  if (stopped) return;
494
656
  if (asked.connection !== connection || fetchAgain) {
@@ -501,13 +663,37 @@ export function createFastLane({
501
663
  }
502
664
  if (state && typeof state.epoch === "string") {
503
665
  resyncDelay = RETRY_MS;
504
- applyState(state);
666
+ applyState(state, asked.read);
505
667
  return;
506
668
  }
507
669
  if (state && STATE_GONE.has(state.status)) {
508
670
  stop();
509
671
  return;
510
672
  }
673
+ // The host has no room for this document now: it holds as much live data
674
+ // as it can (503 busy), or it is not answering (any other 503). Every
675
+ // store goes at save speed, with its own pending ops, so a save never
676
+ // writes the room's stale value. The lane asks again after Retry-After, a
677
+ // minute for busy, or its usual backoff.
678
+ if (state && state.status === 503) {
679
+ const busy = state.code === "busy";
680
+ for (const [name, entry] of entries) {
681
+ if (entry.store.handle.status.mode !== "fast") continue;
682
+ tell(name, { phase: "left", code: busy ? "busy" : "unavailable" });
683
+ console.warn(`clay.live("${name}"): the host has no room for this document now, so it stays at save speed for now`);
684
+ leaveRoom(name, laneOf(name));
685
+ }
686
+ roomNames = new Set();
687
+ if (!connected) return;
688
+ if (busy || state.retryAfter > 0) {
689
+ resyncDelay = RETRY_MS;
690
+ setTimer(resync, (state.retryAfter > 0 ? state.retryAfter : 60) * 1000);
691
+ } else {
692
+ setTimer(resync, resyncDelay);
693
+ resyncDelay = Math.min(resyncDelay * 2, RETRY_MAX_MS);
694
+ }
695
+ return;
696
+ }
511
697
  if (connected) {
512
698
  setTimer(resync, resyncDelay);
513
699
  resyncDelay = Math.min(resyncDelay * 2, RETRY_MAX_MS);
@@ -519,9 +705,12 @@ export function createFastLane({
519
705
  stopped = true;
520
706
  if (timer !== null) clearTimer(timer);
521
707
  timer = null;
708
+ if (readTimer !== null) clearTimer(readTimer);
709
+ readTimer = null;
522
710
  unsubscribe?.();
523
711
  for (const [name, entry] of entries) {
524
712
  const lane = lanes.get(name);
713
+ if (lane?.out) continue;
525
714
  if (lane) leaveRoom(name, lane);
526
715
  else {
527
716
  onLeave(name, { epoch, seq });
@@ -545,7 +734,12 @@ export function createFastLane({
545
734
  buffered = [];
546
735
  },
547
736
  frame: onFrame,
548
- state: applyState,
737
+ // A broadcast state carries no moves. While a store is out, the rejoin
738
+ // goes through this tab's own read, which carries them.
739
+ state(state) {
740
+ if (missed && state && !("moves" in state)) resync();
741
+ else applyState(state);
742
+ },
549
743
  // The room's writer, and how far the file reaches when a save landed.
550
744
  writer(data) {
551
745
  if (!data || data.epoch !== epoch) return;
@@ -578,13 +772,103 @@ export function createFastLane({
578
772
  ops.forEach((op, i) => lane.unsent.push({ id: first + i, op }));
579
773
  schedule();
580
774
  },
775
+ // The store's ops through `through` merged the file article with revision
776
+ // rev into the page: the frame that carries them tells the room, so its
777
+ // checkpoint knows the room holds that edit. A frame already sent leaves it
778
+ // to the next one.
779
+ // The store's history is not the room's (its page was rebuilt while this tab was
780
+ // away): nothing of it is sent again, and the room's frames for it are ignored.
781
+ abandon(name) {
782
+ if (abandoned.has(name)) return;
783
+ abandoned.add(name);
784
+ roomNames.delete(name);
785
+ const lane = laneOf(name);
786
+ lane.awaiting = lane.awaiting.filter((frame) => frame === flying);
787
+ lane.unsent = [];
788
+ lane.adopted = null;
789
+ },
790
+ adopted(name, rev, through) {
791
+ if (stopped || !roomNames.has(name)) return;
792
+ const lane = laneOf(name);
793
+ const waiting = lane.awaiting.find((frame) => !frame.sent && frame.through >= through);
794
+ if (waiting) waiting.adopted = rev;
795
+ else lane.adopted = { rev, through };
796
+ },
581
797
  // The save that held this store ended: frame whatever is still pending past
582
- // what was framed (an accepted save's ops have left pending).
583
- release(name) {
584
- if (stopped || epoch === null || !roomNames.has(name)) return;
585
- laneOf(name).unsent = unframed(name, laneOf(name));
798
+ // what was framed (an accepted save's ops have left pending). A state it
799
+ // kept from the store, or a value the caller kept (read), is replaced by a
800
+ // read of the room, which frames what is pending when the store takes it.
801
+ release(name, { read = false } = {}) {
802
+ if (stopped) return;
803
+ const lane = laneOf(name);
804
+ // A read release arms the barrier even before the first epoch exists: the
805
+ // state that may apply has to be one read after this, and a lane that has
806
+ // not joined a room yet has nothing to fetch.
807
+ if (lane.deferred || read) {
808
+ lane.after = reads + 1;
809
+ if (epoch !== null && connected) resync();
810
+ return;
811
+ }
812
+ if (epoch === null) return;
813
+ if (!roomNames.has(name)) return;
814
+ prune(name, lane);
815
+ lane.unsent = unframed(name, lane);
586
816
  schedule();
587
817
  },
818
+ // Lifetime frame counter retained for the legacy fixture harness.
819
+ framed(name) {
820
+ return lanes.get(name)?.n ?? 0;
821
+ },
822
+ pendingFrames(name, at, { reserve = false } = {}) {
823
+ const lane = lanes.get(name);
824
+ if (reserve && lane && clientId && epoch === at?.epoch) {
825
+ const pending = unframed(name, lane);
826
+ while (pending.length) {
827
+ let size = byteSize({ name, n: lane.n + 1, ops: [], epoch });
828
+ let count = 0;
829
+ while (count < pending.length && count < MAX_OPS) {
830
+ const more = byteSize(pending[count].op) + 1;
831
+ if (count && size + more > frameBytes) break;
832
+ size += more;
833
+ count++;
834
+ }
835
+ const take = pending.splice(0, count);
836
+ const frame = { n: ++lane.n, ops: take.map((e) => e.op), entries: take,
837
+ first: take[0].id, through: take[take.length - 1].id, seq: null,
838
+ sent: false, refused: true, epoch, client: clientId, receiptGeneration, pageGeneration };
839
+ markAdopted(lane, frame);
840
+ lane.awaiting.push(frame);
841
+ lane.framed = frame.through;
842
+ }
843
+ lane.unsent = [];
844
+ }
845
+ const ids = new Set(entries.get(name)?.store.pendingEntries().map((e) => e.id) ?? []);
846
+ let frames = [];
847
+ let complete = true;
848
+ for (const frame of lane?.awaiting ?? []) {
849
+ const present = frame.entries.filter((e) => ids.has(e.id));
850
+ if (!present.length) continue;
851
+ if (present.length !== frame.entries.length || frame.epoch !== at?.epoch || !frame.client) {
852
+ complete = false;
853
+ continue;
854
+ }
855
+ frames = unionRanges(frames, [[frame.client, [[frame.n, frame.n]]]]);
856
+ }
857
+ return { frames, complete };
858
+ },
859
+ captured(name, at, by) {
860
+ const lane = lanes.get(name);
861
+ if (!lane || !at || at.epoch !== epoch) return [];
862
+ const included = frameSet(by);
863
+ const dropIds = [];
864
+ lane.awaiting = lane.awaiting.filter((frame) => {
865
+ const held = frame.epoch === at.epoch &&
866
+ (contains(included, frame.client, frame.n) || (frame.seq !== null && frame.seq <= at.seq));
867
+ if (held) dropIds.push(...frame.entries.map((e) => e.id));
868
+ return !held;
869
+ });
870
+ return dropIds;
871
+ },
588
872
  // Read the room again: a store opened after this tab joined, or a save the
589
873
  // room merged whose state may not have arrived yet.
590
874
  refresh() {
@@ -593,11 +877,14 @@ export function createFastLane({
593
877
  storeOpened() {
594
878
  if (epoch !== null && connected) resync();
595
879
  },
880
+ roomBound(name) {
881
+ return !stopped && lanes.get(name)?.room === true;
882
+ },
596
883
  isWriter() {
597
884
  return writer !== null && writer === clientId;
598
885
  },
599
886
  get status() {
600
- return { connected, synced, epoch, seq, writer, clientId, stopped };
887
+ return { connected, synced, epoch, seq, writer, clientId, stopped, provenanceVersion, receiptGeneration };
601
888
  },
602
889
  stop,
603
890
  };