@syncular/core 0.58.0 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/message.js CHANGED
@@ -14,6 +14,7 @@
14
14
  import { ByteReader, ByteWriter, readStringListMap, readStringMap, utf8Encode, writeStringListMap, writeStringMap, } from './bytes.js';
15
15
  import { isSupportedProtocolWireVersion, SYNC_PACK_MAGIC } from './constants.js';
16
16
  import { DecodeError } from './errors.js';
17
+ import { parseJsonDocument } from './json-document.js';
17
18
  import { FrameType, KNOWN_FRAME_TYPES, REQUEST_FRAME_TYPES, RESPONSE_FRAME_TYPES, } from './frames.js';
18
19
  import { normalizeRejectionDetails, } from './rejection-details.js';
19
20
  import { decodeRowsSegment } from './segment.js';
@@ -28,13 +29,10 @@ function definedObject(record) {
28
29
  function invalid(message) {
29
30
  throw new DecodeError('sync.invalid_request', message);
30
31
  }
31
- function requireJsonDocument(raw, what) {
32
- try {
33
- JSON.parse(raw);
34
- }
35
- catch {
36
- invalid(`${what} is not a valid JSON document`);
37
- }
32
+ /** A `json` field: a valid JSON document (Conventions), kept as raw text. */
33
+ function readJson(r, what) {
34
+ const raw = r.str();
35
+ parseJsonDocument(raw, what);
38
36
  return raw;
39
37
  }
40
38
  /** §1.5: a `ROUND_FAILURE` code is a lowercase dotted catalog code. */
@@ -50,29 +48,63 @@ function validateRoundFailureCode(code, fail) {
50
48
  fail('ROUND_FAILURE.code must be a lowercase dotted code of at most 64 bytes');
51
49
  }
52
50
  }
51
+ /** §1.5, §1.7: a present `outboxId` is non-empty and at most 64 bytes. */
52
+ function checkOutboxId(outboxId, fail) {
53
+ if (outboxId.length === 0 || utf8Encode(outboxId).length > 64) {
54
+ fail('REQ_HEADER.outboxId must be non-empty and at most 64 bytes');
55
+ }
56
+ }
57
+ /** §4.3, §4.4: a unit id is 32 lowercase hex characters. */
58
+ function checkUnitId(id, frame, fail) {
59
+ if (!/^[0-9a-f]{32}$/.test(id)) {
60
+ fail(`${frame}.id must be 32 lowercase hex characters`);
61
+ }
62
+ }
63
+ /** §4.2, §4.4: a digest is 32 bytes; an echo digest may also be empty. */
64
+ function checkDigest(digest, field, fail) {
65
+ const emptyAllowed = field !== 'PULL_HEADER.registrationDigest';
66
+ if (digest.length !== 32 && !(emptyAllowed && digest.length === 0)) {
67
+ fail(`${field} must be ${emptyAllowed ? '0 or ' : ''}32 bytes`);
68
+ }
69
+ }
70
+ /** §4.2, §4.3: a cursor is `-1` or a commitSeq. */
71
+ function checkCursor(cursor, field, fail) {
72
+ if (cursor < -1)
73
+ fail(`${field} must be >= -1`);
74
+ }
75
+ /** §4.4: fenced records in strictly ascending code-unit order of `table`. */
76
+ function checkFencedOrder(fenced, fail) {
77
+ for (let i = 1; i < fenced.length; i++) {
78
+ if (fenced[i - 1].table >= fenced[i].table) {
79
+ fail('SUB_START.fenced tables must be unique and in ascending order');
80
+ }
81
+ }
82
+ }
53
83
  // ---------------------------------------------------------------------------
54
84
  // Frame payload decoders (each consumes exactly the frame payload)
55
85
  // ---------------------------------------------------------------------------
56
- function decodeReqHeader(r, wireVersion) {
86
+ function decodeReqHeader(r) {
57
87
  const clientId = r.str();
58
88
  if (clientId.length === 0)
59
89
  invalid('REQ_HEADER.clientId must be non-empty');
60
90
  const schemaVersion = r.i32();
61
91
  if (schemaVersion < 1)
62
92
  invalid('REQ_HEADER.schemaVersion must be >= 1');
63
- const logEpoch = wireVersion >= 2
64
- ? r.opt(() => {
65
- const value = r.str();
66
- if (value.length === 0)
67
- invalid('REQ_HEADER.logEpoch must be non-empty');
68
- return value;
69
- })
70
- : undefined;
93
+ const logEpoch = r.opt(() => {
94
+ const value = r.str();
95
+ if (value.length === 0)
96
+ invalid('REQ_HEADER.logEpoch must be non-empty');
97
+ return value;
98
+ });
99
+ const outboxId = r.opt(() => r.str());
100
+ if (outboxId !== undefined)
101
+ checkOutboxId(outboxId, invalid);
71
102
  return definedObject({
72
103
  type: 'REQ_HEADER',
73
104
  clientId,
74
105
  schemaVersion,
75
106
  logEpoch: logEpoch,
107
+ outboxId: outboxId,
76
108
  });
77
109
  }
78
110
  function decodeRoundFailure(r) {
@@ -85,6 +117,9 @@ function decodePushCommit(r) {
85
117
  if (clientCommitId.length === 0) {
86
118
  invalid('PUSH_COMMIT.clientCommitId must be non-empty');
87
119
  }
120
+ const clientSeq = r.i64();
121
+ if (clientSeq < 0)
122
+ invalid('PUSH_COMMIT.clientSeq must be >= 0');
88
123
  const operationCount = r.u32();
89
124
  if (operationCount === 0) {
90
125
  throw new DecodeError('sync.empty_commit', 'PUSH_COMMIT must carry at least one operation');
@@ -117,7 +152,7 @@ function decodePushCommit(r) {
117
152
  payload: payload,
118
153
  }));
119
154
  }
120
- return { type: 'PUSH_COMMIT', clientCommitId, operations };
155
+ return { type: 'PUSH_COMMIT', clientCommitId, clientSeq, operations };
121
156
  }
122
157
  function decodePullHeader(r) {
123
158
  const limitCommits = r.i32();
@@ -127,45 +162,50 @@ function decodePullHeader(r) {
127
162
  if ((accept & 0xf0) !== 0) {
128
163
  invalid('PULL_HEADER.accept bits 4-7 must be zero');
129
164
  }
165
+ const cursor = r.i64();
166
+ checkCursor(cursor, 'PULL_HEADER.cursor', invalid);
167
+ const registrationDigest = r.bytes();
168
+ checkDigest(registrationDigest, 'PULL_HEADER.registrationDigest', invalid);
169
+ const echoDigest = r.bytes();
170
+ checkDigest(echoDigest, 'PULL_HEADER.echoDigest', invalid);
130
171
  return {
131
172
  type: 'PULL_HEADER',
132
173
  limitCommits,
133
174
  limitSnapshotRows,
134
175
  maxSnapshotPages,
135
176
  accept,
177
+ cursor,
178
+ registrationDigest,
179
+ echoDigest,
136
180
  };
137
181
  }
138
- function decodeSubscription(r) {
182
+ function decodeProgress(r) {
139
183
  const id = r.str();
140
- const table = r.str();
141
- const scopes = readStringListMap(r);
142
- const params = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.params'));
184
+ checkUnitId(id, 'PROGRESS', invalid);
143
185
  const cursor = r.i64();
144
- const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'));
186
+ checkCursor(cursor, 'PROGRESS.cursor', invalid);
187
+ const bootstrapState = r.opt(() => readJson(r, 'PROGRESS.bootstrapState'));
145
188
  return definedObject({
146
- type: 'SUBSCRIPTION',
189
+ type: 'PROGRESS',
147
190
  id,
148
- table,
149
- scopes,
150
- params: params,
151
191
  cursor,
152
192
  bootstrapState: bootstrapState,
153
193
  });
154
194
  }
155
- function decodeRespHeader(r, wireVersion) {
195
+ function decodeRespHeader(r) {
156
196
  const requiredSchemaVersion = r.opt(() => r.i32());
157
197
  const latestSchemaVersion = r.opt(() => r.i32());
158
- const logEpoch = wireVersion >= 2 ? r.str() : undefined;
159
- if (logEpoch !== undefined && logEpoch.length === 0) {
198
+ const logEpoch = r.str();
199
+ if (logEpoch.length === 0) {
160
200
  invalid('RESP_HEADER.logEpoch must be non-empty');
161
201
  }
162
- const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
202
+ const resetRequired = r.bool();
163
203
  return definedObject({
164
204
  type: 'RESP_HEADER',
165
205
  requiredSchemaVersion: requiredSchemaVersion,
166
206
  latestSchemaVersion: latestSchemaVersion,
167
- logEpoch: logEpoch,
168
- resetRequired: resetRequired,
207
+ logEpoch,
208
+ resetRequired,
169
209
  });
170
210
  }
171
211
  function decodeLease(r) {
@@ -250,9 +290,10 @@ function decodePushResultDetails(r) {
250
290
  invalid('PUSH_RESULT_DETAILS opIndex values must be unique and non-negative');
251
291
  }
252
292
  seen.add(opIndex);
293
+ const parsed = parseJsonDocument(r.str(), 'PUSH_RESULT_DETAILS.details');
253
294
  let details;
254
295
  try {
255
- details = normalizeRejectionDetails(JSON.parse(r.str()));
296
+ details = normalizeRejectionDetails(parsed);
256
297
  }
257
298
  catch (error) {
258
299
  invalid(`invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`);
@@ -263,6 +304,7 @@ function decodePushResultDetails(r) {
263
304
  }
264
305
  function decodeSubStart(r) {
265
306
  const id = r.str();
307
+ checkUnitId(id, 'SUB_START', invalid);
266
308
  const statusByte = r.u8();
267
309
  let status;
268
310
  if (statusByte === 1)
@@ -274,16 +316,42 @@ function decodeSubStart(r) {
274
316
  else
275
317
  invalid(`invalid SUB_START status byte ${statusByte}`);
276
318
  const reasonCode = r.str();
277
- const effectiveScopes = readStringListMap(r);
278
319
  const bootstrap = r.bool();
279
- return {
280
- type: 'SUB_START',
281
- id,
282
- status,
283
- reasonCode,
284
- effectiveScopes,
285
- bootstrap,
286
- };
320
+ const fencedCount = r.u32();
321
+ const fenced = [];
322
+ for (let i = 0; i < fencedCount; i++) {
323
+ const table = r.str();
324
+ const fencedStatus = r.u8();
325
+ if (fencedStatus !== 1 && fencedStatus !== 2) {
326
+ invalid('invalid fenced record status byte');
327
+ }
328
+ fenced.push({
329
+ table,
330
+ status: fencedStatus === 1 ? 'active' : 'revoked',
331
+ scopes: readStringListMap(r),
332
+ });
333
+ }
334
+ checkFencedOrder(fenced, invalid);
335
+ return { type: 'SUB_START', id, status, reasonCode, bootstrap, fenced };
336
+ }
337
+ function decodeLiveStart(r) {
338
+ const statusByte = r.u8();
339
+ let status;
340
+ if (statusByte === 1)
341
+ status = 'active';
342
+ else if (statusByte === 3)
343
+ status = 'reset';
344
+ else if (statusByte === 4)
345
+ status = 'unregistered';
346
+ else
347
+ invalid('invalid LIVE_START status byte');
348
+ return { type: 'LIVE_START', status, reasonCode: r.str() };
349
+ }
350
+ function decodeLiveEnd(r) {
351
+ const throughSeq = r.i64();
352
+ const echoDigest = r.bytes();
353
+ checkDigest(echoDigest, 'LIVE_END.echoDigest', invalid);
354
+ return { type: 'LIVE_END', throughSeq, echoDigest };
287
355
  }
288
356
  function decodeCommit(r) {
289
357
  const commitSeq = r.i64();
@@ -309,23 +377,15 @@ function decodeCommit(r) {
309
377
  op = 'delete';
310
378
  else
311
379
  invalid(`invalid change op byte ${opByte}`);
312
- const rowVersion = r.opt(() => r.i64());
380
+ const rowVersion = r.i64();
381
+ if (rowVersion < 1)
382
+ invalid('change rowVersion must be at least 1');
313
383
  const scopes = readStringMap(r);
314
384
  const row = r.opt(() => r.bytes());
315
- if (op === 'upsert' && (rowVersion === undefined || row === undefined)) {
316
- invalid('upsert change requires rowVersion and row');
317
- }
318
- if (op === 'delete' && (rowVersion !== undefined || row !== undefined)) {
319
- invalid('delete change must not carry rowVersion or row');
385
+ if ((op === 'upsert') !== (row !== undefined)) {
386
+ invalid('change row must be present iff op is upsert');
320
387
  }
321
- changes.push(definedObject({
322
- tableIndex,
323
- rowId,
324
- op,
325
- rowVersion: rowVersion,
326
- scopes,
327
- row: row,
328
- }));
388
+ changes.push(definedObject({ tableIndex, rowId, op, rowVersion, scopes, row }));
329
389
  }
330
390
  return { type: 'COMMIT', commitSeq, createdAtMs, actorId, tables, changes };
331
391
  }
@@ -375,7 +435,7 @@ function decodeSegmentInline(r) {
375
435
  }
376
436
  function decodeSubEnd(r) {
377
437
  const nextCursor = r.i64();
378
- const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUB_END.bootstrapState'));
438
+ const bootstrapState = r.opt(() => readJson(r, 'SUB_END.bootstrapState'));
379
439
  return definedObject({
380
440
  type: 'SUB_END',
381
441
  nextCursor,
@@ -388,7 +448,7 @@ function decodeErrorFrame(r) {
388
448
  const category = r.str();
389
449
  const retryable = r.bool();
390
450
  const recommendedAction = r.str();
391
- const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
451
+ const details = r.opt(() => readJson(r, 'ERROR.details'));
392
452
  return definedObject({
393
453
  type: 'ERROR',
394
454
  code,
@@ -399,12 +459,12 @@ function decodeErrorFrame(r) {
399
459
  details: details,
400
460
  });
401
461
  }
402
- function decodeRequestFrame(frameType, payload, wireVersion) {
462
+ function decodeRequestFrame(frameType, payload) {
403
463
  const r = new ByteReader(payload);
404
464
  let frame;
405
465
  switch (frameType) {
406
466
  case FrameType.REQ_HEADER:
407
- frame = decodeReqHeader(r, wireVersion);
467
+ frame = decodeReqHeader(r);
408
468
  break;
409
469
  case FrameType.ROUND_FAILURE:
410
470
  frame = decodeRoundFailure(r);
@@ -415,8 +475,11 @@ function decodeRequestFrame(frameType, payload, wireVersion) {
415
475
  case FrameType.PULL_HEADER:
416
476
  frame = decodePullHeader(r);
417
477
  break;
418
- case FrameType.SUBSCRIPTION:
419
- frame = decodeSubscription(r);
478
+ case FrameType.UNIT:
479
+ frame = { type: 'UNIT', scopes: readStringMap(r) };
480
+ break;
481
+ case FrameType.PROGRESS:
482
+ frame = decodeProgress(r);
420
483
  break;
421
484
  default:
422
485
  if (RESPONSE_FRAME_TYPES.has(frameType)) {
@@ -427,12 +490,12 @@ function decodeRequestFrame(frameType, payload, wireVersion) {
427
490
  r.expectFullyConsumed(`${frame.type} frame payload`);
428
491
  return frame;
429
492
  }
430
- function decodeResponseFrame(frameType, payload, wireVersion) {
493
+ function decodeResponseFrame(frameType, payload) {
431
494
  const r = new ByteReader(payload);
432
495
  let frame;
433
496
  switch (frameType) {
434
497
  case FrameType.RESP_HEADER:
435
- frame = decodeRespHeader(r, wireVersion);
498
+ frame = decodeRespHeader(r);
436
499
  break;
437
500
  case FrameType.LEASE:
438
501
  frame = decodeLease(r);
@@ -458,6 +521,12 @@ function decodeResponseFrame(frameType, payload, wireVersion) {
458
521
  case FrameType.SUB_END:
459
522
  frame = decodeSubEnd(r);
460
523
  break;
524
+ case FrameType.LIVE_START:
525
+ frame = decodeLiveStart(r);
526
+ break;
527
+ case FrameType.LIVE_END:
528
+ frame = decodeLiveEnd(r);
529
+ break;
461
530
  case FrameType.ERROR:
462
531
  frame = decodeErrorFrame(r);
463
532
  break;
@@ -480,6 +549,7 @@ function validateRequestSequence(frames) {
480
549
  }
481
550
  let pushCount = 0;
482
551
  let hasPull = false;
552
+ let hasProgress = false;
483
553
  let index = 1;
484
554
  let sawRoundFailure = false;
485
555
  for (const frame of frames.slice(1)) {
@@ -506,9 +576,16 @@ function validateRequestSequence(frames) {
506
576
  invalid('duplicate PULL_HEADER frame');
507
577
  hasPull = true;
508
578
  break;
509
- case 'SUBSCRIPTION':
579
+ case 'UNIT':
510
580
  if (!hasPull)
511
- invalid('SUBSCRIPTION frame without a PULL_HEADER');
581
+ invalid('UNIT frame without a PULL_HEADER');
582
+ if (hasProgress)
583
+ invalid('UNIT frame after a PROGRESS frame');
584
+ break;
585
+ case 'PROGRESS':
586
+ if (!hasPull)
587
+ invalid('PROGRESS frame without a PULL_HEADER');
588
+ hasProgress = true;
512
589
  break;
513
590
  case 'UNKNOWN':
514
591
  break;
@@ -524,12 +601,12 @@ function validateResponseSequence(frames) {
524
601
  if (first === undefined || first.type !== 'RESP_HEADER') {
525
602
  invalid('response must start with a RESP_HEADER frame');
526
603
  }
527
- let inSubscription = false;
528
- let sawSubscription = false;
604
+ // §1.6 sections: unit sections, then at most one live section.
605
+ let section = 'none';
606
+ let sawUnitSection = false;
607
+ let sawLive = false;
529
608
  let sawLease = false;
530
609
  let sawBody = false;
531
- let subscriptionHasCommits = false;
532
- let subscriptionHasSegments = false;
533
610
  let errorSeen = false;
534
611
  let lastPushResult;
535
612
  let lastPushResultHasDetails = false;
@@ -541,8 +618,7 @@ function validateResponseSequence(frames) {
541
618
  invalid('duplicate RESP_HEADER frame');
542
619
  break;
543
620
  case 'LEASE':
544
- // §7.3.2: at most one LEASE, immediately after RESP_HEADER (before
545
- // any PUSH_RESULT / subscription section / ERROR).
621
+ // §7.3.2: at most one LEASE, immediately after RESP_HEADER.
546
622
  if (sawLease)
547
623
  invalid('duplicate LEASE frame');
548
624
  if (sawBody)
@@ -550,15 +626,16 @@ function validateResponseSequence(frames) {
550
626
  sawLease = true;
551
627
  break;
552
628
  case 'PUSH_RESULT':
553
- if (sawSubscription)
554
- invalid('PUSH_RESULT frame after SUB_START');
629
+ if (sawUnitSection || sawLive) {
630
+ invalid('PUSH_RESULT frame after a unit or live section');
631
+ }
555
632
  lastPushResult = frame;
556
633
  lastPushResultHasDetails = false;
557
634
  sawBody = true;
558
635
  break;
559
636
  case 'PUSH_RESULT_DETAILS': {
560
- if (sawSubscription) {
561
- invalid('PUSH_RESULT_DETAILS frame after SUB_START');
637
+ if (sawUnitSection || sawLive) {
638
+ invalid('PUSH_RESULT_DETAILS frame after a unit or live section');
562
639
  }
563
640
  if (lastPushResult === undefined) {
564
641
  invalid('PUSH_RESULT_DETAILS without a preceding PUSH_RESULT');
@@ -579,36 +656,43 @@ function validateResponseSequence(frames) {
579
656
  break;
580
657
  }
581
658
  case 'SUB_START':
582
- if (inSubscription)
659
+ if (section === 'unit')
583
660
  invalid('nested SUB_START frame');
584
- inSubscription = true;
585
- sawSubscription = true;
661
+ if (sawLive)
662
+ invalid('SUB_START frame inside or after the live section');
663
+ section = 'unit';
664
+ sawUnitSection = true;
586
665
  sawBody = true;
587
666
  lastPushResult = undefined;
588
- subscriptionHasCommits = false;
589
- subscriptionHasSegments = false;
590
- break;
591
- case 'COMMIT':
592
- if (!inSubscription)
593
- invalid('COMMIT frame outside a subscription');
594
- if (subscriptionHasSegments) {
595
- invalid('COMMIT and segment frames must not both appear for one subscription');
596
- }
597
- subscriptionHasCommits = true;
598
667
  break;
599
668
  case 'SEGMENT_REF':
600
669
  case 'SEGMENT_INLINE':
601
- if (!inSubscription)
602
- invalid('segment frame outside a subscription');
603
- if (subscriptionHasCommits) {
604
- invalid('COMMIT and segment frames must not both appear for one subscription');
605
- }
606
- subscriptionHasSegments = true;
670
+ if (section !== 'unit')
671
+ invalid('segment frame outside a unit section');
607
672
  break;
608
673
  case 'SUB_END':
609
- if (!inSubscription)
674
+ if (section !== 'unit')
610
675
  invalid('SUB_END frame without an open SUB_START');
611
- inSubscription = false;
676
+ section = 'none';
677
+ break;
678
+ case 'LIVE_START':
679
+ if (section === 'unit')
680
+ invalid('LIVE_START frame inside a unit section');
681
+ if (sawLive)
682
+ invalid('second LIVE_START frame');
683
+ section = 'live';
684
+ sawLive = true;
685
+ sawBody = true;
686
+ lastPushResult = undefined;
687
+ break;
688
+ case 'COMMIT':
689
+ if (section !== 'live')
690
+ invalid('COMMIT frame outside the live section');
691
+ break;
692
+ case 'LIVE_END':
693
+ if (section !== 'live')
694
+ invalid('LIVE_END frame without an open LIVE_START');
695
+ section = 'none';
612
696
  break;
613
697
  case 'ERROR':
614
698
  errorSeen = true;
@@ -621,28 +705,33 @@ function validateResponseSequence(frames) {
621
705
  break;
622
706
  }
623
707
  }
624
- if (inSubscription && !errorSeen) {
625
- invalid('subscription not terminated by SUB_END');
708
+ if (section !== 'none' && !errorSeen) {
709
+ invalid('unit or live section not terminated');
626
710
  }
627
711
  }
628
- export function validateFrameSequence(message) {
629
- if (message.msgKind === 'request')
630
- validateRequestSequence(message.frames);
631
- else
632
- validateResponseSequence(message.frames);
633
- }
634
712
  // ---------------------------------------------------------------------------
635
713
  // Frame payload encoders
636
714
  // ---------------------------------------------------------------------------
637
715
  const OP_BYTES = { upsert: 1, delete: 2 };
638
716
  const PUSH_STATUS_BYTES = { applied: 1, cached: 2, rejected: 3 };
639
717
  const SUB_STATUS_BYTES = { active: 1, revoked: 2, reset: 3 };
718
+ const LIVE_STATUS_BYTES = { active: 1, reset: 3, unregistered: 4 };
640
719
  const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 };
641
720
  function encodeInvalid(message) {
642
721
  throw new Error(message);
643
722
  }
644
- function encodeFrame(frame, wireVersion) {
645
- const w = new ByteWriter();
723
+ function encodeUnknown(frame, w) {
724
+ if (!Number.isInteger(frame.frameType) ||
725
+ frame.frameType < 0 ||
726
+ frame.frameType > 0xff ||
727
+ KNOWN_FRAME_TYPES.has(frame.frameType)) {
728
+ encodeInvalid(`UNKNOWN frame type ${frame.frameType} collides with a defined frame type`);
729
+ }
730
+ w.raw(frame.payload);
731
+ return frame.frameType;
732
+ }
733
+ /** Writes the payload; returns the frame type byte. */
734
+ function encodeRequestFrame(frame, w) {
646
735
  switch (frame.type) {
647
736
  case 'REQ_HEADER': {
648
737
  if (frame.clientId.length === 0) {
@@ -653,15 +742,14 @@ function encodeFrame(frame, wireVersion) {
653
742
  }
654
743
  w.str(frame.clientId);
655
744
  w.i32(frame.schemaVersion);
656
- if (wireVersion >= 2) {
657
- if (frame.logEpoch === '') {
658
- encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
659
- }
660
- w.opt(frame.logEpoch, (v) => w.str(v));
745
+ if (frame.logEpoch === '') {
746
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
661
747
  }
662
- else if (frame.logEpoch !== undefined) {
663
- encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
748
+ w.opt(frame.logEpoch, (v) => w.str(v));
749
+ if (frame.outboxId !== undefined) {
750
+ checkOutboxId(frame.outboxId, encodeInvalid);
664
751
  }
752
+ w.opt(frame.outboxId, (v) => w.str(v));
665
753
  break;
666
754
  }
667
755
  case 'ROUND_FAILURE': {
@@ -676,7 +764,11 @@ function encodeFrame(frame, wireVersion) {
676
764
  if (frame.operations.length === 0) {
677
765
  encodeInvalid('PUSH_COMMIT must carry at least one operation');
678
766
  }
767
+ if (frame.clientSeq < 0) {
768
+ encodeInvalid('PUSH_COMMIT.clientSeq must be >= 0');
769
+ }
679
770
  w.str(frame.clientCommitId);
771
+ w.i64(frame.clientSeq);
680
772
  w.u32(frame.operations.length);
681
773
  for (const operation of frame.operations) {
682
774
  if (operation.op === 'upsert' && operation.payload === undefined) {
@@ -700,35 +792,43 @@ function encodeFrame(frame, wireVersion) {
700
792
  w.i32(frame.limitCommits);
701
793
  w.i32(frame.limitSnapshotRows);
702
794
  w.i32(frame.maxSnapshotPages);
795
+ checkCursor(frame.cursor, 'PULL_HEADER.cursor', encodeInvalid);
796
+ checkDigest(frame.registrationDigest, 'PULL_HEADER.registrationDigest', encodeInvalid);
797
+ checkDigest(frame.echoDigest, 'PULL_HEADER.echoDigest', encodeInvalid);
703
798
  w.u8(frame.accept);
799
+ w.i64(frame.cursor);
800
+ w.bytes(frame.registrationDigest);
801
+ w.bytes(frame.echoDigest);
704
802
  break;
705
803
  }
706
- case 'SUBSCRIPTION': {
804
+ case 'UNIT': {
805
+ writeStringMap(w, frame.scopes);
806
+ break;
807
+ }
808
+ case 'PROGRESS': {
809
+ checkUnitId(frame.id, 'PROGRESS', encodeInvalid);
810
+ checkCursor(frame.cursor, 'PROGRESS.cursor', encodeInvalid);
707
811
  w.str(frame.id);
708
- w.str(frame.table);
709
- writeStringListMap(w, frame.scopes);
710
- w.opt(frame.params, (v) => w.str(v));
711
812
  w.i64(frame.cursor);
712
813
  w.opt(frame.bootstrapState, (v) => w.str(v));
713
814
  break;
714
815
  }
816
+ case 'UNKNOWN':
817
+ return encodeUnknown(frame, w);
818
+ }
819
+ return FrameType[frame.type];
820
+ }
821
+ /** Writes the payload; returns the frame type byte. */
822
+ function encodeResponseFrame(frame, w) {
823
+ switch (frame.type) {
715
824
  case 'RESP_HEADER': {
716
825
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
717
826
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
718
- if (wireVersion >= 2) {
719
- if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
720
- encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
721
- }
722
- if (frame.resetRequired === undefined) {
723
- encodeInvalid('RESP_HEADER.resetRequired is required');
724
- }
725
- w.str(frame.logEpoch);
726
- w.bool(frame.resetRequired);
727
- }
728
- else if (frame.logEpoch !== undefined ||
729
- frame.resetRequired !== undefined) {
730
- encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
827
+ if (frame.logEpoch.length === 0) {
828
+ encodeInvalid('RESP_HEADER.logEpoch must be non-empty');
731
829
  }
830
+ w.str(frame.logEpoch);
831
+ w.bool(frame.resetRequired);
732
832
  break;
733
833
  }
734
834
  case 'LEASE': {
@@ -796,11 +896,29 @@ function encodeFrame(frame, wireVersion) {
796
896
  break;
797
897
  }
798
898
  case 'SUB_START': {
899
+ checkUnitId(frame.id, 'SUB_START', encodeInvalid);
900
+ checkFencedOrder(frame.fenced, encodeInvalid);
799
901
  w.str(frame.id);
800
902
  w.u8(SUB_STATUS_BYTES[frame.status]);
801
903
  w.str(frame.reasonCode);
802
- writeStringListMap(w, frame.effectiveScopes);
803
904
  w.bool(frame.bootstrap);
905
+ w.u32(frame.fenced.length);
906
+ for (const record of frame.fenced) {
907
+ w.str(record.table);
908
+ w.u8(SUB_STATUS_BYTES[record.status]);
909
+ writeStringListMap(w, record.scopes);
910
+ }
911
+ break;
912
+ }
913
+ case 'LIVE_START': {
914
+ w.u8(LIVE_STATUS_BYTES[frame.status]);
915
+ w.str(frame.reasonCode);
916
+ break;
917
+ }
918
+ case 'LIVE_END': {
919
+ checkDigest(frame.echoDigest, 'LIVE_END.echoDigest', encodeInvalid);
920
+ w.i64(frame.throughSeq);
921
+ w.bytes(frame.echoDigest);
804
922
  break;
805
923
  }
806
924
  case 'COMMIT': {
@@ -815,18 +933,16 @@ function encodeFrame(frame, wireVersion) {
815
933
  if (change.tableIndex >= frame.tables.length) {
816
934
  encodeInvalid(`change tableIndex ${change.tableIndex} out of range`);
817
935
  }
818
- if (change.op === 'upsert' &&
819
- (change.rowVersion === undefined || change.row === undefined)) {
820
- encodeInvalid('upsert change requires rowVersion and row');
936
+ if ((change.op === 'upsert') !== (change.row !== undefined)) {
937
+ encodeInvalid('change row must be present iff op is upsert');
821
938
  }
822
- if (change.op === 'delete' &&
823
- (change.rowVersion !== undefined || change.row !== undefined)) {
824
- encodeInvalid('delete change must not carry rowVersion or row');
939
+ if (change.rowVersion < 1) {
940
+ encodeInvalid('change rowVersion must be at least 1');
825
941
  }
826
942
  w.u16(change.tableIndex);
827
943
  w.str(change.rowId);
828
944
  w.u8(OP_BYTES[change.op]);
829
- w.opt(change.rowVersion, (v) => w.i64(v));
945
+ w.i64(change.rowVersion);
830
946
  writeStringMap(w, change.scopes);
831
947
  w.opt(change.row, (v) => w.bytes(v));
832
948
  }
@@ -850,7 +966,8 @@ function encodeFrame(frame, wireVersion) {
850
966
  break;
851
967
  }
852
968
  case 'SEGMENT_INLINE': {
853
- return { frameType: FrameType.SEGMENT_INLINE, payload: frame.payload };
969
+ w.raw(frame.payload);
970
+ break;
854
971
  }
855
972
  case 'SUB_END': {
856
973
  w.i64(frame.nextCursor);
@@ -866,33 +983,29 @@ function encodeFrame(frame, wireVersion) {
866
983
  w.opt(frame.details, (v) => w.str(v));
867
984
  break;
868
985
  }
869
- case 'UNKNOWN': {
870
- if (!Number.isInteger(frame.frameType) ||
871
- frame.frameType < 0 ||
872
- frame.frameType > 0xff ||
873
- KNOWN_FRAME_TYPES.has(frame.frameType)) {
874
- encodeInvalid(`UNKNOWN frame type ${frame.frameType} collides with a defined frame type`);
875
- }
876
- return { frameType: frame.frameType, payload: frame.payload };
877
- }
986
+ case 'UNKNOWN':
987
+ return encodeUnknown(frame, w);
878
988
  }
879
- return { frameType: FrameType[frame.type], payload: w.finish() };
989
+ return FrameType[frame.type];
880
990
  }
881
991
  // ---------------------------------------------------------------------------
882
- // Envelope
992
+ // Envelope. The kind-specific codecs let a peer bundle only the direction it
993
+ // speaks; `encodeMessage` and `decodeMessage` dispatch on the kind.
883
994
  // ---------------------------------------------------------------------------
884
- export function encodeMessage(message) {
885
- if (!isSupportedProtocolWireVersion(message.wireVersion)) {
886
- encodeInvalid(`unsupported wireVersion ${message.wireVersion}`);
995
+ function encodeEnvelope(wireVersion, kindByte, frames, validate, encode) {
996
+ if (!isSupportedProtocolWireVersion(wireVersion)) {
997
+ encodeInvalid(`unsupported wireVersion ${wireVersion}`);
887
998
  }
888
- validateFrameSequence(message);
999
+ validate(frames);
889
1000
  const w = new ByteWriter();
890
1001
  w.raw(MAGIC_BYTES);
891
- w.u16(message.wireVersion);
892
- w.u8(message.msgKind === 'request' ? 0x01 : 0x02);
1002
+ w.u16(wireVersion);
1003
+ w.u8(kindByte);
893
1004
  w.u8(0x00);
894
- for (const frame of message.frames) {
895
- const { frameType, payload } = encodeFrame(frame, message.wireVersion);
1005
+ for (const frame of frames) {
1006
+ const p = new ByteWriter();
1007
+ const frameType = encode(frame, p);
1008
+ const payload = p.finish();
896
1009
  w.u8(frameType);
897
1010
  w.u32(payload.length);
898
1011
  w.raw(payload);
@@ -901,6 +1014,17 @@ export function encodeMessage(message) {
901
1014
  w.u32(0);
902
1015
  return w.finish();
903
1016
  }
1017
+ export function encodeRequest(message) {
1018
+ return encodeEnvelope(message.wireVersion, 0x01, message.frames, validateRequestSequence, encodeRequestFrame);
1019
+ }
1020
+ export function encodeResponse(message) {
1021
+ return encodeEnvelope(message.wireVersion, 0x02, message.frames, validateResponseSequence, encodeResponseFrame);
1022
+ }
1023
+ export function encodeMessage(message) {
1024
+ return message.msgKind === 'request'
1025
+ ? encodeRequest(message)
1026
+ : encodeResponse(message);
1027
+ }
904
1028
  function readFrames(r, decodeFrame_) {
905
1029
  const frames = [];
906
1030
  for (;;) {
@@ -922,7 +1046,7 @@ function readFrames(r, decodeFrame_) {
922
1046
  frames.push(decodeFrame_(frameType, payload));
923
1047
  }
924
1048
  }
925
- export function decodeMessage(bytes) {
1049
+ function decodeEnvelope(bytes) {
926
1050
  const r = new ByteReader(bytes);
927
1051
  if (r.remaining < 8)
928
1052
  invalid('truncated envelope header');
@@ -944,20 +1068,31 @@ export function decodeMessage(bytes) {
944
1068
  const flags = r.u8();
945
1069
  if (flags !== 0)
946
1070
  invalid(`non-zero envelope flags 0x${flags.toString(16)}`);
947
- if (kindByte === 0x01) {
948
- const message = {
949
- wireVersion,
950
- msgKind: 'request',
951
- frames: readFrames(r, (frameType, payload) => decodeRequestFrame(frameType, payload, wireVersion)),
952
- };
953
- validateRequestSequence(message.frames);
954
- return message;
955
- }
956
- const message = {
957
- wireVersion,
958
- msgKind: 'response',
959
- frames: readFrames(r, (frameType, payload) => decodeResponseFrame(frameType, payload, wireVersion)),
960
- };
961
- validateResponseSequence(message.frames);
962
- return message;
1071
+ return { r, wireVersion, request: kindByte === 0x01 };
1072
+ }
1073
+ function requestBody(r, wireVersion) {
1074
+ const frames = readFrames(r, decodeRequestFrame);
1075
+ validateRequestSequence(frames);
1076
+ return { wireVersion, msgKind: 'request', frames };
1077
+ }
1078
+ function responseBody(r, wireVersion) {
1079
+ const frames = readFrames(r, decodeResponseFrame);
1080
+ validateResponseSequence(frames);
1081
+ return { wireVersion, msgKind: 'response', frames };
1082
+ }
1083
+ export function decodeRequest(bytes) {
1084
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1085
+ if (!request)
1086
+ invalid('expected a request message');
1087
+ return requestBody(r, wireVersion);
1088
+ }
1089
+ export function decodeResponse(bytes) {
1090
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1091
+ if (request)
1092
+ invalid('expected a response message');
1093
+ return responseBody(r, wireVersion);
1094
+ }
1095
+ export function decodeMessage(bytes) {
1096
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1097
+ return request ? requestBody(r, wireVersion) : responseBody(r, wireVersion);
963
1098
  }