@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/CHANGELOG.md +7 -0
- package/dist/index.d.ts +228 -8
- package/dist/index.js +867 -5
- package/dist/index.js.map +1 -0
- package/package.json +47 -35
- package/dist/conformance.d.ts +0 -33
- package/dist/conformance.js +0 -152
- package/dist/delta.d.ts +0 -59
- package/dist/delta.js +0 -186
- package/dist/fixtures.d.ts +0 -26
- package/dist/fixtures.js +0 -592
- package/dist/frames.d.ts +0 -154
- package/dist/frames.js +0 -190
- package/dist/version.d.ts +0 -11
- package/dist/version.js +0 -10
package/dist/delta.js
DELETED
|
@@ -1,186 +0,0 @@
|
|
|
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
|
-
};
|
package/dist/fixtures.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
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[];
|