@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/src/message.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from './bytes';
23
23
  import { isSupportedProtocolWireVersion, SYNC_PACK_MAGIC } from './constants';
24
24
  import { DecodeError } from './errors';
25
+ import { parseJsonDocument } from './json-document';
25
26
  import {
26
27
  FrameType,
27
28
  KNOWN_FRAME_TYPES,
@@ -42,8 +43,11 @@ export interface ReqHeaderFrame {
42
43
  type: 'REQ_HEADER';
43
44
  clientId: string;
44
45
  schemaVersion: number;
45
- /** Wire v2 partition log identity; absent requests epoch acquisition. */
46
+ /** Partition log identity; absent requests epoch acquisition. */
46
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;
47
51
  }
48
52
 
49
53
  export interface PushOperation {
@@ -59,6 +63,8 @@ export interface PushOperation {
59
63
  export interface PushCommitFrame {
60
64
  type: 'PUSH_COMMIT';
61
65
  clientCommitId: string;
66
+ /** Outbox sequence number (§6.3.2, §7.1); `0` without an outbox. */
67
+ clientSeq: number;
62
68
  operations: PushOperation[];
63
69
  }
64
70
 
@@ -69,15 +75,26 @@ export interface PullHeaderFrame {
69
75
  maxSnapshotPages: number;
70
76
  /** Bitmask (§4.2): bit 0 inline rows, 1 external rows, 2 sqlite, 3 signed URLs. */
71
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;
72
84
  }
73
85
 
74
- export interface SubscriptionFrame {
75
- 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. */
76
96
  id: string;
77
- table: string;
78
- scopes: ScopeMap;
79
- /** Host-opaque JSON document, preserved verbatim. */
80
- params?: string;
97
+ /** `-1`, the §4.7 pin while a resume token is held, or the unit cursor. */
81
98
  cursor: number;
82
99
  /** Opaque resume token JSON, round-tripped byte-for-byte (§4.7). */
83
100
  bootstrapState?: string;
@@ -103,17 +120,18 @@ export type RequestFrame =
103
120
  | RoundFailureFrame
104
121
  | PushCommitFrame
105
122
  | PullHeaderFrame
106
- | SubscriptionFrame
123
+ | UnitFrame
124
+ | ProgressFrame
107
125
  | UnknownFrame;
108
126
 
109
127
  export interface RespHeaderFrame {
110
128
  type: 'RESP_HEADER';
111
129
  requiredSchemaVersion?: number;
112
130
  latestSchemaVersion?: number;
113
- /** Required and non-empty on wire v2. */
114
- logEpoch?: string;
115
- /** Required on wire v2. True produces a header-only reset response. */
116
- resetRequired?: boolean;
131
+ /** Partition log identity, non-empty. */
132
+ logEpoch: string;
133
+ /** True produces a header-only reset response. */
134
+ resetRequired: boolean;
117
135
  }
118
136
 
119
137
  /** §7.3.2: a server-issued auth lease delivered to the client (opaque). */
@@ -172,21 +190,47 @@ export interface PushResultDetailsFrame {
172
190
  entries: Array<{ opIndex: number; details: RejectionDetails }>;
173
191
  }
174
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
+
175
201
  export interface SubStartFrame {
176
202
  type: 'SUB_START';
203
+ /** The unit id (§4.1): 32 lowercase hex characters. */
177
204
  id: string;
178
205
  status: 'active' | 'revoked' | 'reset';
179
206
  reasonCode: string;
180
- effectiveScopes: ScopeMap;
181
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;
182
225
  }
183
226
 
184
227
  export interface CommitChange {
185
228
  tableIndex: number;
186
229
  rowId: string;
187
230
  op: 'upsert' | 'delete';
188
- /** Present iff `op` is `upsert` (§4.5). */
189
- 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;
190
234
  scopes: Record<string, string>;
191
235
  /** Generated-row-codec bytes; present iff `op` is `upsert` (§4.5). */
192
236
  row?: Uint8Array;
@@ -226,7 +270,7 @@ export interface SegmentInlineFrame {
226
270
  export interface SubEndFrame {
227
271
  type: 'SUB_END';
228
272
  nextCursor: number;
229
- /** Present iff the bootstrap is incomplete (§4.4). */
273
+ /** Present iff the unit's bootstrap is incomplete (§4.4). */
230
274
  bootstrapState?: string;
231
275
  }
232
276
 
@@ -250,6 +294,8 @@ export type ResponseFrame =
250
294
  | SegmentRefFrame
251
295
  | SegmentInlineFrame
252
296
  | SubEndFrame
297
+ | LiveStartFrame
298
+ | LiveEndFrame
253
299
  | ErrorFrame
254
300
  | UnknownFrame;
255
301
 
@@ -286,12 +332,10 @@ function invalid(message: string): never {
286
332
  throw new DecodeError('sync.invalid_request', message);
287
333
  }
288
334
 
289
- function requireJsonDocument(raw: string, what: string): string {
290
- try {
291
- JSON.parse(raw);
292
- } catch {
293
- invalid(`${what} is not a valid JSON document`);
294
- }
335
+ /** A `json` field: a valid JSON document (Conventions), kept as raw text. */
336
+ function readJson(r: ByteReader, what: string): string {
337
+ const raw = r.str();
338
+ parseJsonDocument(raw, what);
295
339
  return raw;
296
340
  }
297
341
 
@@ -316,29 +360,84 @@ function validateRoundFailureCode(
316
360
  }
317
361
  }
318
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
+
319
419
  // ---------------------------------------------------------------------------
320
420
  // Frame payload decoders (each consumes exactly the frame payload)
321
421
  // ---------------------------------------------------------------------------
322
422
 
323
- function decodeReqHeader(r: ByteReader, wireVersion: number): ReqHeaderFrame {
423
+ function decodeReqHeader(r: ByteReader): ReqHeaderFrame {
324
424
  const clientId = r.str();
325
425
  if (clientId.length === 0) invalid('REQ_HEADER.clientId must be non-empty');
326
426
  const schemaVersion = r.i32();
327
427
  if (schemaVersion < 1) invalid('REQ_HEADER.schemaVersion must be >= 1');
328
- const logEpoch =
329
- wireVersion >= 2
330
- ? r.opt(() => {
331
- const value = r.str();
332
- if (value.length === 0)
333
- invalid('REQ_HEADER.logEpoch must be non-empty');
334
- return value;
335
- })
336
- : undefined;
428
+ const logEpoch = r.opt(() => {
429
+ const value = r.str();
430
+ if (value.length === 0) invalid('REQ_HEADER.logEpoch must be non-empty');
431
+ return value;
432
+ });
433
+ const outboxId = r.opt(() => r.str());
434
+ if (outboxId !== undefined) checkOutboxId(outboxId, invalid);
337
435
  return definedObject({
338
436
  type: 'REQ_HEADER',
339
437
  clientId,
340
438
  schemaVersion,
341
439
  logEpoch: logEpoch,
440
+ outboxId: outboxId,
342
441
  });
343
442
  }
344
443
 
@@ -353,6 +452,8 @@ function decodePushCommit(r: ByteReader): PushCommitFrame {
353
452
  if (clientCommitId.length === 0) {
354
453
  invalid('PUSH_COMMIT.clientCommitId must be non-empty');
355
454
  }
455
+ const clientSeq = r.i64();
456
+ if (clientSeq < 0) invalid('PUSH_COMMIT.clientSeq must be >= 0');
356
457
  const operationCount = r.u32();
357
458
  if (operationCount === 0) {
358
459
  throw new DecodeError(
@@ -387,7 +488,7 @@ function decodePushCommit(r: ByteReader): PushCommitFrame {
387
488
  }),
388
489
  );
389
490
  }
390
- return { type: 'PUSH_COMMIT', clientCommitId, operations };
491
+ return { type: 'PUSH_COMMIT', clientCommitId, clientSeq, operations };
391
492
  }
392
493
 
393
494
  function decodePullHeader(r: ByteReader): PullHeaderFrame {
@@ -398,51 +499,52 @@ function decodePullHeader(r: ByteReader): PullHeaderFrame {
398
499
  if ((accept & 0xf0) !== 0) {
399
500
  invalid('PULL_HEADER.accept bits 4-7 must be zero');
400
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);
401
508
  return {
402
509
  type: 'PULL_HEADER',
403
510
  limitCommits,
404
511
  limitSnapshotRows,
405
512
  maxSnapshotPages,
406
513
  accept,
514
+ cursor,
515
+ registrationDigest,
516
+ echoDigest,
407
517
  };
408
518
  }
409
519
 
410
- function decodeSubscription(r: ByteReader): SubscriptionFrame {
520
+ function decodeProgress(r: ByteReader): ProgressFrame {
411
521
  const id = r.str();
412
- const table = r.str();
413
- const scopes = readStringListMap(r);
414
- const params = r.opt(() =>
415
- requireJsonDocument(r.str(), 'SUBSCRIPTION.params'),
416
- );
522
+ checkUnitId(id, 'PROGRESS', invalid);
417
523
  const cursor = r.i64();
418
- const bootstrapState = r.opt(() =>
419
- requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'),
420
- );
524
+ checkCursor(cursor, 'PROGRESS.cursor', invalid);
525
+ const bootstrapState = r.opt(() => readJson(r, 'PROGRESS.bootstrapState'));
421
526
  return definedObject({
422
- type: 'SUBSCRIPTION',
527
+ type: 'PROGRESS',
423
528
  id,
424
- table,
425
- scopes,
426
- params: params,
427
529
  cursor,
428
530
  bootstrapState: bootstrapState,
429
531
  });
430
532
  }
431
533
 
432
- function decodeRespHeader(r: ByteReader, wireVersion: number): RespHeaderFrame {
534
+ function decodeRespHeader(r: ByteReader): RespHeaderFrame {
433
535
  const requiredSchemaVersion = r.opt(() => r.i32());
434
536
  const latestSchemaVersion = r.opt(() => r.i32());
435
- const logEpoch = wireVersion >= 2 ? r.str() : undefined;
436
- if (logEpoch !== undefined && logEpoch.length === 0) {
537
+ const logEpoch = r.str();
538
+ if (logEpoch.length === 0) {
437
539
  invalid('RESP_HEADER.logEpoch must be non-empty');
438
540
  }
439
- const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
541
+ const resetRequired = r.bool();
440
542
  return definedObject({
441
543
  type: 'RESP_HEADER',
442
544
  requiredSchemaVersion: requiredSchemaVersion,
443
545
  latestSchemaVersion: latestSchemaVersion,
444
- logEpoch: logEpoch,
445
- resetRequired: resetRequired,
546
+ logEpoch,
547
+ resetRequired,
446
548
  });
447
549
  }
448
550
 
@@ -523,9 +625,10 @@ function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
523
625
  );
524
626
  }
525
627
  seen.add(opIndex);
628
+ const parsed = parseJsonDocument(r.str(), 'PUSH_RESULT_DETAILS.details');
526
629
  let details: RejectionDetails;
527
630
  try {
528
- details = normalizeRejectionDetails(JSON.parse(r.str()) as unknown);
631
+ details = normalizeRejectionDetails(parsed);
529
632
  } catch (error) {
530
633
  invalid(
531
634
  `invalid PUSH_RESULT_DETAILS.details: ${error instanceof Error ? error.message : String(error)}`,
@@ -538,6 +641,7 @@ function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
538
641
 
539
642
  function decodeSubStart(r: ByteReader): SubStartFrame {
540
643
  const id = r.str();
644
+ checkUnitId(id, 'SUB_START', invalid);
541
645
  const statusByte = r.u8();
542
646
  let status: 'active' | 'revoked' | 'reset';
543
647
  if (statusByte === 1) status = 'active';
@@ -545,16 +649,40 @@ function decodeSubStart(r: ByteReader): SubStartFrame {
545
649
  else if (statusByte === 3) status = 'reset';
546
650
  else invalid(`invalid SUB_START status byte ${statusByte}`);
547
651
  const reasonCode = r.str();
548
- const effectiveScopes = readStringListMap(r);
549
652
  const bootstrap = r.bool();
550
- return {
551
- type: 'SUB_START',
552
- id,
553
- status,
554
- reasonCode,
555
- effectiveScopes,
556
- bootstrap,
557
- };
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 };
558
686
  }
559
687
 
560
688
  function decodeCommit(r: ByteReader): CommitFrame {
@@ -579,24 +707,15 @@ function decodeCommit(r: ByteReader): CommitFrame {
579
707
  if (opByte === 1) op = 'upsert';
580
708
  else if (opByte === 2) op = 'delete';
581
709
  else invalid(`invalid change op byte ${opByte}`);
582
- const rowVersion = r.opt(() => r.i64());
710
+ const rowVersion = r.i64();
711
+ if (rowVersion < 1) invalid('change rowVersion must be at least 1');
583
712
  const scopes = readStringMap(r);
584
713
  const row = r.opt(() => r.bytes());
585
- if (op === 'upsert' && (rowVersion === undefined || row === undefined)) {
586
- invalid('upsert change requires rowVersion and row');
587
- }
588
- if (op === 'delete' && (rowVersion !== undefined || row !== undefined)) {
589
- 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');
590
716
  }
591
717
  changes.push(
592
- definedObject({
593
- tableIndex,
594
- rowId,
595
- op,
596
- rowVersion: rowVersion,
597
- scopes,
598
- row: row,
599
- }),
718
+ definedObject({ tableIndex, rowId, op, rowVersion, scopes, row }),
600
719
  );
601
720
  }
602
721
  return { type: 'COMMIT', commitSeq, createdAtMs, actorId, tables, changes };
@@ -647,9 +766,7 @@ function decodeSegmentInline(r: ByteReader): SegmentInlineFrame {
647
766
 
648
767
  function decodeSubEnd(r: ByteReader): SubEndFrame {
649
768
  const nextCursor = r.i64();
650
- const bootstrapState = r.opt(() =>
651
- requireJsonDocument(r.str(), 'SUB_END.bootstrapState'),
652
- );
769
+ const bootstrapState = r.opt(() => readJson(r, 'SUB_END.bootstrapState'));
653
770
  return definedObject({
654
771
  type: 'SUB_END',
655
772
  nextCursor,
@@ -663,7 +780,7 @@ function decodeErrorFrame(r: ByteReader): ErrorFrame {
663
780
  const category = r.str();
664
781
  const retryable = r.bool();
665
782
  const recommendedAction = r.str();
666
- const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
783
+ const details = r.opt(() => readJson(r, 'ERROR.details'));
667
784
  return definedObject({
668
785
  type: 'ERROR',
669
786
  code,
@@ -678,13 +795,12 @@ function decodeErrorFrame(r: ByteReader): ErrorFrame {
678
795
  function decodeRequestFrame(
679
796
  frameType: number,
680
797
  payload: Uint8Array,
681
- wireVersion: number,
682
798
  ): RequestFrame {
683
799
  const r = new ByteReader(payload);
684
800
  let frame: RequestFrame;
685
801
  switch (frameType) {
686
802
  case FrameType.REQ_HEADER:
687
- frame = decodeReqHeader(r, wireVersion);
803
+ frame = decodeReqHeader(r);
688
804
  break;
689
805
  case FrameType.ROUND_FAILURE:
690
806
  frame = decodeRoundFailure(r);
@@ -695,8 +811,11 @@ function decodeRequestFrame(
695
811
  case FrameType.PULL_HEADER:
696
812
  frame = decodePullHeader(r);
697
813
  break;
698
- case FrameType.SUBSCRIPTION:
699
- frame = decodeSubscription(r);
814
+ case FrameType.UNIT:
815
+ frame = { type: 'UNIT', scopes: readStringMap(r) };
816
+ break;
817
+ case FrameType.PROGRESS:
818
+ frame = decodeProgress(r);
700
819
  break;
701
820
  default:
702
821
  if (RESPONSE_FRAME_TYPES.has(frameType)) {
@@ -713,13 +832,12 @@ function decodeRequestFrame(
713
832
  function decodeResponseFrame(
714
833
  frameType: number,
715
834
  payload: Uint8Array,
716
- wireVersion: number,
717
835
  ): ResponseFrame {
718
836
  const r = new ByteReader(payload);
719
837
  let frame: ResponseFrame;
720
838
  switch (frameType) {
721
839
  case FrameType.RESP_HEADER:
722
- frame = decodeRespHeader(r, wireVersion);
840
+ frame = decodeRespHeader(r);
723
841
  break;
724
842
  case FrameType.LEASE:
725
843
  frame = decodeLease(r);
@@ -745,6 +863,12 @@ function decodeResponseFrame(
745
863
  case FrameType.SUB_END:
746
864
  frame = decodeSubEnd(r);
747
865
  break;
866
+ case FrameType.LIVE_START:
867
+ frame = decodeLiveStart(r);
868
+ break;
869
+ case FrameType.LIVE_END:
870
+ frame = decodeLiveEnd(r);
871
+ break;
748
872
  case FrameType.ERROR:
749
873
  frame = decodeErrorFrame(r);
750
874
  break;
@@ -771,6 +895,7 @@ function validateRequestSequence(frames: readonly RequestFrame[]): void {
771
895
  }
772
896
  let pushCount = 0;
773
897
  let hasPull = false;
898
+ let hasProgress = false;
774
899
  let index = 1;
775
900
  let sawRoundFailure = false;
776
901
  for (const frame of frames.slice(1)) {
@@ -794,8 +919,13 @@ function validateRequestSequence(frames: readonly RequestFrame[]): void {
794
919
  if (hasPull) invalid('duplicate PULL_HEADER frame');
795
920
  hasPull = true;
796
921
  break;
797
- case 'SUBSCRIPTION':
798
- 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;
799
929
  break;
800
930
  case 'UNKNOWN':
801
931
  break;
@@ -812,12 +942,12 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
812
942
  if (first === undefined || first.type !== 'RESP_HEADER') {
813
943
  invalid('response must start with a RESP_HEADER frame');
814
944
  }
815
- let inSubscription = false;
816
- 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;
817
949
  let sawLease = false;
818
950
  let sawBody = false;
819
- let subscriptionHasCommits = false;
820
- let subscriptionHasSegments = false;
821
951
  let errorSeen = false;
822
952
  let lastPushResult: PushResultFrame | undefined;
823
953
  let lastPushResultHasDetails = false;
@@ -828,21 +958,22 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
828
958
  invalid('duplicate RESP_HEADER frame');
829
959
  break;
830
960
  case 'LEASE':
831
- // §7.3.2: at most one LEASE, immediately after RESP_HEADER (before
832
- // any PUSH_RESULT / subscription section / ERROR).
961
+ // §7.3.2: at most one LEASE, immediately after RESP_HEADER.
833
962
  if (sawLease) invalid('duplicate LEASE frame');
834
963
  if (sawBody) invalid('LEASE frame must immediately follow RESP_HEADER');
835
964
  sawLease = true;
836
965
  break;
837
966
  case 'PUSH_RESULT':
838
- 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
+ }
839
970
  lastPushResult = frame;
840
971
  lastPushResultHasDetails = false;
841
972
  sawBody = true;
842
973
  break;
843
974
  case 'PUSH_RESULT_DETAILS': {
844
- if (sawSubscription) {
845
- invalid('PUSH_RESULT_DETAILS frame after SUB_START');
975
+ if (sawUnitSection || sawLive) {
976
+ invalid('PUSH_RESULT_DETAILS frame after a unit or live section');
846
977
  }
847
978
  if (lastPushResult === undefined) {
848
979
  invalid('PUSH_RESULT_DETAILS without a preceding PUSH_RESULT');
@@ -871,36 +1002,40 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
871
1002
  break;
872
1003
  }
873
1004
  case 'SUB_START':
874
- if (inSubscription) invalid('nested SUB_START frame');
875
- inSubscription = true;
876
- 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;
877
1010
  sawBody = true;
878
1011
  lastPushResult = undefined;
879
- subscriptionHasCommits = false;
880
- subscriptionHasSegments = false;
881
- break;
882
- case 'COMMIT':
883
- if (!inSubscription) invalid('COMMIT frame outside a subscription');
884
- if (subscriptionHasSegments) {
885
- invalid(
886
- 'COMMIT and segment frames must not both appear for one subscription',
887
- );
888
- }
889
- subscriptionHasCommits = true;
890
1012
  break;
891
1013
  case 'SEGMENT_REF':
892
1014
  case 'SEGMENT_INLINE':
893
- if (!inSubscription) invalid('segment frame outside a subscription');
894
- if (subscriptionHasCommits) {
895
- invalid(
896
- 'COMMIT and segment frames must not both appear for one subscription',
897
- );
898
- }
899
- subscriptionHasSegments = true;
1015
+ if (section !== 'unit') invalid('segment frame outside a unit section');
900
1016
  break;
901
1017
  case 'SUB_END':
902
- if (!inSubscription) invalid('SUB_END frame without an open SUB_START');
903
- 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';
904
1039
  break;
905
1040
  case 'ERROR':
906
1041
  errorSeen = true;
@@ -913,16 +1048,11 @@ function validateResponseSequence(frames: readonly ResponseFrame[]): void {
913
1048
  break;
914
1049
  }
915
1050
  }
916
- if (inSubscription && !errorSeen) {
917
- invalid('subscription not terminated by SUB_END');
1051
+ if (section !== 'none' && !errorSeen) {
1052
+ invalid('unit or live section not terminated');
918
1053
  }
919
1054
  }
920
1055
 
921
- export function validateFrameSequence(message: SyncMessage): void {
922
- if (message.msgKind === 'request') validateRequestSequence(message.frames);
923
- else validateResponseSequence(message.frames);
924
- }
925
-
926
1056
  // ---------------------------------------------------------------------------
927
1057
  // Frame payload encoders
928
1058
  // ---------------------------------------------------------------------------
@@ -930,20 +1060,30 @@ export function validateFrameSequence(message: SyncMessage): void {
930
1060
  const OP_BYTES = { upsert: 1, delete: 2 } as const;
931
1061
  const PUSH_STATUS_BYTES = { applied: 1, cached: 2, rejected: 3 } as const;
932
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;
933
1064
  const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 } as const;
934
1065
 
935
1066
  function encodeInvalid(message: string): never {
936
1067
  throw new Error(message);
937
1068
  }
938
1069
 
939
- function encodeFrame(
940
- frame: RequestFrame | ResponseFrame,
941
- wireVersion: number,
942
- ): {
943
- frameType: number;
944
- payload: Uint8Array;
945
- } {
946
- 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 {
947
1087
  switch (frame.type) {
948
1088
  case 'REQ_HEADER': {
949
1089
  if (frame.clientId.length === 0) {
@@ -954,14 +1094,14 @@ function encodeFrame(
954
1094
  }
955
1095
  w.str(frame.clientId);
956
1096
  w.i32(frame.schemaVersion);
957
- if (wireVersion >= 2) {
958
- if (frame.logEpoch === '') {
959
- encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
960
- }
961
- w.opt(frame.logEpoch, (v) => w.str(v));
962
- } else if (frame.logEpoch !== undefined) {
963
- encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
1097
+ if (frame.logEpoch === '') {
1098
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
1099
+ }
1100
+ w.opt(frame.logEpoch, (v) => w.str(v));
1101
+ if (frame.outboxId !== undefined) {
1102
+ checkOutboxId(frame.outboxId, encodeInvalid);
964
1103
  }
1104
+ w.opt(frame.outboxId, (v) => w.str(v));
965
1105
  break;
966
1106
  }
967
1107
  case 'ROUND_FAILURE': {
@@ -976,7 +1116,11 @@ function encodeFrame(
976
1116
  if (frame.operations.length === 0) {
977
1117
  encodeInvalid('PUSH_COMMIT must carry at least one operation');
978
1118
  }
1119
+ if (frame.clientSeq < 0) {
1120
+ encodeInvalid('PUSH_COMMIT.clientSeq must be >= 0');
1121
+ }
979
1122
  w.str(frame.clientCommitId);
1123
+ w.i64(frame.clientSeq);
980
1124
  w.u32(frame.operations.length);
981
1125
  for (const operation of frame.operations) {
982
1126
  if (operation.op === 'upsert' && operation.payload === undefined) {
@@ -1000,36 +1144,48 @@ function encodeFrame(
1000
1144
  w.i32(frame.limitCommits);
1001
1145
  w.i32(frame.limitSnapshotRows);
1002
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);
1003
1154
  w.u8(frame.accept);
1155
+ w.i64(frame.cursor);
1156
+ w.bytes(frame.registrationDigest);
1157
+ w.bytes(frame.echoDigest);
1004
1158
  break;
1005
1159
  }
1006
- 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);
1007
1167
  w.str(frame.id);
1008
- w.str(frame.table);
1009
- writeStringListMap(w, frame.scopes);
1010
- w.opt(frame.params, (v) => w.str(v));
1011
1168
  w.i64(frame.cursor);
1012
1169
  w.opt(frame.bootstrapState, (v) => w.str(v));
1013
1170
  break;
1014
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) {
1015
1181
  case 'RESP_HEADER': {
1016
1182
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
1017
1183
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
1018
- if (wireVersion >= 2) {
1019
- if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
1020
- encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
1021
- }
1022
- if (frame.resetRequired === undefined) {
1023
- encodeInvalid('RESP_HEADER.resetRequired is required');
1024
- }
1025
- w.str(frame.logEpoch);
1026
- w.bool(frame.resetRequired);
1027
- } else if (
1028
- frame.logEpoch !== undefined ||
1029
- frame.resetRequired !== undefined
1030
- ) {
1031
- encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
1184
+ if (frame.logEpoch.length === 0) {
1185
+ encodeInvalid('RESP_HEADER.logEpoch must be non-empty');
1032
1186
  }
1187
+ w.str(frame.logEpoch);
1188
+ w.bool(frame.resetRequired);
1033
1189
  break;
1034
1190
  }
1035
1191
  case 'LEASE': {
@@ -1095,11 +1251,29 @@ function encodeFrame(
1095
1251
  break;
1096
1252
  }
1097
1253
  case 'SUB_START': {
1254
+ checkUnitId(frame.id, 'SUB_START', encodeInvalid);
1255
+ checkFencedOrder(frame.fenced, encodeInvalid);
1098
1256
  w.str(frame.id);
1099
1257
  w.u8(SUB_STATUS_BYTES[frame.status]);
1100
1258
  w.str(frame.reasonCode);
1101
- writeStringListMap(w, frame.effectiveScopes);
1102
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);
1103
1277
  break;
1104
1278
  }
1105
1279
  case 'COMMIT': {
@@ -1113,22 +1287,16 @@ function encodeFrame(
1113
1287
  if (change.tableIndex >= frame.tables.length) {
1114
1288
  encodeInvalid(`change tableIndex ${change.tableIndex} out of range`);
1115
1289
  }
1116
- if (
1117
- change.op === 'upsert' &&
1118
- (change.rowVersion === undefined || change.row === undefined)
1119
- ) {
1120
- 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');
1121
1292
  }
1122
- if (
1123
- change.op === 'delete' &&
1124
- (change.rowVersion !== undefined || change.row !== undefined)
1125
- ) {
1126
- encodeInvalid('delete change must not carry rowVersion or row');
1293
+ if (change.rowVersion < 1) {
1294
+ encodeInvalid('change rowVersion must be at least 1');
1127
1295
  }
1128
1296
  w.u16(change.tableIndex);
1129
1297
  w.str(change.rowId);
1130
1298
  w.u8(OP_BYTES[change.op]);
1131
- w.opt(change.rowVersion, (v) => w.i64(v));
1299
+ w.i64(change.rowVersion);
1132
1300
  writeStringMap(w, change.scopes);
1133
1301
  w.opt(change.row, (v) => w.bytes(v));
1134
1302
  }
@@ -1152,7 +1320,8 @@ function encodeFrame(
1152
1320
  break;
1153
1321
  }
1154
1322
  case 'SEGMENT_INLINE': {
1155
- return { frameType: FrameType.SEGMENT_INLINE, payload: frame.payload };
1323
+ w.raw(frame.payload);
1324
+ break;
1156
1325
  }
1157
1326
  case 'SUB_END': {
1158
1327
  w.i64(frame.nextCursor);
@@ -1168,39 +1337,37 @@ function encodeFrame(
1168
1337
  w.opt(frame.details, (v) => w.str(v));
1169
1338
  break;
1170
1339
  }
1171
- case 'UNKNOWN': {
1172
- if (
1173
- !Number.isInteger(frame.frameType) ||
1174
- frame.frameType < 0 ||
1175
- frame.frameType > 0xff ||
1176
- KNOWN_FRAME_TYPES.has(frame.frameType)
1177
- ) {
1178
- encodeInvalid(
1179
- `UNKNOWN frame type ${frame.frameType} collides with a defined frame type`,
1180
- );
1181
- }
1182
- return { frameType: frame.frameType, payload: frame.payload };
1183
- }
1340
+ case 'UNKNOWN':
1341
+ return encodeUnknown(frame, w);
1184
1342
  }
1185
- return { frameType: FrameType[frame.type], payload: w.finish() };
1343
+ return FrameType[frame.type];
1186
1344
  }
1187
1345
 
1188
1346
  // ---------------------------------------------------------------------------
1189
- // Envelope
1347
+ // Envelope. The kind-specific codecs let a peer bundle only the direction it
1348
+ // speaks; `encodeMessage` and `decodeMessage` dispatch on the kind.
1190
1349
  // ---------------------------------------------------------------------------
1191
1350
 
1192
- export function encodeMessage(message: SyncMessage): Uint8Array {
1193
- if (!isSupportedProtocolWireVersion(message.wireVersion)) {
1194
- 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}`);
1195
1360
  }
1196
- validateFrameSequence(message);
1361
+ validate(frames);
1197
1362
  const w = new ByteWriter();
1198
1363
  w.raw(MAGIC_BYTES);
1199
- w.u16(message.wireVersion);
1200
- w.u8(message.msgKind === 'request' ? 0x01 : 0x02);
1364
+ w.u16(wireVersion);
1365
+ w.u8(kindByte);
1201
1366
  w.u8(0x00);
1202
- for (const frame of message.frames) {
1203
- const { frameType, payload } = encodeFrame(frame, message.wireVersion);
1367
+ for (const frame of frames) {
1368
+ const p = new ByteWriter();
1369
+ const frameType = encode(frame, p);
1370
+ const payload = p.finish();
1204
1371
  w.u8(frameType);
1205
1372
  w.u32(payload.length);
1206
1373
  w.raw(payload);
@@ -1210,6 +1377,32 @@ export function encodeMessage(message: SyncMessage): Uint8Array {
1210
1377
  return w.finish();
1211
1378
  }
1212
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
+
1213
1406
  function readFrames<T>(
1214
1407
  r: ByteReader,
1215
1408
  decodeFrame_: (frameType: number, payload: Uint8Array) => T,
@@ -1236,7 +1429,11 @@ function readFrames<T>(
1236
1429
  }
1237
1430
  }
1238
1431
 
1239
- export function decodeMessage(bytes: Uint8Array): SyncMessage {
1432
+ function decodeEnvelope(bytes: Uint8Array): {
1433
+ r: ByteReader;
1434
+ wireVersion: number;
1435
+ request: boolean;
1436
+ } {
1240
1437
  const r = new ByteReader(bytes);
1241
1438
  if (r.remaining < 8) invalid('truncated envelope header');
1242
1439
  const magic = r.raw(4);
@@ -1258,24 +1455,34 @@ export function decodeMessage(bytes: Uint8Array): SyncMessage {
1258
1455
  }
1259
1456
  const flags = r.u8();
1260
1457
  if (flags !== 0) invalid(`non-zero envelope flags 0x${flags.toString(16)}`);
1261
- if (kindByte === 0x01) {
1262
- const message: RequestMessage = {
1263
- wireVersion,
1264
- msgKind: 'request',
1265
- frames: readFrames(r, (frameType, payload) =>
1266
- decodeRequestFrame(frameType, payload, wireVersion),
1267
- ),
1268
- };
1269
- validateRequestSequence(message.frames);
1270
- return message;
1271
- }
1272
- const message: ResponseMessage = {
1273
- wireVersion,
1274
- msgKind: 'response',
1275
- frames: readFrames(r, (frameType, payload) =>
1276
- decodeResponseFrame(frameType, payload, wireVersion),
1277
- ),
1278
- };
1279
- validateResponseSequence(message.frames);
1280
- 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);
1281
1488
  }