@panphora/clayjs 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ // The operation model behind clay.live stores. Pure: no DOM, no network.
2
+ // Every client and the server apply the same ops to the same value and get
3
+ // byte-identical JSON, object key order included, so nothing here may depend
4
+ // on timing or on where a value came from.
5
+
6
+ const INDEX = /^(0|[1-9]\d*)$/;
7
+ const OPS = new Set(["set", "del", "add"]);
8
+
9
+ const isContainer = (v) => v !== null && typeof v === "object";
10
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
11
+
12
+ function setOwn(obj, key, value) {
13
+ Object.defineProperty(obj, key, { value, enumerable: true, writable: true, configurable: true });
14
+ }
15
+
16
+ // "players.alice.x" or ["players", "alice", "x"] -> segments. In the dotted form a
17
+ // canonical non-negative integer becomes an index; a key containing a dot needs
18
+ // the array form.
19
+ export function parsePath(path) {
20
+ if (Array.isArray(path)) {
21
+ for (const seg of path) checkSegment(seg);
22
+ return path.slice();
23
+ }
24
+ if (typeof path !== "string") throw new TypeError("clay.live: a path is a dotted string or an array of keys");
25
+ if (path === "") return [];
26
+ return path.split(".").map((s) => (INDEX.test(s) ? Number(s) : s));
27
+ }
28
+
29
+ function checkSegment(seg) {
30
+ if (typeof seg === "string") return;
31
+ if (Number.isInteger(seg) && seg >= 0) return;
32
+ throw new TypeError(`clay.live: path segment ${String(seg)} is not a string or a non-negative integer`);
33
+ }
34
+
35
+ // A deep copy that refuses anything JSON cannot hold, so a stored value can
36
+ // never be mutated through an outside reference and always serializes.
37
+ export function cloneJson(value, seen = new Set()) {
38
+ if (value === null || typeof value === "string" || typeof value === "boolean") return value;
39
+ if (typeof value === "number") {
40
+ if (!Number.isFinite(value)) throw new TypeError("clay.live: numbers must be finite");
41
+ return value;
42
+ }
43
+ if (typeof value !== "object") throw new TypeError(`clay.live: a ${typeof value} is not JSON`);
44
+ if (seen.has(value)) throw new TypeError("clay.live: the value contains a cycle");
45
+ seen.add(value);
46
+ let out;
47
+ if (Array.isArray(value)) {
48
+ out = [];
49
+ for (let i = 0; i < value.length; i++) {
50
+ if (!(i in value)) throw new TypeError("clay.live: sparse arrays are not JSON");
51
+ out.push(cloneJson(value[i], seen));
52
+ }
53
+ } else {
54
+ const proto = Object.getPrototypeOf(value);
55
+ if (proto !== Object.prototype && proto !== null) throw new TypeError("clay.live: only plain objects and arrays are JSON");
56
+ out = {};
57
+ for (const key of Object.keys(value)) setOwn(out, key, cloneJson(value[key], seen));
58
+ }
59
+ seen.delete(value);
60
+ return out;
61
+ }
62
+
63
+ // Shape check, before anything is touched. Returns a normalized op with its
64
+ // value already cloned.
65
+ export function validateOp(op) {
66
+ if (!isContainer(op) || !OPS.has(op.op)) throw new TypeError("clay.live: an op is { op: \"set\" | \"del\" | \"add\", path, ... }");
67
+ const path = parsePath(op.path);
68
+ if (op.op === "set") {
69
+ if (op.value === undefined) throw new TypeError("clay.live: set needs a value; use delete to remove");
70
+ const value = cloneJson(op.value);
71
+ if (path.length === 0 && !isContainer(value)) throw new TypeError("clay.live: the whole value must be an object or an array");
72
+ return { op: "set", path, value };
73
+ }
74
+ if (path.length === 0) throw new TypeError(`clay.live: ${op.op} needs a path`);
75
+ if (op.op === "add") {
76
+ if (typeof op.n !== "number" || !Number.isFinite(op.n)) throw new TypeError("clay.live: add needs a finite number");
77
+ return { op: "add", path, n: op.n };
78
+ }
79
+ return { op: "del", path };
80
+ }
81
+
82
+ function indexOf(arr, seg) {
83
+ if (typeof seg === "number") return seg;
84
+ if (INDEX.test(seg)) return Number(seg);
85
+ return -1;
86
+ }
87
+
88
+ function read(container, seg) {
89
+ if (Array.isArray(container)) {
90
+ const i = indexOf(container, seg);
91
+ return i >= 0 && i < container.length ? container[i] : undefined;
92
+ }
93
+ const key = String(seg);
94
+ return has(container, key) ? container[key] : undefined;
95
+ }
96
+
97
+ // Throws, without mutating anything, when op cannot apply to root: a string
98
+ // key on a list, or a list index past the end. A missing or non-container
99
+ // step along the way is not an error: applyOp replaces it with a container.
100
+ export function checkOp(root, op) {
101
+ if (op.path.length === 0) {
102
+ if (op.op === "set" && (!isContainer(op.value) || Array.isArray(op.value) !== Array.isArray(root))) {
103
+ throw new TypeError(`clay.live: the whole value must stay ${Array.isArray(root) ? "a list" : "an object"}`);
104
+ }
105
+ return;
106
+ }
107
+ let node = root;
108
+ for (let i = 0; i < op.path.length; i++) {
109
+ const seg = op.path[i];
110
+ const last = i === op.path.length - 1;
111
+ if (!isContainer(node)) {
112
+ // The rest of the path will be created, and a new list starts empty: an
113
+ // index other than 0 would leave holes, which are not JSON.
114
+ if (op.op === "del") return;
115
+ for (let j = i; j < op.path.length; j++) {
116
+ if (typeof op.path[j] === "number" && op.path[j] !== 0) {
117
+ throw new RangeError(`clay.live: index ${op.path[j]} would leave holes in a new list at ${op.path.slice(0, j).join(".") || "the root"}`);
118
+ }
119
+ }
120
+ return;
121
+ }
122
+ if (Array.isArray(node)) {
123
+ const idx = indexOf(node, seg);
124
+ if (idx < 0) throw new TypeError(`clay.live: "${seg}" is not an index of the list at ${op.path.slice(0, i).join(".") || "the root"}`);
125
+ const limit = last && op.op !== "del" ? node.length : node.length - 1;
126
+ if (idx > limit && !(last && op.op === "del")) throw new RangeError(`clay.live: index ${idx} is past the end of the list at ${op.path.slice(0, i).join(".") || "the root"}`);
127
+ }
128
+ if (last) return;
129
+ node = read(node, seg);
130
+ }
131
+ }
132
+
133
+ function put(container, seg, value) {
134
+ if (Array.isArray(container)) container[indexOf(container, seg)] = value;
135
+ else setOwn(container, String(seg), value);
136
+ }
137
+
138
+ // Applies one checked op. Mutates root and returns it, or returns the new root
139
+ // for a whole-value set.
140
+ export function applyOp(root, op) {
141
+ const { path } = op;
142
+ if (path.length === 0) return cloneJson(op.value);
143
+ let parent = root;
144
+ for (let i = 0; i < path.length - 1; i++) {
145
+ let next = read(parent, path[i]);
146
+ if (!isContainer(next)) {
147
+ if (op.op === "del") return root;
148
+ next = typeof path[i + 1] === "number" ? [] : {};
149
+ put(parent, path[i], next);
150
+ }
151
+ parent = next;
152
+ }
153
+ const last = path[path.length - 1];
154
+ if (op.op === "set") {
155
+ put(parent, last, cloneJson(op.value));
156
+ } else if (op.op === "add") {
157
+ const cur = read(parent, last);
158
+ put(parent, last, (typeof cur === "number" && Number.isFinite(cur) ? cur : 0) + op.n);
159
+ } else if (Array.isArray(parent)) {
160
+ const idx = indexOf(parent, last);
161
+ if (idx >= 0 && idx < parent.length) parent.splice(idx, 1);
162
+ } else {
163
+ delete parent[String(last)];
164
+ }
165
+ return root;
166
+ }
167
+
168
+ // Applies a list of ops in order; checks each one against the value as it
169
+ // stands at that point. Callers that need atomicity apply to a clone.
170
+ export function applyOps(root, ops) {
171
+ let value = root;
172
+ for (const op of ops) {
173
+ checkOp(value, op);
174
+ value = applyOp(value, op);
175
+ }
176
+ return value;
177
+ }
178
+
179
+ const samePath = (a, b) => a.length === b.length && a.every((s, i) => String(s) === String(b[i]));
180
+
181
+ // Merges neighbouring ops on the same path, without changing the result:
182
+ // set,set -> the later set; add,add -> one add of the sum; set,add -> one set of
183
+ // the sum; set,del and add,del -> the del, but only on a one-segment path (a
184
+ // deeper set may have created the parents the del leaves behind). A del is never
185
+ // merged with what follows it (on a list it shifts the indexes after it).
186
+ export function coalesce(ops) {
187
+ const out = [];
188
+ for (const op of ops) {
189
+ const prev = out[out.length - 1];
190
+ if (!prev || prev.op === "del" || !samePath(prev.path, op.path)) {
191
+ out.push(op);
192
+ continue;
193
+ }
194
+ if (op.op === "del" && op.path.length > 1) {
195
+ out.push(op);
196
+ } else if (op.op === "set" || op.op === "del") {
197
+ out[out.length - 1] = op;
198
+ } else if (prev.op === "add") {
199
+ out[out.length - 1] = { op: "add", path: prev.path, n: prev.n + op.n };
200
+ } else {
201
+ const base = typeof prev.value === "number" ? prev.value : 0;
202
+ out[out.length - 1] = { op: "set", path: prev.path, value: base + op.n };
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ // True when one path is a prefix of the other: an op on one can change what
209
+ // the other reads.
210
+ export function overlaps(a, b) {
211
+ const n = Math.min(a.length, b.length);
212
+ for (let i = 0; i < n; i++) if (String(a[i]) !== String(b[i])) return false;
213
+ return true;
214
+ }
215
+
216
+ // Makes target equal next in place, keeping the identity of every nested
217
+ // object or list that still sits at the same path with the same type, so a
218
+ // reference handed out earlier stays live. Object keys end up in next's order.
219
+ // next's own nested values are moved in, not copied: pass a fresh value.
220
+ export function assignDeep(target, next) {
221
+ if (Array.isArray(target)) {
222
+ for (let i = 0; i < next.length; i++) target[i] = reuse(i < target.length ? target[i] : undefined, next[i]);
223
+ target.length = next.length;
224
+ return target;
225
+ }
226
+ const keys = Object.keys(next);
227
+ const values = keys.map((k) => reuse(has(target, k) ? target[k] : undefined, next[k]));
228
+ for (const k of Object.keys(target)) delete target[k];
229
+ keys.forEach((k, i) => setOwn(target, k, values[i]));
230
+ return target;
231
+ }
232
+
233
+ function reuse(cur, next) {
234
+ if (isContainer(cur) && isContainer(next) && Array.isArray(cur) === Array.isArray(next)) return assignDeep(cur, next);
235
+ return next;
236
+ }
237
+
238
+ // The text a live store writes into its script tag. `<` is escaped so a value
239
+ // holding "</script>" can never end the element.
240
+ export function serializeLive(value) {
241
+ return JSON.stringify(value).replace(/</g, "\\u003c");
242
+ }
243
+
244
+ // A live tag's lineage, `live-at="<epoch>:<seq>:<hash>"`: the room position its
245
+ // value was written at, and a hash of the exact text, so a room can tell its own
246
+ // unedited value from an edit of it.
247
+ const STAMP = /^([A-Za-z0-9-]{1,64}):(0|[1-9]\d*):([0-9a-f]{8})$/;
248
+
249
+ // FNV-1a over UTF-16 code units, as 8 hex digits. Both copies must agree.
250
+ export function textHash(text) {
251
+ let h = 0x811c9dc5;
252
+ for (let i = 0; i < text.length; i++) {
253
+ h ^= text.charCodeAt(i);
254
+ h = Math.imul(h, 0x01000193) >>> 0;
255
+ }
256
+ return h.toString(16).padStart(8, "0");
257
+ }
258
+
259
+ export function stampOf(epoch, seq, text) {
260
+ return `${epoch}:${seq}:${textHash(text)}`;
261
+ }
262
+
263
+ export function parseStamp(value) {
264
+ const m = typeof value === "string" ? STAMP.exec(value) : null;
265
+ return m ? { epoch: m[1], seq: Number(m[2]), hash: m[3] } : null;
266
+ }
267
+
268
+ // The leaf-level ops that turn before into after: set for a changed or added
269
+ // leaf, del for a removed key, a whole-value set where the container kind or a
270
+ // list's length changes. Every op is idempotent, so replaying the result over a
271
+ // value that already holds `after` at those paths changes nothing.
272
+ export function diffOps(before, after, path = []) {
273
+ if (!isContainer(before) || !isContainer(after) || Array.isArray(before) !== Array.isArray(after)) {
274
+ return [{ op: "set", path, value: cloneJson(after) }];
275
+ }
276
+ if (Array.isArray(before)) {
277
+ if (before.length !== after.length) return [{ op: "set", path, value: cloneJson(after) }];
278
+ const out = [];
279
+ for (let i = 0; i < after.length; i++) {
280
+ if (serializeLive(before[i]) !== serializeLive(after[i])) out.push(...diffOps(before[i], after[i], [...path, i]));
281
+ }
282
+ return out;
283
+ }
284
+ const out = [];
285
+ for (const key of Object.keys(before)) if (!has(after, key)) out.push({ op: "del", path: [...path, key] });
286
+ for (const key of Object.keys(after)) {
287
+ if (!has(before, key)) out.push({ op: "set", path: [...path, key], value: cloneJson(after[key]) });
288
+ else if (serializeLive(before[key]) !== serializeLive(after[key])) out.push(...diffOps(before[key], after[key], [...path, key]));
289
+ }
290
+ return out;
291
+ }
@@ -0,0 +1,84 @@
1
+ import { onStream } from "./stream-bridge.js";
2
+
3
+ // The host side of clay.live's fast lane on a host that advertises `live-data`:
4
+ // one POST per frame of ops, one GET for the room's whole state. Both name the
5
+ // document with Document-URL, as the sync relay and presence do.
6
+
7
+ const DATA_PATH = "/_/sync/data";
8
+
9
+ function dataUrl() {
10
+ return new URL(DATA_PATH, window.location.origin);
11
+ }
12
+
13
+ /**
14
+ * Send one frame ({ name, n, ops }). Resolves { status, body, retryAfter }:
15
+ * status 0 when the request never got an answer.
16
+ */
17
+ export async function relayData(clientId, frame) {
18
+ const url = dataUrl();
19
+ url.searchParams.set("client-id", clientId);
20
+ try {
21
+ const response = await fetch(url.href, {
22
+ method: "POST",
23
+ credentials: "same-origin",
24
+ headers: { "Content-Type": "application/json", "Document-URL": window.location.href },
25
+ body: JSON.stringify(frame),
26
+ });
27
+ let body = null;
28
+ try {
29
+ body = await response.json();
30
+ } catch {}
31
+ const retryAfter = Number(response.headers?.get?.("Retry-After")) || 0;
32
+ return { status: response.status, body, retryAfter };
33
+ } catch {
34
+ return { status: 0, body: null, retryAfter: 0 };
35
+ }
36
+ }
37
+
38
+ /**
39
+ * The room's { epoch, seq, writer, stores }; { status } when the host refused;
40
+ * null when the request never got an answer. clientId marks this tab as taking part.
41
+ */
42
+ export async function fetchLiveState(clientId) {
43
+ const url = dataUrl();
44
+ if (clientId) url.searchParams.set("client-id", clientId);
45
+ try {
46
+ const response = await fetch(url.href, {
47
+ credentials: "same-origin",
48
+ cache: "no-store",
49
+ headers: { "Document-URL": window.location.href },
50
+ });
51
+ if (!response.ok) return { status: response.status };
52
+ const state = await response.json();
53
+ // The stream is open but the host has not seen its subscription yet: not
54
+ // ready, and the lane reads again.
55
+ if (state && typeof state.epoch === "string" && state.conn === null) return null;
56
+ return state;
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The fast-lane transport (fast-lane.js) for a host that advertises live-data:
64
+ * frames go up by POST, and come down as named events on the live-lane stream.
65
+ */
66
+ export function serverTransport() {
67
+ return {
68
+ start(handlers) {
69
+ return onStream({
70
+ open(info) {
71
+ if (info.lane === "live") handlers.open(info);
72
+ },
73
+ close: () => handlers.close(),
74
+ event(type, data) {
75
+ if (type === "data") handlers.frame(data);
76
+ else if (type === "data-state") handlers.state(data);
77
+ else if (type === "data-writer") handlers.writer(data);
78
+ },
79
+ });
80
+ },
81
+ send: relayData,
82
+ fetchState: fetchLiveState,
83
+ };
84
+ }
@@ -0,0 +1,291 @@
1
+ // A clay.live store: one JSON value held in memory and changed only through ops.
2
+ // What page code reads (state) is the last value the store accepted from outside
3
+ // (authoritative) with this tab's own unconfirmed ops (pending) replayed over it.
4
+ // Local ops change state at once. When authoritative moves, state is rebuilt in
5
+ // place, so an object or list page code holds a reference to stays live.
6
+
7
+ import { validateOp, checkOp, applyOp, applyOps, cloneJson, assignDeep, coalesce, serializeLive } from "./ops.js";
8
+
9
+ const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
10
+ const INDEX = /^(0|[1-9]\d*)$/;
11
+ const isContainer = (v) => v !== null && typeof v === "object";
12
+ const kind = (v) => (Array.isArray(v) ? "a list" : "an object");
13
+
14
+ function readPath(root, path) {
15
+ let node = root;
16
+ for (const seg of path) {
17
+ if (!isContainer(node)) return undefined;
18
+ if (Array.isArray(node)) node = node[Number(seg)];
19
+ else node = Object.prototype.hasOwnProperty.call(node, seg) ? node[seg] : undefined;
20
+ }
21
+ return node;
22
+ }
23
+
24
+ // applyOp, except that a set replacing a container with one of the same kind
25
+ // rewrites it in place, so a proxy over the old container still points at the
26
+ // value page code sees.
27
+ function applyInPlace(root, op) {
28
+ if (op.op === "set") {
29
+ const current = readPath(root, op.path);
30
+ if (isContainer(current) && isContainer(op.value) && Array.isArray(current) === Array.isArray(op.value)) {
31
+ assignDeep(current, cloneJson(op.value));
32
+ return;
33
+ }
34
+ }
35
+ applyOp(root, op);
36
+ }
37
+
38
+ export function createStore({ name, value, ephemeral = false }) {
39
+ if (!isContainer(value)) throw new TypeError(`clay.live("${name}"): the tag must hold a JSON object or list`);
40
+
41
+ let authoritative = cloneJson(value);
42
+ const state = cloneJson(value);
43
+ // This tab's unconfirmed ops, oldest first, each with a local id that only grows.
44
+ let pending = [];
45
+ let nextId = 1;
46
+ let batch = null;
47
+ let mode = "file";
48
+ const listeners = new Set();
49
+ const sinks = new Set();
50
+ const views = new WeakMap();
51
+
52
+ function emit(source, ops) {
53
+ const event = { source, ops };
54
+ for (const fn of [...listeners]) {
55
+ try {
56
+ fn(event);
57
+ } catch (err) {
58
+ console.error(`clay.live("${name}"): a change listener threw`, err);
59
+ }
60
+ }
61
+ }
62
+
63
+ function rebuild() {
64
+ let next = cloneJson(authoritative);
65
+ const kept = [];
66
+ for (const entry of pending) {
67
+ try {
68
+ checkOp(next, entry.op);
69
+ next = applyOp(next, entry.op);
70
+ kept.push(entry);
71
+ } catch {}
72
+ }
73
+ pending = kept;
74
+ assignDeep(state, next);
75
+ }
76
+
77
+ function publish(ops) {
78
+ for (const sink of [...sinks]) {
79
+ try {
80
+ sink(ops);
81
+ } catch (err) {
82
+ console.error(`clay.live("${name}"): a transport threw`, err);
83
+ }
84
+ }
85
+ emit("local", ops);
86
+ }
87
+
88
+ function commit(rawOps) {
89
+ const ops = rawOps.map(validateOp);
90
+ for (const op of ops) {
91
+ if (op.path.length === 0 && kind(op.value) !== kind(state)) {
92
+ throw new TypeError(`clay.live("${name}"): the whole value must stay ${kind(state)}`);
93
+ }
94
+ }
95
+ if (ops.length === 1) checkOp(state, ops[0]);
96
+ else applyOps(cloneJson(state), ops);
97
+ for (const op of ops) applyInPlace(state, op);
98
+ pending.push(...ops.map((op) => ({ id: nextId++, op })));
99
+ if (batch) batch.push(...ops);
100
+ else publish(ops);
101
+ }
102
+
103
+ function view(target, path) {
104
+ let byPath = views.get(target);
105
+ if (!byPath) views.set(target, (byPath = new Map()));
106
+ const key = JSON.stringify(path);
107
+ let proxy = byPath.get(key);
108
+ if (!proxy) {
109
+ proxy = new Proxy(target, traps(path));
110
+ byPath.set(key, proxy);
111
+ }
112
+ return proxy;
113
+ }
114
+
115
+ function traps(path) {
116
+ return {
117
+ get(target, prop) {
118
+ if (Array.isArray(target) && MUTATORS.has(prop)) {
119
+ return (...args) => {
120
+ const copy = cloneJson(target);
121
+ const result = Array.prototype[prop].apply(copy, args);
122
+ commit([{ op: "set", path, value: copy }]);
123
+ return result === copy ? view(target, path) : result;
124
+ };
125
+ }
126
+ const value = target[prop];
127
+ if (typeof prop === "symbol" || !isContainer(value) || !Object.prototype.hasOwnProperty.call(target, prop)) return value;
128
+ return view(value, [...path, Array.isArray(target) ? Number(prop) : prop]);
129
+ },
130
+ set(target, prop, value) {
131
+ if (typeof prop === "symbol") throw new TypeError(`clay.live("${name}"): symbol keys are not JSON`);
132
+ if (Array.isArray(target)) {
133
+ if (prop === "length") {
134
+ const copy = cloneJson(target);
135
+ copy.length = value;
136
+ commit([{ op: "set", path, value: copy }]);
137
+ return true;
138
+ }
139
+ if (!INDEX.test(prop)) throw new TypeError(`clay.live("${name}"): "${prop}" is not a list index`);
140
+ commit([{ op: "set", path: [...path, Number(prop)], value }]);
141
+ return true;
142
+ }
143
+ commit([{ op: "set", path: [...path, prop], value }]);
144
+ return true;
145
+ },
146
+ deleteProperty(target, prop) {
147
+ if (typeof prop === "symbol") return true;
148
+ if (Array.isArray(target)) throw new TypeError(`clay.live("${name}"): remove a list item with splice`);
149
+ commit([{ op: "del", path: [...path, prop] }]);
150
+ return true;
151
+ },
152
+ defineProperty() {
153
+ throw new TypeError(`clay.live("${name}"): assign values instead of defining properties`);
154
+ },
155
+ setPrototypeOf() {
156
+ return false;
157
+ },
158
+ preventExtensions() {
159
+ return false;
160
+ },
161
+ };
162
+ }
163
+
164
+ const data = view(state, []);
165
+ let resolveReady;
166
+ const ready = new Promise((resolve) => {
167
+ resolveReady = resolve;
168
+ });
169
+
170
+ const handle = Object.freeze({
171
+ name,
172
+ ephemeral,
173
+ ready,
174
+ get data() {
175
+ return data;
176
+ },
177
+ get status() {
178
+ return { mode };
179
+ },
180
+ set(path, value) {
181
+ commit([{ op: "set", path, value }]);
182
+ },
183
+ delete(path) {
184
+ commit([{ op: "del", path }]);
185
+ },
186
+ add(path, n = 1) {
187
+ commit([{ op: "add", path, n }]);
188
+ },
189
+ update(fn) {
190
+ if (typeof fn !== "function") throw new TypeError(`clay.live("${name}"): update() takes a function`);
191
+ if (batch) return fn(data);
192
+ batch = [];
193
+ const mark = pending.length;
194
+ const markId = nextId;
195
+ let result;
196
+ try {
197
+ result = fn(data);
198
+ if (result && typeof result.then === "function") {
199
+ throw new TypeError(`clay.live("${name}"): update() runs synchronously; do async work before calling it`);
200
+ }
201
+ } catch (err) {
202
+ batch = null;
203
+ pending.length = mark;
204
+ nextId = markId;
205
+ rebuild();
206
+ throw err;
207
+ }
208
+ const ops = coalesce(batch);
209
+ batch = null;
210
+ pending.length = mark;
211
+ nextId = markId;
212
+ pending.push(...ops.map((op) => ({ id: nextId++, op })));
213
+ if (ops.length) publish(ops);
214
+ return result;
215
+ },
216
+ on(type, fn) {
217
+ if (type !== "change") throw new TypeError(`clay.live("${name}"): the only event is "change"`);
218
+ if (typeof fn !== "function") throw new TypeError(`clay.live("${name}"): on() takes a function`);
219
+ listeners.add(fn);
220
+ return () => listeners.delete(fn);
221
+ },
222
+ off(type, fn) {
223
+ listeners.delete(fn);
224
+ },
225
+ toJSON() {
226
+ return cloneJson(state);
227
+ },
228
+ });
229
+ resolveReady(handle);
230
+
231
+ return {
232
+ handle,
233
+ name,
234
+ ephemeral,
235
+ // The live value itself, for serializing into a save. Never mutate it.
236
+ get value() {
237
+ return state;
238
+ },
239
+ get authoritative() {
240
+ return authoritative;
241
+ },
242
+ get pending() {
243
+ return pending.map((entry) => entry.op);
244
+ },
245
+ // The same ops with their ids, for a transport that resends them.
246
+ pendingEntries() {
247
+ return pending.map((entry) => ({ id: entry.id, op: entry.op }));
248
+ },
249
+ // The id of the newest op this store has committed (0 before the first).
250
+ get mark() {
251
+ return nextId - 1;
252
+ },
253
+ setMode(next) {
254
+ mode = next;
255
+ },
256
+ // A transport hears every local op batch, in order, as it is committed.
257
+ onLocal(fn) {
258
+ sinks.add(fn);
259
+ return () => sinks.delete(fn);
260
+ },
261
+ // This tab's ops up to and including `through` are in the file (the save
262
+ // that carried them was accepted) and no newer value arrived since: they
263
+ // become part of authoritative. The value page code sees does not change.
264
+ confirm(through) {
265
+ const taken = pending.filter((entry) => entry.id <= through);
266
+ if (!taken.length) return;
267
+ try {
268
+ authoritative = applyOps(cloneJson(authoritative), taken.map((entry) => entry.op));
269
+ } catch {
270
+ authoritative = cloneJson(state);
271
+ }
272
+ pending = pending.filter((entry) => entry.id > through);
273
+ },
274
+ // A new value from outside. Ops already in it are dropped: the first
275
+ // `confirmed` pending ops, or every op up to the mark `through`. The rest
276
+ // replay over it; a pending op that no longer applies is dropped too.
277
+ // Listeners hear about it only when the value page code sees changed.
278
+ rebase(next, { source = "remote", confirmed = 0, through } = {}) {
279
+ if (!isContainer(next) || kind(next) !== kind(state)) {
280
+ console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
281
+ return false;
282
+ }
283
+ const before = serializeLive(state);
284
+ authoritative = cloneJson(next);
285
+ pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
286
+ rebuild();
287
+ if (serializeLive(state) !== before) emit(source, null);
288
+ return true;
289
+ },
290
+ };
291
+ }