@syncular/core 0.59.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/src/message.ts CHANGED
@@ -45,6 +45,9 @@ export interface ReqHeaderFrame {
45
45
  schemaVersion: number;
46
46
  /** Partition log identity; absent requests epoch acquisition. */
47
47
  logEpoch?: string;
48
+ /** Identity of the sender's outbox sequence (§6.3.2, §7.1); absent for a
49
+ * sender without an outbox. Non-empty, at most 64 bytes. */
50
+ outboxId?: string;
48
51
  }
49
52
 
50
53
  export interface PushOperation {
@@ -60,6 +63,8 @@ export interface PushOperation {
60
63
  export interface PushCommitFrame {
61
64
  type: 'PUSH_COMMIT';
62
65
  clientCommitId: string;
66
+ /** Outbox sequence number (§6.3.2, §7.1); `0` without an outbox. */
67
+ clientSeq: number;
63
68
  operations: PushOperation[];
64
69
  }
65
70
 
@@ -70,15 +75,26 @@ export interface PullHeaderFrame {
70
75
  maxSnapshotPages: number;
71
76
  /** Bitmask (§4.2): bit 0 inline rows, 1 external rows, 2 sqlite, 3 signed URLs. */
72
77
  accept: number;
78
+ /** The replica cursor (§4.5); `-1` when no unit is live. */
79
+ cursor: number;
80
+ /** §4.1 registration digest: exactly 32 bytes. */
81
+ registrationDigest: Uint8Array;
82
+ /** The last processed `LIVE_END.echoDigest`: empty or 32 bytes (§4.2). */
83
+ echoDigest: Uint8Array;
73
84
  }
74
85
 
75
- export interface SubscriptionFrame {
76
- type: 'SUBSCRIPTION';
86
+ /** §4.3: one unit of a traveling registration list. */
87
+ export interface UnitFrame {
88
+ type: 'UNIT';
89
+ scopes: Record<string, string>;
90
+ }
91
+
92
+ /** §4.3: the progress of one catching-up unit. */
93
+ export interface ProgressFrame {
94
+ type: 'PROGRESS';
95
+ /** The unit id (§4.1): 32 lowercase hex characters. */
77
96
  id: string;
78
- table: string;
79
- scopes: ScopeMap;
80
- /** Host-opaque JSON document, preserved verbatim. */
81
- params?: string;
97
+ /** `-1`, the §4.7 pin while a resume token is held, or the unit cursor. */
82
98
  cursor: number;
83
99
  /** Opaque resume token JSON, round-tripped byte-for-byte (§4.7). */
84
100
  bootstrapState?: string;
@@ -104,7 +120,8 @@ export type RequestFrame =
104
120
  | RoundFailureFrame
105
121
  | PushCommitFrame
106
122
  | PullHeaderFrame
107
- | SubscriptionFrame
123
+ | UnitFrame
124
+ | ProgressFrame
108
125
  | UnknownFrame;
109
126
 
110
127
  export interface RespHeaderFrame {
@@ -173,21 +190,47 @@ export interface PushResultDetailsFrame {
173
190
  entries: Array<{ opIndex: number; details: RejectionDetails }>;
174
191
  }
175
192
 
193
+ /** §4.4: one (unit, table) pair the wildcard fence touched. */
194
+ export interface FencedRecord {
195
+ table: string;
196
+ status: 'active' | 'revoked';
197
+ /** Effective lists of the variables the fence added; empty when revoked. */
198
+ scopes: ScopeMap;
199
+ }
200
+
176
201
  export interface SubStartFrame {
177
202
  type: 'SUB_START';
203
+ /** The unit id (§4.1): 32 lowercase hex characters. */
178
204
  id: string;
179
205
  status: 'active' | 'revoked' | 'reset';
180
206
  reasonCode: string;
181
- effectiveScopes: ScopeMap;
182
207
  bootstrap: boolean;
208
+ /** The unit's echo, in ascending table order (§4.4). */
209
+ fenced: FencedRecord[];
210
+ }
211
+
212
+ export interface LiveStartFrame {
213
+ type: 'LIVE_START';
214
+ status: 'active' | 'reset' | 'unregistered';
215
+ reasonCode: string;
216
+ }
217
+
218
+ export interface LiveEndFrame {
219
+ type: 'LIVE_END';
220
+ /** Highest commitSeq the live section is complete through; `-1` for
221
+ * `reset` and `unregistered` (§4.4). */
222
+ throughSeq: number;
223
+ /** Echo digest of the request: empty or 32 bytes (§4.4). */
224
+ echoDigest: Uint8Array;
183
225
  }
184
226
 
185
227
  export interface CommitChange {
186
228
  tableIndex: number;
187
229
  rowId: string;
188
230
  op: 'upsert' | 'delete';
189
- /** Present iff `op` is `upsert` (§4.5). */
190
- rowVersion?: number;
231
+ /** The row's `server_version` after the write, ≥ 1; a delete's is the
232
+ * version its tombstone records (§2.2, §4.5). */
233
+ rowVersion: number;
191
234
  scopes: Record<string, string>;
192
235
  /** Generated-row-codec bytes; present iff `op` is `upsert` (§4.5). */
193
236
  row?: Uint8Array;
@@ -227,7 +270,7 @@ export interface SegmentInlineFrame {
227
270
  export interface SubEndFrame {
228
271
  type: 'SUB_END';
229
272
  nextCursor: number;
230
- /** Present iff the bootstrap is incomplete (§4.4). */
273
+ /** Present iff the unit's bootstrap is incomplete (§4.4). */
231
274
  bootstrapState?: string;
232
275
  }
233
276
 
@@ -251,6 +294,8 @@ export type ResponseFrame =
251
294
  | SegmentRefFrame
252
295
  | SegmentInlineFrame
253
296
  | SubEndFrame
297
+ | LiveStartFrame
298
+ | LiveEndFrame
254
299
  | ErrorFrame
255
300
  | UnknownFrame;
256
301
 
@@ -315,6 +360,62 @@ function validateRoundFailureCode(
315
360
  }
316
361
  }
317
362
 
363
+ /** §1.5, §1.7: a present `outboxId` is non-empty and at most 64 bytes. */
364
+ function checkOutboxId(outboxId: string, fail: (message: string) => never) {
365
+ if (outboxId.length === 0 || utf8Encode(outboxId).length > 64) {
366
+ fail('REQ_HEADER.outboxId must be non-empty and at most 64 bytes');
367
+ }
368
+ }
369
+
370
+ /** §4.3, §4.4: a unit id is 32 lowercase hex characters. */
371
+ function checkUnitId(
372
+ id: string,
373
+ frame: 'PROGRESS' | 'SUB_START',
374
+ fail: (message: string) => never,
375
+ ) {
376
+ if (!/^[0-9a-f]{32}$/.test(id)) {
377
+ fail(`${frame}.id must be 32 lowercase hex characters`);
378
+ }
379
+ }
380
+
381
+ /** §4.2, §4.4: a digest is 32 bytes; an echo digest may also be empty. */
382
+ function checkDigest(
383
+ digest: Uint8Array,
384
+ field:
385
+ | 'PULL_HEADER.registrationDigest'
386
+ | 'PULL_HEADER.echoDigest'
387
+ | 'LIVE_END.echoDigest',
388
+ fail: (message: string) => never,
389
+ ) {
390
+ const emptyAllowed = field !== 'PULL_HEADER.registrationDigest';
391
+ if (digest.length !== 32 && !(emptyAllowed && digest.length === 0)) {
392
+ fail(`${field} must be ${emptyAllowed ? '0 or ' : ''}32 bytes`);
393
+ }
394
+ }
395
+
396
+ /** §4.2, §4.3: a cursor is `-1` or a commitSeq. */
397
+ function checkCursor(
398
+ cursor: number,
399
+ field: 'PULL_HEADER.cursor' | 'PROGRESS.cursor',
400
+ fail: (message: string) => never,
401
+ ) {
402
+ if (cursor < -1) fail(`${field} must be >= -1`);
403
+ }
404
+
405
+ /** §4.4: fenced records in strictly ascending code-unit order of `table`. */
406
+ function checkFencedOrder(
407
+ fenced: readonly FencedRecord[],
408
+ fail: (message: string) => never,
409
+ ) {
410
+ for (let i = 1; i < fenced.length; i++) {
411
+ if (
412
+ (fenced[i - 1] as FencedRecord).table >= (fenced[i] as FencedRecord).table
413
+ ) {
414
+ fail('SUB_START.fenced tables must be unique and in ascending order');
415
+ }
416
+ }
417
+ }
418
+
318
419
  // ---------------------------------------------------------------------------
319
420
  // Frame payload decoders (each consumes exactly the frame payload)
320
421
  // ---------------------------------------------------------------------------
@@ -329,11 +430,14 @@ function decodeReqHeader(r: ByteReader): ReqHeaderFrame {
329
430
  if (value.length === 0) invalid('REQ_HEADER.logEpoch must be non-empty');
330
431
  return value;
331
432
  });
433
+ const outboxId = r.opt(() => r.str());
434
+ if (outboxId !== undefined) checkOutboxId(outboxId, invalid);
332
435
  return definedObject({
333
436
  type: 'REQ_HEADER',
334
437
  clientId,
335
438
  schemaVersion,
336
439
  logEpoch: logEpoch,
440
+ outboxId: outboxId,
337
441
  });
338
442
  }
339
443
 
@@ -348,6 +452,8 @@ function decodePushCommit(r: ByteReader): PushCommitFrame {
348
452
  if (clientCommitId.length === 0) {
349
453
  invalid('PUSH_COMMIT.clientCommitId must be non-empty');
350
454
  }
455
+ const clientSeq = r.i64();
456
+ if (clientSeq < 0) invalid('PUSH_COMMIT.clientSeq must be >= 0');
351
457
  const operationCount = r.u32();
352
458
  if (operationCount === 0) {
353
459
  throw new DecodeError(
@@ -382,7 +488,7 @@ function decodePushCommit(r: ByteReader): PushCommitFrame {
382
488
  }),
383
489
  );
384
490
  }
385
- return { type: 'PUSH_COMMIT', clientCommitId, operations };
491
+ return { type: 'PUSH_COMMIT', clientCommitId, clientSeq, operations };
386
492
  }
387
493
 
388
494
  function decodePullHeader(r: ByteReader): PullHeaderFrame {
@@ -393,30 +499,33 @@ function decodePullHeader(r: ByteReader): PullHeaderFrame {
393
499
  if ((accept & 0xf0) !== 0) {
394
500
  invalid('PULL_HEADER.accept bits 4-7 must be zero');
395
501
  }
502
+ const cursor = r.i64();
503
+ checkCursor(cursor, 'PULL_HEADER.cursor', invalid);
504
+ const registrationDigest = r.bytes();
505
+ checkDigest(registrationDigest, 'PULL_HEADER.registrationDigest', invalid);
506
+ const echoDigest = r.bytes();
507
+ checkDigest(echoDigest, 'PULL_HEADER.echoDigest', invalid);
396
508
  return {
397
509
  type: 'PULL_HEADER',
398
510
  limitCommits,
399
511
  limitSnapshotRows,
400
512
  maxSnapshotPages,
401
513
  accept,
514
+ cursor,
515
+ registrationDigest,
516
+ echoDigest,
402
517
  };
403
518
  }
404
519
 
405
- function decodeSubscription(r: ByteReader): SubscriptionFrame {
520
+ function decodeProgress(r: ByteReader): ProgressFrame {
406
521
  const id = r.str();
407
- const table = r.str();
408
- const scopes = readStringListMap(r);
409
- const params = r.opt(() => readJson(r, 'SUBSCRIPTION.params'));
522
+ checkUnitId(id, 'PROGRESS', invalid);
410
523
  const cursor = r.i64();
411
- const bootstrapState = r.opt(() =>
412
- readJson(r, 'SUBSCRIPTION.bootstrapState'),
413
- );
524
+ checkCursor(cursor, 'PROGRESS.cursor', invalid);
525
+ const bootstrapState = r.opt(() => readJson(r, 'PROGRESS.bootstrapState'));
414
526
  return definedObject({
415
- type: 'SUBSCRIPTION',
527
+ type: 'PROGRESS',
416
528
  id,
417
- table,
418
- scopes,
419
- params: params,
420
529
  cursor,
421
530
  bootstrapState: bootstrapState,
422
531
  });
@@ -532,6 +641,7 @@ function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
532
641
 
533
642
  function decodeSubStart(r: ByteReader): SubStartFrame {
534
643
  const id = r.str();
644
+ checkUnitId(id, 'SUB_START', invalid);
535
645
  const statusByte = r.u8();
536
646
  let status: 'active' | 'revoked' | 'reset';
537
647
  if (statusByte === 1) status = 'active';
@@ -539,16 +649,40 @@ function decodeSubStart(r: ByteReader): SubStartFrame {
539
649
  else if (statusByte === 3) status = 'reset';
540
650
  else invalid(`invalid SUB_START status byte ${statusByte}`);
541
651
  const reasonCode = r.str();
542
- const effectiveScopes = readStringListMap(r);
543
652
  const bootstrap = r.bool();
544
- return {
545
- type: 'SUB_START',
546
- id,
547
- status,
548
- reasonCode,
549
- effectiveScopes,
550
- bootstrap,
551
- };
653
+ const fencedCount = r.u32();
654
+ const fenced: FencedRecord[] = [];
655
+ for (let i = 0; i < fencedCount; i++) {
656
+ const table = r.str();
657
+ const fencedStatus = r.u8();
658
+ if (fencedStatus !== 1 && fencedStatus !== 2) {
659
+ invalid('invalid fenced record status byte');
660
+ }
661
+ fenced.push({
662
+ table,
663
+ status: fencedStatus === 1 ? 'active' : 'revoked',
664
+ scopes: readStringListMap(r),
665
+ });
666
+ }
667
+ checkFencedOrder(fenced, invalid);
668
+ return { type: 'SUB_START', id, status, reasonCode, bootstrap, fenced };
669
+ }
670
+
671
+ function decodeLiveStart(r: ByteReader): LiveStartFrame {
672
+ const statusByte = r.u8();
673
+ let status: LiveStartFrame['status'];
674
+ if (statusByte === 1) status = 'active';
675
+ else if (statusByte === 3) status = 'reset';
676
+ else if (statusByte === 4) status = 'unregistered';
677
+ else invalid('invalid LIVE_START status byte');
678
+ return { type: 'LIVE_START', status, reasonCode: r.str() };
679
+ }
680
+
681
+ function decodeLiveEnd(r: ByteReader): LiveEndFrame {
682
+ const throughSeq = r.i64();
683
+ const echoDigest = r.bytes();
684
+ checkDigest(echoDigest, 'LIVE_END.echoDigest', invalid);
685
+ return { type: 'LIVE_END', throughSeq, echoDigest };
552
686
  }
553
687
 
554
688
  function decodeCommit(r: ByteReader): CommitFrame {
@@ -573,24 +707,15 @@ function decodeCommit(r: ByteReader): CommitFrame {
573
707
  if (opByte === 1) op = 'upsert';
574
708
  else if (opByte === 2) op = 'delete';
575
709
  else invalid(`invalid change op byte ${opByte}`);
576
- const rowVersion = r.opt(() => r.i64());
710
+ const rowVersion = r.i64();
711
+ if (rowVersion < 1) invalid('change rowVersion must be at least 1');
577
712
  const scopes = readStringMap(r);
578
713
  const row = r.opt(() => r.bytes());
579
- if (op === 'upsert' && (rowVersion === undefined || row === undefined)) {
580
- invalid('upsert change requires rowVersion and row');
581
- }
582
- if (op === 'delete' && (rowVersion !== undefined || row !== undefined)) {
583
- invalid('delete change must not carry rowVersion or row');
714
+ if ((op === 'upsert') !== (row !== undefined)) {
715
+ invalid('change row must be present iff op is upsert');
584
716
  }
585
717
  changes.push(
586
- definedObject({
587
- tableIndex,
588
- rowId,
589
- op,
590
- rowVersion: rowVersion,
591
- scopes,
592
- row: row,
593
- }),
718
+ definedObject({ tableIndex, rowId, op, rowVersion, scopes, row }),
594
719
  );
595
720
  }
596
721
  return { type: 'COMMIT', commitSeq, createdAtMs, actorId, tables, changes };
@@ -686,8 +811,11 @@ function decodeRequestFrame(
686
811
  case FrameType.PULL_HEADER:
687
812
  frame = decodePullHeader(r);
688
813
  break;
689
- case FrameType.SUBSCRIPTION:
690
- frame = decodeSubscription(r);
814
+ case FrameType.UNIT:
815
+ frame = { type: 'UNIT', scopes: readStringMap(r) };
816
+ break;
817
+ case FrameType.PROGRESS:
818
+ frame = decodeProgress(r);
691
819
  break;
692
820
  default:
693
821
  if (RESPONSE_FRAME_TYPES.has(frameType)) {
@@ -735,6 +863,12 @@ function decodeResponseFrame(
735
863
  case FrameType.SUB_END:
736
864
  frame = decodeSubEnd(r);
737
865
  break;
866
+ case FrameType.LIVE_START:
867
+ frame = decodeLiveStart(r);
868
+ break;
869
+ case FrameType.LIVE_END:
870
+ frame = decodeLiveEnd(r);
871
+ break;
738
872
  case FrameType.ERROR:
739
873
  frame = decodeErrorFrame(r);
740
874
  break;
@@ -761,6 +895,7 @@ function validateRequestSequence(frames: readonly RequestFrame[]): void {
761
895
  }
762
896
  let pushCount = 0;
763
897
  let hasPull = false;
898
+ let hasProgress = false;
764
899
  let index = 1;
765
900
  let sawRoundFailure = false;
766
901
  for (const frame of frames.slice(1)) {
@@ -784,8 +919,13 @@ function validateRequestSequence(frames: readonly RequestFrame[]): void {
784
919
  if (hasPull) invalid('duplicate PULL_HEADER frame');
785
920
  hasPull = true;
786
921
  break;
787
- case 'SUBSCRIPTION':
788
- if (!hasPull) invalid('SUBSCRIPTION frame without a PULL_HEADER');
922
+ case 'UNIT':
923
+ if (!hasPull) invalid('UNIT frame without a PULL_HEADER');
924
+ if (hasProgress) invalid('UNIT frame after a PROGRESS frame');
925
+ break;
926
+ case 'PROGRESS':
927
+ if (!hasPull) invalid('PROGRESS frame without a PULL_HEADER');
928
+ hasProgress = true;
789
929
  break;
790
930
  case 'UNKNOWN':
791
931
  break;
@@ -802,12 +942,12 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
802
942
  if (first === undefined || first.type !== 'RESP_HEADER') {
803
943
  invalid('response must start with a RESP_HEADER frame');
804
944
  }
805
- let inSubscription = false;
806
- let sawSubscription = false;
945
+ // §1.6 sections: unit sections, then at most one live section.
946
+ let section: 'none' | 'unit' | 'live' = 'none';
947
+ let sawUnitSection = false;
948
+ let sawLive = false;
807
949
  let sawLease = false;
808
950
  let sawBody = false;
809
- let subscriptionHasCommits = false;
810
- let subscriptionHasSegments = false;
811
951
  let errorSeen = false;
812
952
  let lastPushResult: PushResultFrame | undefined;
813
953
  let lastPushResultHasDetails = false;
@@ -818,21 +958,22 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
818
958
  invalid('duplicate RESP_HEADER frame');
819
959
  break;
820
960
  case 'LEASE':
821
- // §7.3.2: at most one LEASE, immediately after RESP_HEADER (before
822
- // any PUSH_RESULT / subscription section / ERROR).
961
+ // §7.3.2: at most one LEASE, immediately after RESP_HEADER.
823
962
  if (sawLease) invalid('duplicate LEASE frame');
824
963
  if (sawBody) invalid('LEASE frame must immediately follow RESP_HEADER');
825
964
  sawLease = true;
826
965
  break;
827
966
  case 'PUSH_RESULT':
828
- if (sawSubscription) invalid('PUSH_RESULT frame after SUB_START');
967
+ if (sawUnitSection || sawLive) {
968
+ invalid('PUSH_RESULT frame after a unit or live section');
969
+ }
829
970
  lastPushResult = frame;
830
971
  lastPushResultHasDetails = false;
831
972
  sawBody = true;
832
973
  break;
833
974
  case 'PUSH_RESULT_DETAILS': {
834
- if (sawSubscription) {
835
- invalid('PUSH_RESULT_DETAILS frame after SUB_START');
975
+ if (sawUnitSection || sawLive) {
976
+ invalid('PUSH_RESULT_DETAILS frame after a unit or live section');
836
977
  }
837
978
  if (lastPushResult === undefined) {
838
979
  invalid('PUSH_RESULT_DETAILS without a preceding PUSH_RESULT');
@@ -861,36 +1002,40 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
861
1002
  break;
862
1003
  }
863
1004
  case 'SUB_START':
864
- if (inSubscription) invalid('nested SUB_START frame');
865
- inSubscription = true;
866
- sawSubscription = true;
1005
+ if (section === 'unit') invalid('nested SUB_START frame');
1006
+ if (sawLive)
1007
+ invalid('SUB_START frame inside or after the live section');
1008
+ section = 'unit';
1009
+ sawUnitSection = true;
867
1010
  sawBody = true;
868
1011
  lastPushResult = undefined;
869
- subscriptionHasCommits = false;
870
- subscriptionHasSegments = false;
871
- break;
872
- case 'COMMIT':
873
- if (!inSubscription) invalid('COMMIT frame outside a subscription');
874
- if (subscriptionHasSegments) {
875
- invalid(
876
- 'COMMIT and segment frames must not both appear for one subscription',
877
- );
878
- }
879
- subscriptionHasCommits = true;
880
1012
  break;
881
1013
  case 'SEGMENT_REF':
882
1014
  case 'SEGMENT_INLINE':
883
- if (!inSubscription) invalid('segment frame outside a subscription');
884
- if (subscriptionHasCommits) {
885
- invalid(
886
- 'COMMIT and segment frames must not both appear for one subscription',
887
- );
888
- }
889
- subscriptionHasSegments = true;
1015
+ if (section !== 'unit') invalid('segment frame outside a unit section');
890
1016
  break;
891
1017
  case 'SUB_END':
892
- if (!inSubscription) invalid('SUB_END frame without an open SUB_START');
893
- inSubscription = false;
1018
+ if (section !== 'unit')
1019
+ invalid('SUB_END frame without an open SUB_START');
1020
+ section = 'none';
1021
+ break;
1022
+ case 'LIVE_START':
1023
+ if (section === 'unit')
1024
+ invalid('LIVE_START frame inside a unit section');
1025
+ if (sawLive) invalid('second LIVE_START frame');
1026
+ section = 'live';
1027
+ sawLive = true;
1028
+ sawBody = true;
1029
+ lastPushResult = undefined;
1030
+ break;
1031
+ case 'COMMIT':
1032
+ if (section !== 'live')
1033
+ invalid('COMMIT frame outside the live section');
1034
+ break;
1035
+ case 'LIVE_END':
1036
+ if (section !== 'live')
1037
+ invalid('LIVE_END frame without an open LIVE_START');
1038
+ section = 'none';
894
1039
  break;
895
1040
  case 'ERROR':
896
1041
  errorSeen = true;
@@ -903,16 +1048,11 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
903
1048
  break;
904
1049
  }
905
1050
  }
906
- if (inSubscription && !errorSeen) {
907
- invalid('subscription not terminated by SUB_END');
1051
+ if (section !== 'none' && !errorSeen) {
1052
+ invalid('unit or live section not terminated');
908
1053
  }
909
1054
  }
910
1055
 
911
- export function validateFrameSequence(message: SyncMessage): void {
912
- if (message.msgKind === 'request') validateRequestSequence(message.frames);
913
- else validateResponseSequence(message.frames);
914
- }
915
-
916
1056
  // ---------------------------------------------------------------------------
917
1057
  // Frame payload encoders
918
1058
  // ---------------------------------------------------------------------------
@@ -920,17 +1060,30 @@ export function validateFrameSequence(message: SyncMessage): void {
920
1060
  const OP_BYTES = { upsert: 1, delete: 2 } as const;
921
1061
  const PUSH_STATUS_BYTES = { applied: 1, cached: 2, rejected: 3 } as const;
922
1062
  const SUB_STATUS_BYTES = { active: 1, revoked: 2, reset: 3 } as const;
1063
+ const LIVE_STATUS_BYTES = { active: 1, reset: 3, unregistered: 4 } as const;
923
1064
  const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 } as const;
924
1065
 
925
1066
  function encodeInvalid(message: string): never {
926
1067
  throw new Error(message);
927
1068
  }
928
1069
 
929
- function encodeFrame(frame: RequestFrame | ResponseFrame): {
930
- frameType: number;
931
- payload: Uint8Array;
932
- } {
933
- const w = new ByteWriter();
1070
+ function encodeUnknown(frame: UnknownFrame, w: ByteWriter): number {
1071
+ if (
1072
+ !Number.isInteger(frame.frameType) ||
1073
+ frame.frameType < 0 ||
1074
+ frame.frameType > 0xff ||
1075
+ KNOWN_FRAME_TYPES.has(frame.frameType)
1076
+ ) {
1077
+ encodeInvalid(
1078
+ `UNKNOWN frame type ${frame.frameType} collides with a defined frame type`,
1079
+ );
1080
+ }
1081
+ w.raw(frame.payload);
1082
+ return frame.frameType;
1083
+ }
1084
+
1085
+ /** Writes the payload; returns the frame type byte. */
1086
+ function encodeRequestFrame(frame: RequestFrame, w: ByteWriter): number {
934
1087
  switch (frame.type) {
935
1088
  case 'REQ_HEADER': {
936
1089
  if (frame.clientId.length === 0) {
@@ -945,6 +1098,10 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
945
1098
  encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
946
1099
  }
947
1100
  w.opt(frame.logEpoch, (v) => w.str(v));
1101
+ if (frame.outboxId !== undefined) {
1102
+ checkOutboxId(frame.outboxId, encodeInvalid);
1103
+ }
1104
+ w.opt(frame.outboxId, (v) => w.str(v));
948
1105
  break;
949
1106
  }
950
1107
  case 'ROUND_FAILURE': {
@@ -959,7 +1116,11 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
959
1116
  if (frame.operations.length === 0) {
960
1117
  encodeInvalid('PUSH_COMMIT must carry at least one operation');
961
1118
  }
1119
+ if (frame.clientSeq < 0) {
1120
+ encodeInvalid('PUSH_COMMIT.clientSeq must be >= 0');
1121
+ }
962
1122
  w.str(frame.clientCommitId);
1123
+ w.i64(frame.clientSeq);
963
1124
  w.u32(frame.operations.length);
964
1125
  for (const operation of frame.operations) {
965
1126
  if (operation.op === 'upsert' && operation.payload === undefined) {
@@ -983,18 +1144,40 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
983
1144
  w.i32(frame.limitCommits);
984
1145
  w.i32(frame.limitSnapshotRows);
985
1146
  w.i32(frame.maxSnapshotPages);
1147
+ checkCursor(frame.cursor, 'PULL_HEADER.cursor', encodeInvalid);
1148
+ checkDigest(
1149
+ frame.registrationDigest,
1150
+ 'PULL_HEADER.registrationDigest',
1151
+ encodeInvalid,
1152
+ );
1153
+ checkDigest(frame.echoDigest, 'PULL_HEADER.echoDigest', encodeInvalid);
986
1154
  w.u8(frame.accept);
1155
+ w.i64(frame.cursor);
1156
+ w.bytes(frame.registrationDigest);
1157
+ w.bytes(frame.echoDigest);
987
1158
  break;
988
1159
  }
989
- case 'SUBSCRIPTION': {
1160
+ case 'UNIT': {
1161
+ writeStringMap(w, frame.scopes);
1162
+ break;
1163
+ }
1164
+ case 'PROGRESS': {
1165
+ checkUnitId(frame.id, 'PROGRESS', encodeInvalid);
1166
+ checkCursor(frame.cursor, 'PROGRESS.cursor', encodeInvalid);
990
1167
  w.str(frame.id);
991
- w.str(frame.table);
992
- writeStringListMap(w, frame.scopes);
993
- w.opt(frame.params, (v) => w.str(v));
994
1168
  w.i64(frame.cursor);
995
1169
  w.opt(frame.bootstrapState, (v) => w.str(v));
996
1170
  break;
997
1171
  }
1172
+ case 'UNKNOWN':
1173
+ return encodeUnknown(frame, w);
1174
+ }
1175
+ return FrameType[frame.type];
1176
+ }
1177
+
1178
+ /** Writes the payload; returns the frame type byte. */
1179
+ function encodeResponseFrame(frame: ResponseFrame, w: ByteWriter): number {
1180
+ switch (frame.type) {
998
1181
  case 'RESP_HEADER': {
999
1182
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
1000
1183
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
@@ -1068,11 +1251,29 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
1068
1251
  break;
1069
1252
  }
1070
1253
  case 'SUB_START': {
1254
+ checkUnitId(frame.id, 'SUB_START', encodeInvalid);
1255
+ checkFencedOrder(frame.fenced, encodeInvalid);
1071
1256
  w.str(frame.id);
1072
1257
  w.u8(SUB_STATUS_BYTES[frame.status]);
1073
1258
  w.str(frame.reasonCode);
1074
- writeStringListMap(w, frame.effectiveScopes);
1075
1259
  w.bool(frame.bootstrap);
1260
+ w.u32(frame.fenced.length);
1261
+ for (const record of frame.fenced) {
1262
+ w.str(record.table);
1263
+ w.u8(SUB_STATUS_BYTES[record.status]);
1264
+ writeStringListMap(w, record.scopes);
1265
+ }
1266
+ break;
1267
+ }
1268
+ case 'LIVE_START': {
1269
+ w.u8(LIVE_STATUS_BYTES[frame.status]);
1270
+ w.str(frame.reasonCode);
1271
+ break;
1272
+ }
1273
+ case 'LIVE_END': {
1274
+ checkDigest(frame.echoDigest, 'LIVE_END.echoDigest', encodeInvalid);
1275
+ w.i64(frame.throughSeq);
1276
+ w.bytes(frame.echoDigest);
1076
1277
  break;
1077
1278
  }
1078
1279
  case 'COMMIT': {
@@ -1086,22 +1287,16 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
1086
1287
  if (change.tableIndex >= frame.tables.length) {
1087
1288
  encodeInvalid(`change tableIndex ${change.tableIndex} out of range`);
1088
1289
  }
1089
- if (
1090
- change.op === 'upsert' &&
1091
- (change.rowVersion === undefined || change.row === undefined)
1092
- ) {
1093
- encodeInvalid('upsert change requires rowVersion and row');
1290
+ if ((change.op === 'upsert') !== (change.row !== undefined)) {
1291
+ encodeInvalid('change row must be present iff op is upsert');
1094
1292
  }
1095
- if (
1096
- change.op === 'delete' &&
1097
- (change.rowVersion !== undefined || change.row !== undefined)
1098
- ) {
1099
- encodeInvalid('delete change must not carry rowVersion or row');
1293
+ if (change.rowVersion < 1) {
1294
+ encodeInvalid('change rowVersion must be at least 1');
1100
1295
  }
1101
1296
  w.u16(change.tableIndex);
1102
1297
  w.str(change.rowId);
1103
1298
  w.u8(OP_BYTES[change.op]);
1104
- w.opt(change.rowVersion, (v) => w.i64(v));
1299
+ w.i64(change.rowVersion);
1105
1300
  writeStringMap(w, change.scopes);
1106
1301
  w.opt(change.row, (v) => w.bytes(v));
1107
1302
  }
@@ -1125,7 +1320,8 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
1125
1320
  break;
1126
1321
  }
1127
1322
  case 'SEGMENT_INLINE': {
1128
- return { frameType: FrameType.SEGMENT_INLINE, payload: frame.payload };
1323
+ w.raw(frame.payload);
1324
+ break;
1129
1325
  }
1130
1326
  case 'SUB_END': {
1131
1327
  w.i64(frame.nextCursor);
@@ -1141,39 +1337,37 @@ function encodeFrame(frame: RequestFrame | ResponseFrame): {
1141
1337
  w.opt(frame.details, (v) => w.str(v));
1142
1338
  break;
1143
1339
  }
1144
- case 'UNKNOWN': {
1145
- if (
1146
- !Number.isInteger(frame.frameType) ||
1147
- frame.frameType < 0 ||
1148
- frame.frameType > 0xff ||
1149
- KNOWN_FRAME_TYPES.has(frame.frameType)
1150
- ) {
1151
- encodeInvalid(
1152
- `UNKNOWN frame type ${frame.frameType} collides with a defined frame type`,
1153
- );
1154
- }
1155
- return { frameType: frame.frameType, payload: frame.payload };
1156
- }
1340
+ case 'UNKNOWN':
1341
+ return encodeUnknown(frame, w);
1157
1342
  }
1158
- return { frameType: FrameType[frame.type], payload: w.finish() };
1343
+ return FrameType[frame.type];
1159
1344
  }
1160
1345
 
1161
1346
  // ---------------------------------------------------------------------------
1162
- // Envelope
1347
+ // Envelope. The kind-specific codecs let a peer bundle only the direction it
1348
+ // speaks; `encodeMessage` and `decodeMessage` dispatch on the kind.
1163
1349
  // ---------------------------------------------------------------------------
1164
1350
 
1165
- export function encodeMessage(message: SyncMessage): Uint8Array {
1166
- if (!isSupportedProtocolWireVersion(message.wireVersion)) {
1167
- encodeInvalid(`unsupported wireVersion ${message.wireVersion}`);
1351
+ function encodeEnvelope<F extends RequestFrame | ResponseFrame>(
1352
+ wireVersion: number,
1353
+ kindByte: number,
1354
+ frames: readonly F[],
1355
+ validate: (frames: readonly F[]) => void,
1356
+ encode: (frame: F, w: ByteWriter) => number,
1357
+ ): Uint8Array {
1358
+ if (!isSupportedProtocolWireVersion(wireVersion)) {
1359
+ encodeInvalid(`unsupported wireVersion ${wireVersion}`);
1168
1360
  }
1169
- validateFrameSequence(message);
1361
+ validate(frames);
1170
1362
  const w = new ByteWriter();
1171
1363
  w.raw(MAGIC_BYTES);
1172
- w.u16(message.wireVersion);
1173
- w.u8(message.msgKind === 'request' ? 0x01 : 0x02);
1364
+ w.u16(wireVersion);
1365
+ w.u8(kindByte);
1174
1366
  w.u8(0x00);
1175
- for (const frame of message.frames) {
1176
- const { frameType, payload } = encodeFrame(frame);
1367
+ for (const frame of frames) {
1368
+ const p = new ByteWriter();
1369
+ const frameType = encode(frame, p);
1370
+ const payload = p.finish();
1177
1371
  w.u8(frameType);
1178
1372
  w.u32(payload.length);
1179
1373
  w.raw(payload);
@@ -1183,6 +1377,32 @@ export function encodeMessage(message: SyncMessage): Uint8Array {
1183
1377
  return w.finish();
1184
1378
  }
1185
1379
 
1380
+ export function encodeRequest(message: RequestMessage): Uint8Array {
1381
+ return encodeEnvelope(
1382
+ message.wireVersion,
1383
+ 0x01,
1384
+ message.frames,
1385
+ validateRequestSequence,
1386
+ encodeRequestFrame,
1387
+ );
1388
+ }
1389
+
1390
+ export function encodeResponse(message: ResponseMessage): Uint8Array {
1391
+ return encodeEnvelope(
1392
+ message.wireVersion,
1393
+ 0x02,
1394
+ message.frames,
1395
+ validateResponseSequence,
1396
+ encodeResponseFrame,
1397
+ );
1398
+ }
1399
+
1400
+ export function encodeMessage(message: SyncMessage): Uint8Array {
1401
+ return message.msgKind === 'request'
1402
+ ? encodeRequest(message)
1403
+ : encodeResponse(message);
1404
+ }
1405
+
1186
1406
  function readFrames<T>(
1187
1407
  r: ByteReader,
1188
1408
  decodeFrame_: (frameType: number, payload: Uint8Array) => T,
@@ -1209,7 +1429,11 @@ function readFrames<T>(
1209
1429
  }
1210
1430
  }
1211
1431
 
1212
- export function decodeMessage(bytes: Uint8Array): SyncMessage {
1432
+ function decodeEnvelope(bytes: Uint8Array): {
1433
+ r: ByteReader;
1434
+ wireVersion: number;
1435
+ request: boolean;
1436
+ } {
1213
1437
  const r = new ByteReader(bytes);
1214
1438
  if (r.remaining < 8) invalid('truncated envelope header');
1215
1439
  const magic = r.raw(4);
@@ -1231,24 +1455,34 @@ export function decodeMessage(bytes: Uint8Array): SyncMessage {
1231
1455
  }
1232
1456
  const flags = r.u8();
1233
1457
  if (flags !== 0) invalid(`non-zero envelope flags 0x${flags.toString(16)}`);
1234
- if (kindByte === 0x01) {
1235
- const message: RequestMessage = {
1236
- wireVersion,
1237
- msgKind: 'request',
1238
- frames: readFrames(r, (frameType, payload) =>
1239
- decodeRequestFrame(frameType, payload),
1240
- ),
1241
- };
1242
- validateRequestSequence(message.frames);
1243
- return message;
1244
- }
1245
- const message: ResponseMessage = {
1246
- wireVersion,
1247
- msgKind: 'response',
1248
- frames: readFrames(r, (frameType, payload) =>
1249
- decodeResponseFrame(frameType, payload),
1250
- ),
1251
- };
1252
- validateResponseSequence(message.frames);
1253
- return message;
1458
+ return { r, wireVersion, request: kindByte === 0x01 };
1459
+ }
1460
+
1461
+ function requestBody(r: ByteReader, wireVersion: number): RequestMessage {
1462
+ const frames = readFrames(r, decodeRequestFrame);
1463
+ validateRequestSequence(frames);
1464
+ return { wireVersion, msgKind: 'request', frames };
1465
+ }
1466
+
1467
+ function responseBody(r: ByteReader, wireVersion: number): ResponseMessage {
1468
+ const frames = readFrames(r, decodeResponseFrame);
1469
+ validateResponseSequence(frames);
1470
+ return { wireVersion, msgKind: 'response', frames };
1471
+ }
1472
+
1473
+ export function decodeRequest(bytes: Uint8Array): RequestMessage {
1474
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1475
+ if (!request) invalid('expected a request message');
1476
+ return requestBody(r, wireVersion);
1477
+ }
1478
+
1479
+ export function decodeResponse(bytes: Uint8Array): ResponseMessage {
1480
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1481
+ if (request) invalid('expected a response message');
1482
+ return responseBody(r, wireVersion);
1483
+ }
1484
+
1485
+ export function decodeMessage(bytes: Uint8Array): SyncMessage {
1486
+ const { r, wireVersion, request } = decodeEnvelope(bytes);
1487
+ return request ? requestBody(r, wireVersion) : responseBody(r, wireVersion);
1254
1488
  }