@panphora/clayjs 1.12.2 → 1.13.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.
@@ -20,6 +20,7 @@
20
20
 
21
21
  import { applyOps, applyOp, checkOp, cloneJson, validateOp, serializeLive, textHash, diffOps } from "./ops.js";
22
22
  import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
23
+ import { movesSince } from "./moves.js";
23
24
 
24
25
  const VERSION = 4;
25
26
  export const REQUEST_MS = 2000;
@@ -118,14 +119,20 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
118
119
 
119
120
  const landedIn = (m) => (m.behind || m.landed === null ? -1 : m.landed);
120
121
 
121
- function roomState() {
122
+ function roomState(since = null) {
122
123
  const stores = {};
123
124
  const meta = {};
124
125
  for (const [name, m] of mirror.stores) {
125
126
  stores[name] = { value: cloneJson(m.value), ephemeral: m.ephemeral, seen: receiptsFor(name), last: m.last, landed: landedIn(m) };
126
127
  meta[name] = { log: m.log, last: m.last, landed: m.landed, floor: m.floor, behind: m.behind, file: m.file, inFile: m.inFile };
127
128
  }
128
- return { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
129
+ const state = { epoch: room.epoch, seq: room.seq, writer: clientId, stores, mirror: { seen: [...mirror.seen], stores: meta } };
130
+ if (since && since.epoch === mirror.epoch) {
131
+ const moves = {};
132
+ for (const [name, m] of mirror.stores) moves[name] = movesSince(m, since);
133
+ state.moves = moves;
134
+ }
135
+ return state;
129
136
  }
130
137
 
131
138
  function alreadyMerged(m, frame) {
@@ -507,7 +514,7 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
507
514
  } else if (room && m.type === "post") {
508
515
  if (m.epoch === room.epoch) post({ type: "answer", to: m.from, rid: m.rid, result: roomApply(m.from, m.frame) });
509
516
  } else if (room && m.type === "state-request") {
510
- post({ type: "answer", to: m.from, rid: m.rid, result: roomState() });
517
+ post({ type: "answer", to: m.from, rid: m.rid, result: roomState(m.since) });
511
518
  }
512
519
  }
513
520
 
@@ -554,11 +561,11 @@ export function localTransport({ seed, key = "clay-live:" + window.location.path
554
561
  // The sequencer's state, installed into the mirror first. An answer to a
555
562
  // request made before the mirror changed (another state, or this tab
556
563
  // starting a room) is dropped: the lane asks again or already has it.
557
- async fetchState() {
558
- if (room) return roomState();
564
+ async fetchState(_clientId, since = null) {
565
+ if (room) return roomState(since);
559
566
  const asked = gen;
560
- const state = await request({ type: "state-request" });
561
- if (room) return roomState();
567
+ const state = await request({ type: "state-request", since });
568
+ if (room) return roomState(since);
562
569
  if (asked !== gen || !install(state)) return null;
563
570
  return state;
564
571
  },
@@ -0,0 +1,11 @@
1
+ // The moves a store's log holds after `since` ({ epoch, seq }), for a tab that
2
+ // already applied everything through since.seq. [] when nothing is newer; null
3
+ // when the log no longer reaches back that far (its floor is past since.seq),
4
+ // so the tab cannot replay and takes the value as it is. A log may start above
5
+ // since.seq + 1 with nothing missing: the seqs between went to other stores.
6
+ export function movesSince(store, since) {
7
+ if (!since || !Number.isInteger(since.seq)) return null;
8
+ if (since.seq >= (store.last ?? 0)) return [];
9
+ if (since.seq < (store.floor ?? 0)) return null;
10
+ return store.log.filter((entry) => entry.seq > since.seq).map((entry) => ({ seq: entry.seq, ops: entry.ops }));
11
+ }
@@ -39,9 +39,10 @@ export async function relayData(clientId, frame) {
39
39
  * The room's { epoch, seq, writer, stores }; { status } when the host refused;
40
40
  * null when the request never got an answer. clientId marks this tab as taking part.
41
41
  */
42
- export async function fetchLiveState(clientId) {
42
+ export async function fetchLiveState(clientId, since = null) {
43
43
  const url = dataUrl();
44
44
  if (clientId) url.searchParams.set("client-id", clientId);
45
+ if (since && typeof since.epoch === "string" && Number.isInteger(since.seq)) url.searchParams.set("since", `${since.epoch}:${since.seq}`);
45
46
  try {
46
47
  const response = await fetch(url.href, {
47
48
  credentials: "same-origin",
package/src/live/store.js CHANGED
@@ -47,6 +47,8 @@ export function createStore({ name, value, ephemeral = false }) {
47
47
  let mode = "file";
48
48
  const listeners = new Set();
49
49
  const sinks = new Set();
50
+ const rebases = new Set();
51
+ const deliveries = new Set();
50
52
  const views = new WeakMap();
51
53
 
52
54
  function emit(source, ops) {
@@ -275,17 +277,46 @@ export function createStore({ name, value, ephemeral = false }) {
275
277
  // `confirmed` pending ops, or every op up to the mark `through`. The rest
276
278
  // replay over it; a pending op that no longer applies is dropped too.
277
279
  // Listeners hear about it only when the value page code sees changed.
278
- rebase(next, { source = "remote", confirmed = 0, through } = {}) {
280
+ rebase(next, { source = "remote", confirmed = 0, through, frame = null, room = null, cause = null } = {}) {
279
281
  if (!isContainer(next) || kind(next) !== kind(state)) {
280
282
  console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
281
283
  return false;
282
284
  }
283
285
  const before = serializeLive(state);
286
+ const prev = authoritative;
284
287
  authoritative = cloneJson(next);
285
288
  pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
286
289
  rebuild();
290
+ for (const fn of [...rebases]) {
291
+ try {
292
+ fn({ source, frame, room, cause, prev, next: authoritative });
293
+ } catch (err) {
294
+ console.error(`clay.live("${name}"): a rebase listener threw`, err);
295
+ }
296
+ }
287
297
  if (serializeLive(state) !== before) emit(source, null);
288
298
  return true;
289
299
  },
300
+ // Every rebase, whether or not the value page code sees changed, with what
301
+ // caused it: the frame the room applied, the state read, or the lane's own
302
+ // settling. For the page editor; never on the handle.
303
+ onRebase(fn) {
304
+ rebases.add(fn);
305
+ return () => rebases.delete(fn);
306
+ },
307
+ // What became of this tab's frames: { phase, code, n, seq, through, retryAfter }.
308
+ onDelivery(fn) {
309
+ deliveries.add(fn);
310
+ return () => deliveries.delete(fn);
311
+ },
312
+ deliver(event) {
313
+ for (const fn of [...deliveries]) {
314
+ try {
315
+ fn(event);
316
+ } catch (err) {
317
+ console.error(`clay.live("${name}"): a delivery listener threw`, err);
318
+ }
319
+ }
320
+ },
290
321
  };
291
322
  }