@velajs/live-protocol 1.0.0 → 1.1.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/dist/index.js CHANGED
@@ -1,5 +1,914 @@
1
- export { LIVE_PROTOCOL } from "./version.js";
2
- export { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER, LIVE_ERROR_CODES, LIVE_EVENT, RESERVED_EVENT_PREFIX, canonicalLiveFrame, encodeLiveEnvelope, encodeLiveFrame, isClientLiveFrame, isRowOp, isRowOps, isServerLiveFrame, liveEnvelope, readLiveEnvelope } from "./frames.js";
3
- export { DEFAULT_KEY_FIELD, applyListDelta, encodeListDelta } from "./delta.js";
4
- export { runProtocolConformance } from "./conformance.js";
5
- export { DELTA_FIXTURES, FRAME_FIXTURES } from "./fixtures.js";
1
+ //#region src/version.ts
2
+ /**
3
+ * Live-protocol wire version. Bumped ONLY on a breaking wire change (renaming
4
+ * or removing a field, changing a delivery guarantee). Additive changes — new
5
+ * optional fields, new frame types — do NOT bump it: receivers MUST ignore
6
+ * unknown frame `t` values and unknown object fields.
7
+ *
8
+ * A client advertises the version it speaks via the `v` field on its `sub`
9
+ * frame; a server that cannot serve that version replies
10
+ * `{ t: 'error', code: 'unsupported_protocol', fatal: true }`.
11
+ */
12
+ const LIVE_PROTOCOL = 2;
13
+ //#endregion
14
+ //#region src/frames.ts
15
+ /**
16
+ * The normative frame catalog for Vela live queries.
17
+ *
18
+ * Live frames ride Vela's existing WebSocket envelope `{ event, data }` under
19
+ * the single reserved event name `$live`; the frame itself is the envelope's
20
+ * `data`, discriminated on `t`. Classic gateway events, `ping`→`pong`
21
+ * keepalive, and live frames coexist on one socket. The `$` prefix is reserved
22
+ * for the framework: app gateways must never register a `$…` event.
23
+ *
24
+ * Byte-identical encoding matters: golden fixtures pin the exact wire string
25
+ * for every frame shape, and both the server and the client encode through
26
+ * {@link encodeLiveFrame} / {@link encodeLiveEnvelope} so the two sides cannot
27
+ * drift. Canonical key order is the declaration order of each type below;
28
+ * absent optionals are omitted entirely.
29
+ */
30
+ /** The reserved envelope event every live frame rides under. */
31
+ const LIVE_EVENT = "$live";
32
+ /**
33
+ * The reserved event-name prefix. The WS dispatcher rejects app gateways that
34
+ * register a `$…` event at bootstrap so live (and future framework) frames can
35
+ * never collide with app events.
36
+ */
37
+ const RESERVED_EVENT_PREFIX = "$";
38
+ /**
39
+ * HTTP response headers carrying the commit cursor/epoch of the log scope a
40
+ * mutation's invalidations landed in. The client gates optimistic-layer drops
41
+ * on a subscription frame whose `cursor` passes this value (and whose `epoch`
42
+ * matches) — never on HTTP response timing, which races the broadcast.
43
+ */
44
+ const COMMIT_CURSOR_HEADER = "Vela-Commit-Cursor";
45
+ const COMMIT_EPOCH_HEADER = "Vela-Commit-Epoch";
46
+ /** Default hard limits shared by every live-protocol endpoint. */
47
+ const MAX_LIVE_FRAME_BYTES = 64 * 1024;
48
+ const MAX_PRESENCE_METADATA_BYTES = 4 * 1024;
49
+ const MAX_DELTA_OPS = 1e3;
50
+ /** Well-known `error` frame codes. The code space is open — receivers must tolerate unknown codes. */
51
+ const LIVE_ERROR_CODES = {
52
+ UNSUPPORTED_PROTOCOL: "unsupported_protocol",
53
+ DUPLICATE_SUB: "duplicate_sub",
54
+ UNKNOWN_QUERY: "unknown_query",
55
+ FORBIDDEN: "forbidden",
56
+ BAD_ARGS: "bad_args",
57
+ LIMIT_EXCEEDED: "limit_exceeded",
58
+ INTERNAL: "internal"
59
+ };
60
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
61
+ const isCursor = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
62
+ const isBoundedString = (value, max, allowEmpty = false) => typeof value === "string" && (allowEmpty || value.length > 0) && value.length <= max;
63
+ const isOptionalBoundedString = (value, max) => value === void 0 || isBoundedString(value, max);
64
+ const hasOwn = (value, key) => Object.hasOwn(value, key);
65
+ const hasCursorPair = (value, cursor, epoch) => value[cursor] === void 0 && value[epoch] === void 0 || isCursor(value[cursor]) && isBoundedString(value[epoch], 256);
66
+ /** Structural guard for a single {@link RowOp}. Unknown extra fields are tolerated. */
67
+ const isRowOp = (value) => {
68
+ if (!isRecord(value) || !isBoundedString(value["key"], 512)) return false;
69
+ const op = value["op"];
70
+ if (op === "delete") return true;
71
+ if (op !== "insert" && op !== "update") return false;
72
+ if (!isRecord(value["row"]) || !isJsonWithin(value["row"], 65536)) return false;
73
+ if (op === "insert") {
74
+ const before = value["before"];
75
+ return before === null || isBoundedString(before, 512);
76
+ }
77
+ return true;
78
+ };
79
+ const isRowOps = (value) => Array.isArray(value) && value.length <= 1e3 && value.every(isRowOp);
80
+ /**
81
+ * Structural guard for a client frame. Frames with an unknown `t` return
82
+ * false — per the forward-compat rule the receiver then ignores the frame.
83
+ */
84
+ const isClientLiveFrame = (value) => {
85
+ if (!isRecord(value) || !isJsonWithin(value, 65536)) return false;
86
+ switch (value["t"]) {
87
+ case "sub": return isBoundedString(value["sub"], 256) && isBoundedString(value["query"], 256) && hasCursorPair(value, "sinceCursor", "sinceEpoch") && isOptionalBoundedString(value["key"], 128) && value["v"] === 2 && (!hasOwn(value, "args") || isJsonWithin(value["args"], 32 * 1024));
88
+ case "unsub": return isBoundedString(value["sub"], 256);
89
+ case "presence": return isBoundedString(value["room"], 512) && (!hasOwn(value, "meta") || isJsonWithin(value["meta"], 4096));
90
+ default: return false;
91
+ }
92
+ };
93
+ /** Structural guard for a server frame. Unknown `t` → false (receiver ignores). */
94
+ const isServerLiveFrame = (value) => {
95
+ if (!isRecord(value) || !isJsonWithin(value, 65536)) return false;
96
+ switch (value["t"]) {
97
+ case "ack": return isBoundedString(value["sub"], 256);
98
+ case "data": return isBoundedString(value["sub"], 256) && hasOwn(value, "snapshot") && hasCursorPair(value, "cursor", "epoch");
99
+ case "delta": return isBoundedString(value["sub"], 256) && isRowOps(value["ops"]) && hasCursorPair(value, "cursor", "epoch");
100
+ case "settled": return isBoundedString(value["sub"], 256) && hasCursorPair(value, "cursor", "epoch");
101
+ case "resume": return isBoundedString(value["sub"], 256) && isCursor(value["cursor"]) && isBoundedString(value["epoch"], 256);
102
+ case "error": return isOptionalBoundedString(value["sub"], 256) && isBoundedString(value["code"], 128) && isBoundedString(value["message"], 2048, true) && typeof value["fatal"] === "boolean";
103
+ default: return false;
104
+ }
105
+ };
106
+ /**
107
+ * Extract the live frame from a parsed WS envelope, or `undefined` when the
108
+ * envelope is not a live envelope. Does NOT validate the frame — pair with
109
+ * {@link isClientLiveFrame} / {@link isServerLiveFrame} on the receiving side.
110
+ */
111
+ const readLiveEnvelope = (envelope) => {
112
+ if (!isRecord(envelope) || envelope["event"] !== "$live" || !hasOwn(envelope, "data") || !isJsonWithin(envelope, 65536)) return;
113
+ return envelope["data"];
114
+ };
115
+ const DANGEROUS_KEYS = /* @__PURE__ */ new Set([
116
+ "__proto__",
117
+ "constructor",
118
+ "prototype"
119
+ ]);
120
+ const isJsonWithin = (value, maxBytes) => {
121
+ if (!isJsonValue(value, /* @__PURE__ */ new WeakSet(), { nodes: 0 }, 0)) return false;
122
+ try {
123
+ const serialized = JSON.stringify(value);
124
+ return serialized !== void 0 && new TextEncoder().encode(serialized).byteLength <= maxBytes;
125
+ } catch {
126
+ return false;
127
+ }
128
+ };
129
+ const isJsonValue = (value, seen, budget, depth) => {
130
+ budget.nodes += 1;
131
+ if (budget.nodes > 1e4 || depth > 32) return false;
132
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
133
+ if (typeof value === "number") return Number.isFinite(value);
134
+ if (typeof value !== "object" || seen.has(value)) return false;
135
+ seen.add(value);
136
+ const values = [];
137
+ if (Array.isArray(value)) values.push(...value);
138
+ else {
139
+ const prototype = Object.getPrototypeOf(value);
140
+ if (prototype !== Object.prototype && prototype !== null) return false;
141
+ for (const key of Object.keys(value)) {
142
+ if (DANGEROUS_KEYS.has(key)) return false;
143
+ values.push(value[key]);
144
+ }
145
+ }
146
+ for (const child of values) if (!isJsonValue(child, seen, budget, depth + 1)) return false;
147
+ seen.delete(value);
148
+ return true;
149
+ };
150
+ /** Wrap a frame in the `$live` envelope object. */
151
+ const liveEnvelope = (frame) => ({
152
+ event: LIVE_EVENT,
153
+ data: frame
154
+ });
155
+ const CANONICAL_KEYS = {
156
+ sub: [
157
+ "t",
158
+ "sub",
159
+ "query",
160
+ "args",
161
+ "sinceCursor",
162
+ "sinceEpoch",
163
+ "key",
164
+ "v"
165
+ ],
166
+ unsub: ["t", "sub"],
167
+ presence: [
168
+ "t",
169
+ "room",
170
+ "meta"
171
+ ],
172
+ ack: ["t", "sub"],
173
+ data: [
174
+ "t",
175
+ "sub",
176
+ "snapshot",
177
+ "cursor",
178
+ "epoch"
179
+ ],
180
+ delta: [
181
+ "t",
182
+ "sub",
183
+ "ops",
184
+ "cursor",
185
+ "epoch"
186
+ ],
187
+ settled: [
188
+ "t",
189
+ "sub",
190
+ "cursor",
191
+ "epoch"
192
+ ],
193
+ resume: [
194
+ "t",
195
+ "sub",
196
+ "cursor",
197
+ "epoch"
198
+ ],
199
+ error: [
200
+ "t",
201
+ "sub",
202
+ "code",
203
+ "message",
204
+ "fatal"
205
+ ]
206
+ };
207
+ const ROW_OP_KEYS = [
208
+ "op",
209
+ "key",
210
+ "row",
211
+ "before"
212
+ ];
213
+ const canonicalRowOp = (op) => {
214
+ const source = op;
215
+ const out = {};
216
+ for (const key of ROW_OP_KEYS) if (source[key] !== void 0) out[key] = source[key];
217
+ return out;
218
+ };
219
+ /**
220
+ * Rebuild a frame with the canonical key order, dropping absent optionals.
221
+ * `JSON.stringify` of the result is the frame's canonical wire form — the one
222
+ * the golden fixtures pin byte-for-byte.
223
+ */
224
+ const canonicalLiveFrame = (frame) => {
225
+ const source = frame;
226
+ const keys = CANONICAL_KEYS[frame.t];
227
+ if (keys === void 0) throw new Error(`Unknown live frame type: ${String(frame.t)}`);
228
+ const out = {};
229
+ for (const key of keys) {
230
+ const value = source[key];
231
+ if (value === void 0) continue;
232
+ out[key] = frame.t === "delta" && key === "ops" ? value.map(canonicalRowOp) : value;
233
+ }
234
+ return out;
235
+ };
236
+ /** Canonical JSON encoding of a bare frame (no envelope). */
237
+ const encodeLiveFrame = (frame) => {
238
+ if (!isClientLiveFrame(frame) && !isServerLiveFrame(frame)) throw new TypeError("Cannot encode an invalid or oversized live frame.");
239
+ return JSON.stringify(canonicalLiveFrame(frame));
240
+ };
241
+ /** Canonical JSON encoding of the full `$live` envelope — what actually goes on the socket. */
242
+ const encodeLiveEnvelope = (frame) => `{"event":${JSON.stringify(LIVE_EVENT)},"data":${encodeLiveFrame(frame)}}`;
243
+ //#endregion
244
+ //#region src/delta.ts
245
+ /** Default row-identity field. Per-query override rides the `sub` frame's `key`. */
246
+ const DEFAULT_KEY_FIELD = "id";
247
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
248
+ const readRowKey = (row, keyField) => {
249
+ if (!isPlainObject(row)) return void 0;
250
+ const key = row[keyField];
251
+ return typeof key === "string" ? key : void 0;
252
+ };
253
+ /**
254
+ * Index rows by key preserving order; `undefined` the moment any row is
255
+ * unkeyable or a key repeats (bail rules 2 and 3 — a duplicated key cannot be
256
+ * expressed as keyed deltas without silently collapsing rows).
257
+ */
258
+ const indexRows = (rows, keyField) => {
259
+ const byKey = /* @__PURE__ */ new Map();
260
+ const order = [];
261
+ for (const row of rows) {
262
+ const key = readRowKey(row, keyField);
263
+ if (key === void 0 || byKey.has(key)) return void 0;
264
+ byKey.set(key, row);
265
+ order.push(key);
266
+ }
267
+ return {
268
+ byKey,
269
+ order
270
+ };
271
+ };
272
+ /**
273
+ * True when rows present in BOTH lists keep the same relative order (bail
274
+ * rule 4): the merge updates survivors in place and never reorders them, so a
275
+ * survivor that moved cannot be expressed as deltas.
276
+ */
277
+ const survivorsKeepOrder = (previous, next) => {
278
+ const survivingPrevious = previous.order.filter((key) => next.byKey.has(key));
279
+ const survivingNext = next.order.filter((key) => previous.byKey.has(key));
280
+ if (survivingPrevious.length !== survivingNext.length) return false;
281
+ return survivingPrevious.every((key, index) => survivingNext[index] === key);
282
+ };
283
+ /**
284
+ * Diff `previous` vs `next` into row ops, or `undefined` when any bail rule
285
+ * holds and the caller must send a full snapshot instead.
286
+ *
287
+ * An empty array is a valid result (no row-level change — typically the server
288
+ * catches byte-identical results earlier and sends `settled` instead).
289
+ */
290
+ const encodeListDelta = (previous, next, keyField = "id") => {
291
+ try {
292
+ if (!Array.isArray(previous) || !Array.isArray(next)) return void 0;
293
+ const previousIndex = indexRows(previous, keyField);
294
+ const nextIndex = indexRows(next, keyField);
295
+ if (previousIndex === void 0 || nextIndex === void 0) return void 0;
296
+ if (!survivorsKeepOrder(previousIndex, nextIndex)) return void 0;
297
+ const ops = [];
298
+ for (const key of previousIndex.order) if (!nextIndex.byKey.has(key)) ops.push({
299
+ op: "delete",
300
+ key
301
+ });
302
+ const followingSurvivor = new Array(nextIndex.order.length);
303
+ let anchor = null;
304
+ for (let index = nextIndex.order.length - 1; index >= 0; index -= 1) {
305
+ followingSurvivor[index] = anchor;
306
+ const key = nextIndex.order[index];
307
+ if (previousIndex.byKey.has(key)) anchor = key;
308
+ }
309
+ for (const [index, key] of nextIndex.order.entries()) {
310
+ const nextRow = nextIndex.byKey.get(key);
311
+ const previousRow = previousIndex.byKey.get(key);
312
+ const nextFingerprint = JSON.stringify(nextRow);
313
+ if (previousRow === void 0) {
314
+ ops.push({
315
+ op: "insert",
316
+ key,
317
+ row: nextRow,
318
+ before: followingSurvivor[index] ?? null
319
+ });
320
+ continue;
321
+ }
322
+ if (JSON.stringify(previousRow) !== nextFingerprint) ops.push({
323
+ op: "update",
324
+ key,
325
+ row: nextRow
326
+ });
327
+ }
328
+ if (ops.length > next.length) return void 0;
329
+ return ops;
330
+ } catch {
331
+ return;
332
+ }
333
+ };
334
+ /**
335
+ * Merge row ops into a cached array result, returning a NEW array (the input
336
+ * is never mutated), or `undefined` when the ops cannot be applied cleanly —
337
+ * the caller then falls back to full replacement and lets the next snapshot
338
+ * reconcile.
339
+ *
340
+ * Idempotent by construction: replaying an op after a snapshot already
341
+ * delivered its effect changes nothing.
342
+ */
343
+ const applyListDelta = (current, ops, keyField = "id") => {
344
+ if (!Array.isArray(current)) return void 0;
345
+ const rows = [];
346
+ const seen = /* @__PURE__ */ new Set();
347
+ for (const element of current) {
348
+ const key = readRowKey(element, keyField);
349
+ if (key === void 0 || seen.has(key)) return void 0;
350
+ seen.add(key);
351
+ rows.push(element);
352
+ }
353
+ let next = [...rows];
354
+ for (const op of ops) {
355
+ const existingIndex = next.findIndex((row) => row[keyField] === op.key);
356
+ if (op.op === "delete") {
357
+ if (existingIndex !== -1) next.splice(existingIndex, 1);
358
+ continue;
359
+ }
360
+ if (existingIndex !== -1) {
361
+ next[existingIndex] = op.row;
362
+ continue;
363
+ }
364
+ if (op.op === "insert" && op.before !== null) {
365
+ const anchorIndex = next.findIndex((row) => row[keyField] === op.before);
366
+ if (anchorIndex !== -1) {
367
+ next.splice(anchorIndex, 0, op.row);
368
+ continue;
369
+ }
370
+ }
371
+ next = [...next, op.row];
372
+ }
373
+ return next;
374
+ };
375
+ //#endregion
376
+ //#region src/fixtures.ts
377
+ const FRAME_FIXTURES = [
378
+ {
379
+ name: "sub (full)",
380
+ frame: {
381
+ t: "sub",
382
+ sub: "s1",
383
+ query: "todos.list",
384
+ args: { listId: "l1" },
385
+ sinceCursor: 42,
386
+ sinceEpoch: "e-1",
387
+ key: "id",
388
+ v: 2
389
+ },
390
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"sub\",\"sub\":\"s1\",\"query\":\"todos.list\",\"args\":{\"listId\":\"l1\"},\"sinceCursor\":42,\"sinceEpoch\":\"e-1\",\"key\":\"id\",\"v\":2}}"
391
+ },
392
+ {
393
+ name: "sub (minimal)",
394
+ frame: {
395
+ t: "sub",
396
+ sub: "s2",
397
+ query: "todos.all",
398
+ v: 2
399
+ },
400
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"sub\",\"sub\":\"s2\",\"query\":\"todos.all\",\"v\":2}}"
401
+ },
402
+ {
403
+ name: "unsub",
404
+ frame: {
405
+ t: "unsub",
406
+ sub: "s1"
407
+ },
408
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"unsub\",\"sub\":\"s1\"}}"
409
+ },
410
+ {
411
+ name: "presence",
412
+ frame: {
413
+ t: "presence",
414
+ room: "r1",
415
+ meta: { name: "kauan" }
416
+ },
417
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"presence\",\"room\":\"r1\",\"meta\":{\"name\":\"kauan\"}}}"
418
+ },
419
+ {
420
+ name: "ack",
421
+ frame: {
422
+ t: "ack",
423
+ sub: "s1"
424
+ },
425
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"ack\",\"sub\":\"s1\"}}"
426
+ },
427
+ {
428
+ name: "data",
429
+ frame: {
430
+ t: "data",
431
+ sub: "s1",
432
+ snapshot: [{
433
+ id: "a",
434
+ text: "hi"
435
+ }],
436
+ cursor: 7,
437
+ epoch: "e-1"
438
+ },
439
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"data\",\"sub\":\"s1\",\"snapshot\":[{\"id\":\"a\",\"text\":\"hi\"}],\"cursor\":7,\"epoch\":\"e-1\"}}"
440
+ },
441
+ {
442
+ name: "data (cold, no cursor)",
443
+ frame: {
444
+ t: "data",
445
+ sub: "s1",
446
+ snapshot: null
447
+ },
448
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"data\",\"sub\":\"s1\",\"snapshot\":null}}"
449
+ },
450
+ {
451
+ name: "delta",
452
+ frame: {
453
+ t: "delta",
454
+ sub: "s1",
455
+ ops: [
456
+ {
457
+ op: "delete",
458
+ key: "a"
459
+ },
460
+ {
461
+ op: "insert",
462
+ key: "b",
463
+ row: { id: "b" },
464
+ before: null
465
+ },
466
+ {
467
+ op: "update",
468
+ key: "c",
469
+ row: {
470
+ id: "c",
471
+ n: 2
472
+ }
473
+ }
474
+ ],
475
+ cursor: 8,
476
+ epoch: "e-1"
477
+ },
478
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"delta\",\"sub\":\"s1\",\"ops\":[{\"op\":\"delete\",\"key\":\"a\"},{\"op\":\"insert\",\"key\":\"b\",\"row\":{\"id\":\"b\"},\"before\":null},{\"op\":\"update\",\"key\":\"c\",\"row\":{\"id\":\"c\",\"n\":2}}],\"cursor\":8,\"epoch\":\"e-1\"}}"
479
+ },
480
+ {
481
+ name: "settled",
482
+ frame: {
483
+ t: "settled",
484
+ sub: "s1",
485
+ cursor: 9,
486
+ epoch: "e-1"
487
+ },
488
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"settled\",\"sub\":\"s1\",\"cursor\":9,\"epoch\":\"e-1\"}}"
489
+ },
490
+ {
491
+ name: "resume",
492
+ frame: {
493
+ t: "resume",
494
+ sub: "s1",
495
+ cursor: 42,
496
+ epoch: "e-1"
497
+ },
498
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"resume\",\"sub\":\"s1\",\"cursor\":42,\"epoch\":\"e-1\"}}"
499
+ },
500
+ {
501
+ name: "error (subscription)",
502
+ frame: {
503
+ t: "error",
504
+ sub: "s1",
505
+ code: "forbidden",
506
+ message: "nope",
507
+ fatal: true
508
+ },
509
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"error\",\"sub\":\"s1\",\"code\":\"forbidden\",\"message\":\"nope\",\"fatal\":true}}"
510
+ },
511
+ {
512
+ name: "error (connection)",
513
+ frame: {
514
+ t: "error",
515
+ code: "unsupported_protocol",
516
+ message: "v2 required",
517
+ fatal: true
518
+ },
519
+ wire: "{\"event\":\"$live\",\"data\":{\"t\":\"error\",\"code\":\"unsupported_protocol\",\"message\":\"v2 required\",\"fatal\":true}}"
520
+ }
521
+ ];
522
+ const DELTA_FIXTURES = [
523
+ {
524
+ name: "noop",
525
+ previous: [],
526
+ next: [],
527
+ expected: []
528
+ },
529
+ {
530
+ name: "insert into empty",
531
+ previous: [],
532
+ next: [{
533
+ id: "a",
534
+ n: 1
535
+ }],
536
+ expected: [{
537
+ op: "insert",
538
+ key: "a",
539
+ row: {
540
+ id: "a",
541
+ n: 1
542
+ },
543
+ before: null
544
+ }]
545
+ },
546
+ {
547
+ name: "insert head",
548
+ previous: [{ id: "b" }],
549
+ next: [{ id: "a" }, { id: "b" }],
550
+ expected: [{
551
+ op: "insert",
552
+ key: "a",
553
+ row: { id: "a" },
554
+ before: "b"
555
+ }]
556
+ },
557
+ {
558
+ name: "insert middle",
559
+ previous: [{ id: "a" }, { id: "c" }],
560
+ next: [
561
+ { id: "a" },
562
+ { id: "b" },
563
+ { id: "c" }
564
+ ],
565
+ expected: [{
566
+ op: "insert",
567
+ key: "b",
568
+ row: { id: "b" },
569
+ before: "c"
570
+ }]
571
+ },
572
+ {
573
+ name: "insert tail",
574
+ previous: [{ id: "a" }],
575
+ next: [{ id: "a" }, { id: "b" }],
576
+ expected: [{
577
+ op: "insert",
578
+ key: "b",
579
+ row: { id: "b" },
580
+ before: null
581
+ }]
582
+ },
583
+ {
584
+ name: "update in place",
585
+ previous: [{
586
+ id: "a",
587
+ n: 1
588
+ }],
589
+ next: [{
590
+ id: "a",
591
+ n: 2
592
+ }],
593
+ expected: [{
594
+ op: "update",
595
+ key: "a",
596
+ row: {
597
+ id: "a",
598
+ n: 2
599
+ }
600
+ }]
601
+ },
602
+ {
603
+ name: "delete one",
604
+ previous: [{ id: "a" }, { id: "b" }],
605
+ next: [{ id: "a" }],
606
+ expected: [{
607
+ op: "delete",
608
+ key: "b"
609
+ }]
610
+ },
611
+ {
612
+ name: "mixed delete+update+insert",
613
+ previous: [
614
+ {
615
+ id: "a",
616
+ n: 1
617
+ },
618
+ {
619
+ id: "b",
620
+ n: 1
621
+ },
622
+ {
623
+ id: "c",
624
+ n: 1
625
+ }
626
+ ],
627
+ next: [
628
+ {
629
+ id: "b",
630
+ n: 2
631
+ },
632
+ {
633
+ id: "d",
634
+ n: 1
635
+ },
636
+ {
637
+ id: "c",
638
+ n: 1
639
+ }
640
+ ],
641
+ expected: [
642
+ {
643
+ op: "delete",
644
+ key: "a"
645
+ },
646
+ {
647
+ op: "update",
648
+ key: "b",
649
+ row: {
650
+ id: "b",
651
+ n: 2
652
+ }
653
+ },
654
+ {
655
+ op: "insert",
656
+ key: "d",
657
+ row: {
658
+ id: "d",
659
+ n: 1
660
+ },
661
+ before: "c"
662
+ }
663
+ ]
664
+ },
665
+ {
666
+ name: "stacked inserts share an anchor in next order",
667
+ previous: [{ id: "z" }],
668
+ next: [
669
+ { id: "x" },
670
+ { id: "y" },
671
+ { id: "z" }
672
+ ],
673
+ expected: [{
674
+ op: "insert",
675
+ key: "x",
676
+ row: { id: "x" },
677
+ before: "z"
678
+ }, {
679
+ op: "insert",
680
+ key: "y",
681
+ row: { id: "y" },
682
+ before: "z"
683
+ }]
684
+ },
685
+ {
686
+ name: "custom key field",
687
+ previous: [{
688
+ _key: "a",
689
+ n: 1
690
+ }],
691
+ next: [{
692
+ _key: "a",
693
+ n: 2
694
+ }],
695
+ keyField: "_key",
696
+ expected: [{
697
+ op: "update",
698
+ key: "a",
699
+ row: {
700
+ _key: "a",
701
+ n: 2
702
+ }
703
+ }]
704
+ },
705
+ {
706
+ name: "bail: clear list (rule 5)",
707
+ previous: [{ id: "a" }, { id: "b" }],
708
+ next: [],
709
+ expected: null
710
+ },
711
+ {
712
+ name: "bail: near-total change (rule 5)",
713
+ previous: [{ id: "a" }, { id: "b" }],
714
+ next: [
715
+ { id: "c" },
716
+ { id: "d" },
717
+ { id: "e" }
718
+ ],
719
+ expected: null
720
+ },
721
+ {
722
+ name: "bail: previous not array (rule 1)",
723
+ previous: { id: "a" },
724
+ next: [{ id: "a" }],
725
+ expected: null
726
+ },
727
+ {
728
+ name: "bail: next not array (rule 1)",
729
+ previous: [{ id: "a" }],
730
+ next: { id: "a" },
731
+ expected: null
732
+ },
733
+ {
734
+ name: "bail: row missing key (rule 2)",
735
+ previous: [{ id: "a" }],
736
+ next: [{ text: "no key" }],
737
+ expected: null
738
+ },
739
+ {
740
+ name: "bail: non-string key (rule 2)",
741
+ previous: [{ id: "a" }],
742
+ next: [{ id: 5 }],
743
+ expected: null
744
+ },
745
+ {
746
+ name: "bail: scalar row (rule 2)",
747
+ previous: [{ id: "a" }],
748
+ next: ["a"],
749
+ expected: null
750
+ },
751
+ {
752
+ name: "bail: duplicate key in previous (rule 3)",
753
+ previous: [{ id: "a" }, { id: "a" }],
754
+ next: [{ id: "a" }],
755
+ expected: null
756
+ },
757
+ {
758
+ name: "bail: duplicate key in next (rule 3)",
759
+ previous: [{ id: "a" }],
760
+ next: [{ id: "a" }, { id: "a" }],
761
+ expected: null
762
+ },
763
+ {
764
+ name: "bail: survivors reordered (rule 4)",
765
+ previous: [{ id: "a" }, { id: "b" }],
766
+ next: [{ id: "b" }, { id: "a" }],
767
+ expected: null
768
+ }
769
+ ];
770
+ //#endregion
771
+ //#region src/conformance.ts
772
+ /**
773
+ * The protocol conformance runner. Both wire endpoints run this in their own
774
+ * test suites (see `@velajs/testing`'s live harness) so a codec that drifts
775
+ * from the golden fixtures — or from the shared delta semantics — fails a test
776
+ * on the offending side.
777
+ *
778
+ * Checks, in order:
779
+ * 1. Frame encoding: `encodeLiveEnvelope(fixture.frame)` is byte-identical to
780
+ * the pinned wire string, the wire parses back to a guard-recognized frame,
781
+ * and `readLiveEnvelope` extracts it.
782
+ * 2. Delta fixtures: the codec's `encodeListDelta` produces exactly the pinned
783
+ * ops (or bails where the fixture says it must), and for every mergeable
784
+ * fixture `applyListDelta` reconstructs `next` exactly — then reapplying
785
+ * the same ops changes nothing (at-least-once replay idempotency).
786
+ * 3. A seeded randomized sweep of generated list pairs asserting the
787
+ * exact-reconstruction property on cases the fixtures don't enumerate.
788
+ */
789
+ const REFERENCE_CODEC = {
790
+ encodeListDelta,
791
+ applyListDelta
792
+ };
793
+ const deepEqual = (a, b) => {
794
+ if (a === b) return true;
795
+ if (Array.isArray(a) || Array.isArray(b)) {
796
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
797
+ return a.every((value, index) => deepEqual(value, b[index]));
798
+ }
799
+ if (typeof a === "object" && typeof b === "object" && a !== null && b !== null) {
800
+ const aKeys = Object.keys(a);
801
+ const bKeys = Object.keys(b);
802
+ if (aKeys.length !== bKeys.length) return false;
803
+ return aKeys.every((key) => key in b && deepEqual(a[key], b[key]));
804
+ }
805
+ return false;
806
+ };
807
+ /** Deterministic LCG so the randomized sweep is reproducible (no Math.random). */
808
+ const makeRandom = (seed) => {
809
+ let state = seed >>> 0;
810
+ return () => {
811
+ state = state * 1664525 + 1013904223 >>> 0;
812
+ return state / 4294967296;
813
+ };
814
+ };
815
+ /**
816
+ * Generate a mergeable previous/next pair: start from a random keyed list,
817
+ * then delete a random subset, update random payloads, and insert fresh keys
818
+ * at random positions — survivor order is preserved by construction, so the
819
+ * encoder may only bail via the op-count cap (rule 5).
820
+ */
821
+ const generateCase = (random, caseIndex) => {
822
+ const previousLength = Math.floor(random() * 8);
823
+ const previous = [];
824
+ for (let index = 0; index < previousLength; index += 1) previous.push({
825
+ id: `k${caseIndex}-${index}`,
826
+ n: Math.floor(random() * 100)
827
+ });
828
+ const next = [];
829
+ for (const row of previous) {
830
+ if (random() < .25) continue;
831
+ next.push(random() < .4 ? {
832
+ ...row,
833
+ n: Math.floor(random() * 100)
834
+ } : row);
835
+ }
836
+ const insertions = Math.floor(random() * 4);
837
+ for (let index = 0; index < insertions; index += 1) {
838
+ const position = Math.floor(random() * (next.length + 1));
839
+ next.splice(position, 0, {
840
+ id: `f${caseIndex}-${index}`,
841
+ n: Math.floor(random() * 100)
842
+ });
843
+ }
844
+ return {
845
+ previous,
846
+ next
847
+ };
848
+ };
849
+ /**
850
+ * Run the full conformance suite against a codec (defaults to the reference
851
+ * codec in this package — the package's own tests run exactly this).
852
+ */
853
+ const runProtocolConformance = (codec = REFERENCE_CODEC) => {
854
+ const failures = [];
855
+ let checks = 0;
856
+ for (const fixture of FRAME_FIXTURES) {
857
+ checks += 1;
858
+ const encoded = encodeLiveEnvelope(fixture.frame);
859
+ if (encoded !== fixture.wire) {
860
+ failures.push(`frame "${fixture.name}": encoded wire differs\n expected ${fixture.wire}\n actual ${encoded}`);
861
+ continue;
862
+ }
863
+ const frame = readLiveEnvelope(JSON.parse(fixture.wire));
864
+ if (frame === void 0) {
865
+ failures.push(`frame "${fixture.name}": readLiveEnvelope did not recognize the envelope`);
866
+ continue;
867
+ }
868
+ if (!isClientLiveFrame(frame) && !isServerLiveFrame(frame)) failures.push(`frame "${fixture.name}": decoded frame not recognized by either guard`);
869
+ }
870
+ for (const fixture of DELTA_FIXTURES) {
871
+ checks += 1;
872
+ const keyField = fixture.keyField ?? "id";
873
+ const ops = codec.encodeListDelta(fixture.previous, fixture.next, keyField);
874
+ if (fixture.expected === null) {
875
+ if (ops !== void 0) failures.push(`delta "${fixture.name}": expected bail-to-snapshot, got ${JSON.stringify(ops)}`);
876
+ continue;
877
+ }
878
+ if (ops === void 0) {
879
+ failures.push(`delta "${fixture.name}": encoder bailed, expected ${JSON.stringify(fixture.expected)}`);
880
+ continue;
881
+ }
882
+ if (!deepEqual(ops, fixture.expected)) {
883
+ failures.push(`delta "${fixture.name}": ops differ\n expected ${JSON.stringify(fixture.expected)}\n actual ${JSON.stringify(ops)}`);
884
+ continue;
885
+ }
886
+ const merged = codec.applyListDelta(fixture.previous, ops, keyField);
887
+ if (merged === void 0 || !deepEqual(merged, fixture.next)) {
888
+ failures.push(`delta "${fixture.name}": apply(previous, ops) did not reconstruct next\n expected ${JSON.stringify(fixture.next)}\n actual ${JSON.stringify(merged)}`);
889
+ continue;
890
+ }
891
+ const replayed = codec.applyListDelta(merged, ops, keyField);
892
+ if (replayed === void 0 || !deepEqual(replayed, fixture.next)) failures.push(`delta "${fixture.name}": replaying the same ops was not idempotent`);
893
+ }
894
+ const random = makeRandom(24301);
895
+ for (let caseIndex = 0; caseIndex < 250; caseIndex += 1) {
896
+ checks += 1;
897
+ const { previous, next } = generateCase(random, caseIndex);
898
+ const ops = codec.encodeListDelta(previous, next);
899
+ if (ops === void 0) {
900
+ if (encodeListDelta(previous, next) !== void 0) failures.push(`random #${caseIndex}: codec bailed where the reference codec succeeds`);
901
+ continue;
902
+ }
903
+ const merged = codec.applyListDelta(previous, ops);
904
+ if (merged === void 0 || !deepEqual(merged, next)) failures.push(`random #${caseIndex}: apply(previous, ops) != next\n previous ${JSON.stringify(previous)}\n next ${JSON.stringify(next)}\n ops ${JSON.stringify(ops)}\n merged ${JSON.stringify(merged)}`);
905
+ }
906
+ return {
907
+ failures,
908
+ checks
909
+ };
910
+ };
911
+ //#endregion
912
+ export { COMMIT_CURSOR_HEADER, COMMIT_EPOCH_HEADER, DEFAULT_KEY_FIELD, DELTA_FIXTURES, FRAME_FIXTURES, LIVE_ERROR_CODES, LIVE_EVENT, LIVE_PROTOCOL, MAX_DELTA_OPS, MAX_LIVE_FRAME_BYTES, MAX_PRESENCE_METADATA_BYTES, RESERVED_EVENT_PREFIX, applyListDelta, canonicalLiveFrame, encodeListDelta, encodeLiveEnvelope, encodeLiveFrame, isClientLiveFrame, isRowOp, isRowOps, isServerLiveFrame, liveEnvelope, readLiveEnvelope, runProtocolConformance };
913
+
914
+ //# sourceMappingURL=index.js.map