@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/index.js
CHANGED
|
@@ -1,5 +1,867 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|