@velajs/live-protocol 1.0.0 → 1.0.1

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