@velajs/live-protocol 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kauan Guesser
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # @velajs/live-protocol
2
+
3
+ The normative wire protocol for **Vela live queries** — the single source of truth both the server (`@velajs/vela/live`) and the client (`@velajs/client`) implement against, so the two sides cannot drift.
4
+
5
+ Zero runtime dependencies. Ships three things:
6
+
7
+ 1. **The frame catalog** — live frames ride Vela's WebSocket envelope under the reserved event `$live`, discriminated on `t`:
8
+ - client → server: `sub`, `unsub`, `presence`
9
+ - server → client: `ack`, `data`, `delta`, `settled`, `resume`, `error`
10
+ - plus the `Vela-Commit-Cursor` / `Vela-Commit-Epoch` HTTP header names used to gate optimistic-update drops.
11
+ 2. **The shared keyed-delta codec** — `encodeListDelta(previous, next)` / `applyListDelta(current, ops)` with identical bail-to-snapshot rules on both sides, and an exact-reconstruction guarantee: whenever the encoder does not bail, applying the ops reproduces `next` byte-for-byte, ordering included.
12
+ 3. **Golden conformance fixtures** — `runProtocolConformance(codec)` runs byte-exact frame fixtures, pinned delta fixtures, and a seeded randomized sweep. The server and client test suites both call it; a wire change that forgets to update the fixtures fails a test instead of shipping an incompatibility.
13
+
14
+ ## Versioning
15
+
16
+ `LIVE_PROTOCOL` (currently `1`) bumps only on breaking wire changes. Receivers must ignore unknown frame types and unknown fields; additive changes do not bump the version. Any wire change releases in lockstep: live-protocol → `@velajs/vela` → `@velajs/cloudflare` → `@velajs/client`.
17
+
18
+ ## Delivery semantics (normative summary)
19
+
20
+ - **At-least-once** frames; keyed delta merge is idempotent, so replay after reconnect is harmless.
21
+ - **Cursor + epoch** identify a position in a *log scope* (one Durable Object on Cloudflare, one process on Node). Epoch mismatch ⇒ full snapshot, never a delta.
22
+ - **`settled`** means the re-run result was byte-identical: no payload, but the cursor still advances (this is what drops optimistic layers for writes that didn't change a query's result).
23
+ - **`resume`** means nothing relevant changed while the client was away: keep the cached value, advance the cursor.
24
+ - Optimistic updates gate on a subscription frame whose `cursor` passes the mutation's `Vela-Commit-Cursor` — never on HTTP response timing, which races the broadcast.
25
+
26
+ See `vela/LIVE.md` in the main framework repo for the full feature documentation.
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The protocol conformance runner. Both wire endpoints run this in their own
3
+ * test suites (see `@velajs/testing`'s live harness) so a codec that drifts
4
+ * from the golden fixtures — or from the shared delta semantics — fails a test
5
+ * on the offending side.
6
+ *
7
+ * Checks, in order:
8
+ * 1. Frame encoding: `encodeLiveEnvelope(fixture.frame)` is byte-identical to
9
+ * the pinned wire string, the wire parses back to a guard-recognized frame,
10
+ * and `readLiveEnvelope` extracts it.
11
+ * 2. Delta fixtures: the codec's `encodeListDelta` produces exactly the pinned
12
+ * ops (or bails where the fixture says it must), and for every mergeable
13
+ * fixture `applyListDelta` reconstructs `next` exactly — then reapplying
14
+ * the same ops changes nothing (at-least-once replay idempotency).
15
+ * 3. A seeded randomized sweep of generated list pairs asserting the
16
+ * exact-reconstruction property on cases the fixtures don't enumerate.
17
+ */
18
+ import type { RowOp } from './frames';
19
+ /** The two halves a wire endpoint must implement compatibly. */
20
+ export interface DeltaCodec {
21
+ encodeListDelta: (previous: unknown, next: unknown, keyField?: string) => RowOp[] | undefined;
22
+ applyListDelta: (current: unknown, ops: readonly RowOp[], keyField?: string) => unknown[] | undefined;
23
+ }
24
+ export interface ConformanceReport {
25
+ /** Human-readable failure descriptions; empty = conformant. */
26
+ failures: string[];
27
+ checks: number;
28
+ }
29
+ /**
30
+ * Run the full conformance suite against a codec (defaults to the reference
31
+ * codec in this package — the package's own tests run exactly this).
32
+ */
33
+ export declare const runProtocolConformance: (codec?: DeltaCodec) => ConformanceReport;
@@ -0,0 +1,152 @@
1
+ /**
2
+ * The protocol conformance runner. Both wire endpoints run this in their own
3
+ * test suites (see `@velajs/testing`'s live harness) so a codec that drifts
4
+ * from the golden fixtures — or from the shared delta semantics — fails a test
5
+ * on the offending side.
6
+ *
7
+ * Checks, in order:
8
+ * 1. Frame encoding: `encodeLiveEnvelope(fixture.frame)` is byte-identical to
9
+ * the pinned wire string, the wire parses back to a guard-recognized frame,
10
+ * and `readLiveEnvelope` extracts it.
11
+ * 2. Delta fixtures: the codec's `encodeListDelta` produces exactly the pinned
12
+ * ops (or bails where the fixture says it must), and for every mergeable
13
+ * fixture `applyListDelta` reconstructs `next` exactly — then reapplying
14
+ * the same ops changes nothing (at-least-once replay idempotency).
15
+ * 3. A seeded randomized sweep of generated list pairs asserting the
16
+ * exact-reconstruction property on cases the fixtures don't enumerate.
17
+ */ import { DEFAULT_KEY_FIELD, applyListDelta, encodeListDelta } from "./delta.js";
18
+ import { encodeLiveEnvelope, isClientLiveFrame, isServerLiveFrame, readLiveEnvelope } from "./frames.js";
19
+ import { DELTA_FIXTURES, FRAME_FIXTURES } from "./fixtures.js";
20
+ const REFERENCE_CODEC = {
21
+ encodeListDelta,
22
+ applyListDelta
23
+ };
24
+ const deepEqual = (a, b)=>{
25
+ if (a === b) return true;
26
+ if (Array.isArray(a) || Array.isArray(b)) {
27
+ if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
28
+ return a.every((value, index)=>deepEqual(value, b[index]));
29
+ }
30
+ if (typeof a === 'object' && typeof b === 'object' && a !== null && b !== null) {
31
+ const aKeys = Object.keys(a);
32
+ const bKeys = Object.keys(b);
33
+ if (aKeys.length !== bKeys.length) return false;
34
+ return aKeys.every((key)=>key in b && deepEqual(a[key], b[key]));
35
+ }
36
+ return false;
37
+ };
38
+ /** Deterministic LCG so the randomized sweep is reproducible (no Math.random). */ const makeRandom = (seed)=>{
39
+ let state = seed >>> 0;
40
+ return ()=>{
41
+ state = state * 1664525 + 1013904223 >>> 0;
42
+ return state / 0x1_0000_0000;
43
+ };
44
+ };
45
+ /**
46
+ * Generate a mergeable previous/next pair: start from a random keyed list,
47
+ * then delete a random subset, update random payloads, and insert fresh keys
48
+ * at random positions — survivor order is preserved by construction, so the
49
+ * encoder may only bail via the op-count cap (rule 5).
50
+ */ const generateCase = (random, caseIndex)=>{
51
+ const previousLength = Math.floor(random() * 8);
52
+ const previous = [];
53
+ for(let index = 0; index < previousLength; index += 1){
54
+ previous.push({
55
+ id: `k${caseIndex}-${index}`,
56
+ n: Math.floor(random() * 100)
57
+ });
58
+ }
59
+ const next = [];
60
+ for (const row of previous){
61
+ if (random() < 0.25) continue; // delete
62
+ next.push(random() < 0.4 ? {
63
+ ...row,
64
+ n: Math.floor(random() * 100)
65
+ } : row);
66
+ }
67
+ const insertions = Math.floor(random() * 4);
68
+ for(let index = 0; index < insertions; index += 1){
69
+ const position = Math.floor(random() * (next.length + 1));
70
+ next.splice(position, 0, {
71
+ id: `f${caseIndex}-${index}`,
72
+ n: Math.floor(random() * 100)
73
+ });
74
+ }
75
+ return {
76
+ previous,
77
+ next
78
+ };
79
+ };
80
+ /**
81
+ * Run the full conformance suite against a codec (defaults to the reference
82
+ * codec in this package — the package's own tests run exactly this).
83
+ */ export const runProtocolConformance = (codec = REFERENCE_CODEC)=>{
84
+ const failures = [];
85
+ let checks = 0;
86
+ for (const fixture of FRAME_FIXTURES){
87
+ checks += 1;
88
+ const encoded = encodeLiveEnvelope(fixture.frame);
89
+ if (encoded !== fixture.wire) {
90
+ failures.push(`frame "${fixture.name}": encoded wire differs\n expected ${fixture.wire}\n actual ${encoded}`);
91
+ continue;
92
+ }
93
+ const frame = readLiveEnvelope(JSON.parse(fixture.wire));
94
+ if (frame === undefined) {
95
+ failures.push(`frame "${fixture.name}": readLiveEnvelope did not recognize the envelope`);
96
+ continue;
97
+ }
98
+ if (!isClientLiveFrame(frame) && !isServerLiveFrame(frame)) {
99
+ failures.push(`frame "${fixture.name}": decoded frame not recognized by either guard`);
100
+ }
101
+ }
102
+ for (const fixture of DELTA_FIXTURES){
103
+ checks += 1;
104
+ const keyField = fixture.keyField ?? DEFAULT_KEY_FIELD;
105
+ const ops = codec.encodeListDelta(fixture.previous, fixture.next, keyField);
106
+ if (fixture.expected === null) {
107
+ if (ops !== undefined) {
108
+ failures.push(`delta "${fixture.name}": expected bail-to-snapshot, got ${JSON.stringify(ops)}`);
109
+ }
110
+ continue;
111
+ }
112
+ if (ops === undefined) {
113
+ failures.push(`delta "${fixture.name}": encoder bailed, expected ${JSON.stringify(fixture.expected)}`);
114
+ continue;
115
+ }
116
+ if (!deepEqual(ops, fixture.expected)) {
117
+ failures.push(`delta "${fixture.name}": ops differ\n expected ${JSON.stringify(fixture.expected)}\n actual ${JSON.stringify(ops)}`);
118
+ continue;
119
+ }
120
+ const merged = codec.applyListDelta(fixture.previous, ops, keyField);
121
+ if (merged === undefined || !deepEqual(merged, fixture.next)) {
122
+ failures.push(`delta "${fixture.name}": apply(previous, ops) did not reconstruct next\n expected ${JSON.stringify(fixture.next)}\n actual ${JSON.stringify(merged)}`);
123
+ continue;
124
+ }
125
+ const replayed = codec.applyListDelta(merged, ops, keyField);
126
+ if (replayed === undefined || !deepEqual(replayed, fixture.next)) {
127
+ failures.push(`delta "${fixture.name}": replaying the same ops was not idempotent`);
128
+ }
129
+ }
130
+ const random = makeRandom(0x5eed);
131
+ for(let caseIndex = 0; caseIndex < 250; caseIndex += 1){
132
+ checks += 1;
133
+ const { previous, next } = generateCase(random, caseIndex);
134
+ const ops = codec.encodeListDelta(previous, next);
135
+ if (ops === undefined) {
136
+ // Survivor order is preserved by construction, so only rule 5 may bail.
137
+ const referenceOps = encodeListDelta(previous, next);
138
+ if (referenceOps !== undefined) {
139
+ failures.push(`random #${caseIndex}: codec bailed where the reference codec succeeds`);
140
+ }
141
+ continue;
142
+ }
143
+ const merged = codec.applyListDelta(previous, ops);
144
+ if (merged === undefined || !deepEqual(merged, next)) {
145
+ 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)}`);
146
+ }
147
+ }
148
+ return {
149
+ failures,
150
+ checks
151
+ };
152
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The shared keyed list-delta codec — BOTH sides of the wire implement the
3
+ * delta contract through this one module: the server encodes a previous-vs-next
4
+ * query result into `RowOp`s ({@link encodeListDelta}), the client merges them
5
+ * into its cached value ({@link applyListDelta}).
6
+ *
7
+ * Ported from lunora's `subscription-delivery.ts` (encoder) and
8
+ * `delta-merge.ts` (merge), with two deliberate changes:
9
+ *
10
+ * 1. The key field defaults to `'id'` (Vela/CRUD convention, not lunora's
11
+ * `_id`) and is configurable per query.
12
+ * 2. `insert` ops carry an explicit `before` anchor (the key of the row they
13
+ * precede in the authoritative result; `null` = append) instead of
14
+ * approximating position via a `_creationTime` heuristic. Because encoder
15
+ * and merge live in the same package, this buys the exact-reconstruction
16
+ * property the conformance suite enforces: whenever the encoder does not
17
+ * bail, `applyListDelta(previous, encodeListDelta(previous, next))` is
18
+ * deep-equal to `next`, ordering included.
19
+ *
20
+ * Bail-to-snapshot contract (identical on both sides — the server MUST send a
21
+ * full `data` snapshot and the client MUST fall back to full replacement when
22
+ * any of these hold):
23
+ *
24
+ * 1. previous or next is not an array;
25
+ * 2. any row is not a plain object carrying a string key (or the value cannot
26
+ * be JSON-serialized);
27
+ * 3. a duplicate key appears in either array;
28
+ * 4. rows present in BOTH arrays changed relative order (the merge replaces
29
+ * survivors in place and never reorders them);
30
+ * 5. the op count exceeds the next array's length (a near-total change is
31
+ * cheaper as a snapshot).
32
+ *
33
+ * Op ordering inside a delta: deletes first (previous order), then
34
+ * inserts/updates (next order) — the merge never sees a transient over-length
35
+ * list. Merging is idempotent (`insert` on an existing key replaces in place,
36
+ * `delete` of an absent key is a no-op) so at-least-once replay after a
37
+ * reconnect is harmless.
38
+ */
39
+ import type { RowOp } from './frames';
40
+ /** Default row-identity field. Per-query override rides the `sub` frame's `key`. */
41
+ export declare const DEFAULT_KEY_FIELD = "id";
42
+ /**
43
+ * Diff `previous` vs `next` into row ops, or `undefined` when any bail rule
44
+ * holds and the caller must send a full snapshot instead.
45
+ *
46
+ * An empty array is a valid result (no row-level change — typically the server
47
+ * catches byte-identical results earlier and sends `settled` instead).
48
+ */
49
+ export declare const encodeListDelta: (previous: unknown, next: unknown, keyField?: string) => RowOp[] | undefined;
50
+ /**
51
+ * Merge row ops into a cached array result, returning a NEW array (the input
52
+ * is never mutated), or `undefined` when the ops cannot be applied cleanly —
53
+ * the caller then falls back to full replacement and lets the next snapshot
54
+ * reconcile.
55
+ *
56
+ * Idempotent by construction: replaying an op after a snapshot already
57
+ * delivered its effect changes nothing.
58
+ */
59
+ export declare const applyListDelta: (current: unknown, ops: readonly RowOp[], keyField?: string) => unknown[] | undefined;
package/dist/delta.js ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The shared keyed list-delta codec — BOTH sides of the wire implement the
3
+ * delta contract through this one module: the server encodes a previous-vs-next
4
+ * query result into `RowOp`s ({@link encodeListDelta}), the client merges them
5
+ * into its cached value ({@link applyListDelta}).
6
+ *
7
+ * Ported from lunora's `subscription-delivery.ts` (encoder) and
8
+ * `delta-merge.ts` (merge), with two deliberate changes:
9
+ *
10
+ * 1. The key field defaults to `'id'` (Vela/CRUD convention, not lunora's
11
+ * `_id`) and is configurable per query.
12
+ * 2. `insert` ops carry an explicit `before` anchor (the key of the row they
13
+ * precede in the authoritative result; `null` = append) instead of
14
+ * approximating position via a `_creationTime` heuristic. Because encoder
15
+ * and merge live in the same package, this buys the exact-reconstruction
16
+ * property the conformance suite enforces: whenever the encoder does not
17
+ * bail, `applyListDelta(previous, encodeListDelta(previous, next))` is
18
+ * deep-equal to `next`, ordering included.
19
+ *
20
+ * Bail-to-snapshot contract (identical on both sides — the server MUST send a
21
+ * full `data` snapshot and the client MUST fall back to full replacement when
22
+ * any of these hold):
23
+ *
24
+ * 1. previous or next is not an array;
25
+ * 2. any row is not a plain object carrying a string key (or the value cannot
26
+ * be JSON-serialized);
27
+ * 3. a duplicate key appears in either array;
28
+ * 4. rows present in BOTH arrays changed relative order (the merge replaces
29
+ * survivors in place and never reorders them);
30
+ * 5. the op count exceeds the next array's length (a near-total change is
31
+ * cheaper as a snapshot).
32
+ *
33
+ * Op ordering inside a delta: deletes first (previous order), then
34
+ * inserts/updates (next order) — the merge never sees a transient over-length
35
+ * list. Merging is idempotent (`insert` on an existing key replaces in place,
36
+ * `delete` of an absent key is a no-op) so at-least-once replay after a
37
+ * reconnect is harmless.
38
+ */ /** Default row-identity field. Per-query override rides the `sub` frame's `key`. */ export const DEFAULT_KEY_FIELD = 'id';
39
+ const isPlainObject = (value)=>typeof value === 'object' && value !== null && !Array.isArray(value);
40
+ const readRowKey = (row, keyField)=>{
41
+ if (!isPlainObject(row)) return undefined;
42
+ const key = row[keyField];
43
+ return typeof key === 'string' ? key : undefined;
44
+ };
45
+ /**
46
+ * Index rows by key preserving order; `undefined` the moment any row is
47
+ * unkeyable or a key repeats (bail rules 2 and 3 — a duplicated key cannot be
48
+ * expressed as keyed deltas without silently collapsing rows).
49
+ */ const indexRows = (rows, keyField)=>{
50
+ const byKey = new Map();
51
+ const order = [];
52
+ for (const row of rows){
53
+ const key = readRowKey(row, keyField);
54
+ if (key === undefined || byKey.has(key)) return undefined;
55
+ byKey.set(key, row);
56
+ order.push(key);
57
+ }
58
+ return {
59
+ byKey,
60
+ order
61
+ };
62
+ };
63
+ /**
64
+ * True when rows present in BOTH lists keep the same relative order (bail
65
+ * rule 4): the merge updates survivors in place and never reorders them, so a
66
+ * survivor that moved cannot be expressed as deltas.
67
+ */ const survivorsKeepOrder = (previous, next)=>{
68
+ const survivingPrevious = previous.order.filter((key)=>next.byKey.has(key));
69
+ const survivingNext = next.order.filter((key)=>previous.byKey.has(key));
70
+ if (survivingPrevious.length !== survivingNext.length) return false;
71
+ return survivingPrevious.every((key, index)=>survivingNext[index] === key);
72
+ };
73
+ /**
74
+ * Diff `previous` vs `next` into row ops, or `undefined` when any bail rule
75
+ * holds and the caller must send a full snapshot instead.
76
+ *
77
+ * An empty array is a valid result (no row-level change — typically the server
78
+ * catches byte-identical results earlier and sends `settled` instead).
79
+ */ export const encodeListDelta = (previous, next, keyField = DEFAULT_KEY_FIELD)=>{
80
+ try {
81
+ if (!Array.isArray(previous) || !Array.isArray(next)) return undefined;
82
+ const previousIndex = indexRows(previous, keyField);
83
+ const nextIndex = indexRows(next, keyField);
84
+ if (previousIndex === undefined || nextIndex === undefined) return undefined;
85
+ if (!survivorsKeepOrder(previousIndex, nextIndex)) return undefined;
86
+ const ops = [];
87
+ // Deletes first, in previous order.
88
+ for (const key of previousIndex.order){
89
+ if (!nextIndex.byKey.has(key)) ops.push({
90
+ op: 'delete',
91
+ key
92
+ });
93
+ }
94
+ // The `before` anchor for an insert at position i is the nearest FOLLOWING
95
+ // survivor in next order (null = append). At merge time, when the insert
96
+ // applies, the list holds exactly the survivors (in order, updates replace
97
+ // in place) plus earlier inserts; splicing sequentially before the anchor
98
+ // therefore reproduces next's ordering exactly — inserts sharing an anchor
99
+ // stack in emission order, trailing inserts append in emission order.
100
+ const followingSurvivor = new Array(nextIndex.order.length);
101
+ let anchor = null;
102
+ for(let index = nextIndex.order.length - 1; index >= 0; index -= 1){
103
+ followingSurvivor[index] = anchor;
104
+ const key = nextIndex.order[index];
105
+ if (previousIndex.byKey.has(key)) anchor = key;
106
+ }
107
+ // Inserts/updates in next order. Each row is fingerprinted with a single
108
+ // JSON.stringify reused for the changed-row compare; an unserializable row
109
+ // throws and the whole encode bails to snapshot (rule 2).
110
+ for (const [index, key] of nextIndex.order.entries()){
111
+ const nextRow = nextIndex.byKey.get(key);
112
+ const previousRow = previousIndex.byKey.get(key);
113
+ const nextFingerprint = JSON.stringify(nextRow);
114
+ if (previousRow === undefined) {
115
+ ops.push({
116
+ op: 'insert',
117
+ key,
118
+ row: nextRow,
119
+ before: followingSurvivor[index] ?? null
120
+ });
121
+ continue;
122
+ }
123
+ if (JSON.stringify(previousRow) !== nextFingerprint) {
124
+ ops.push({
125
+ op: 'update',
126
+ key,
127
+ row: nextRow
128
+ });
129
+ }
130
+ }
131
+ // Bail rule 5: a near-total change is better sent as one snapshot.
132
+ if (ops.length > next.length) return undefined;
133
+ return ops;
134
+ } catch {
135
+ return undefined;
136
+ }
137
+ };
138
+ /**
139
+ * Merge row ops into a cached array result, returning a NEW array (the input
140
+ * is never mutated), or `undefined` when the ops cannot be applied cleanly —
141
+ * the caller then falls back to full replacement and lets the next snapshot
142
+ * reconcile.
143
+ *
144
+ * Idempotent by construction: replaying an op after a snapshot already
145
+ * delivered its effect changes nothing.
146
+ */ export const applyListDelta = (current, ops, keyField = DEFAULT_KEY_FIELD)=>{
147
+ if (!Array.isArray(current)) return undefined;
148
+ const rows = [];
149
+ const seen = new Set();
150
+ for (const element of current){
151
+ const key = readRowKey(element, keyField);
152
+ if (key === undefined || seen.has(key)) return undefined;
153
+ seen.add(key);
154
+ rows.push(element);
155
+ }
156
+ let next = [
157
+ ...rows
158
+ ];
159
+ for (const op of ops){
160
+ const existingIndex = next.findIndex((row)=>row[keyField] === op.key);
161
+ if (op.op === 'delete') {
162
+ if (existingIndex !== -1) next.splice(existingIndex, 1);
163
+ continue;
164
+ }
165
+ if (existingIndex !== -1) {
166
+ // Present → replace in place. Covers `update`, and an `insert` whose row
167
+ // a snapshot already delivered (replay idempotency).
168
+ next[existingIndex] = op.row;
169
+ continue;
170
+ }
171
+ if (op.op === 'insert' && op.before !== null) {
172
+ const anchorIndex = next.findIndex((row)=>row[keyField] === op.before);
173
+ if (anchorIndex !== -1) {
174
+ next.splice(anchorIndex, 0, op.row);
175
+ continue;
176
+ }
177
+ }
178
+ // `insert` with a null/missing anchor, or an `update` for a row this page
179
+ // never held (degraded replay) → append.
180
+ next = [
181
+ ...next,
182
+ op.row
183
+ ];
184
+ }
185
+ return next;
186
+ };
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Golden wire fixtures — the drift tripwire. The server suite (@velajs/vela)
3
+ * and the client suite (@velajs/client) both run these through
4
+ * `runProtocolConformance`, so an encoding change on either side fails a test
5
+ * instead of surfacing as a production incompatibility.
6
+ *
7
+ * `wire` strings are byte-exact: they pin the canonical key order of
8
+ * `encodeLiveEnvelope`. Do not reformat them.
9
+ */
10
+ import type { LiveFrame, RowOp } from './frames';
11
+ export interface FrameFixture {
12
+ name: string;
13
+ frame: LiveFrame;
14
+ /** Exact canonical envelope bytes: `encodeLiveEnvelope(frame)` must equal this. */
15
+ wire: string;
16
+ }
17
+ export declare const FRAME_FIXTURES: FrameFixture[];
18
+ export interface DeltaFixture {
19
+ name: string;
20
+ previous: unknown;
21
+ next: unknown;
22
+ keyField?: string;
23
+ /** Expected ops, or `null` when the encoder MUST bail to snapshot. */
24
+ expected: RowOp[] | null;
25
+ }
26
+ export declare const DELTA_FIXTURES: DeltaFixture[];