@syncular/core 0.30.10 → 0.30.11

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/bytes.js CHANGED
@@ -198,7 +198,7 @@ export function writeStringListMap(writer, map) {
198
198
  writer.str(value);
199
199
  }
200
200
  }
201
- export function readStringListMap(reader) {
201
+ function readCanonicalMap(reader, read) {
202
202
  const count = reader.u32();
203
203
  const entries = [];
204
204
  let previousKey;
@@ -208,14 +208,19 @@ export function readStringListMap(reader) {
208
208
  throw new DecodeError('sync.invalid_request', `map keys must be unique and in ascending code-unit order (saw ${JSON.stringify(key)} after ${JSON.stringify(previousKey)})`);
209
209
  }
210
210
  previousKey = key;
211
- const valueCount = reader.u32();
212
- const values = [];
213
- for (let j = 0; j < valueCount; j++)
214
- values.push(reader.str());
215
- entries.push([key, values]);
211
+ entries.push([key, read()]);
216
212
  }
217
213
  return Object.fromEntries(entries);
218
214
  }
215
+ export function readStringListMap(reader) {
216
+ return readCanonicalMap(reader, () => {
217
+ const count = reader.u32();
218
+ const values = [];
219
+ for (let i = 0; i < count; i++)
220
+ values.push(reader.str());
221
+ return values;
222
+ });
223
+ }
219
224
  /** `map` of `str` → `str` (stored scopes on change records, SPEC.md §4.5). */
220
225
  export function writeStringMap(writer, map) {
221
226
  const keys = Object.keys(map).sort();
@@ -227,16 +232,5 @@ export function writeStringMap(writer, map) {
227
232
  }
228
233
  }
229
234
  export function readStringMap(reader) {
230
- const count = reader.u32();
231
- const entries = [];
232
- let previousKey;
233
- for (let i = 0; i < count; i++) {
234
- const key = reader.str();
235
- if (previousKey !== undefined && key <= previousKey) {
236
- throw new DecodeError('sync.invalid_request', `map keys must be unique and in ascending code-unit order (saw ${JSON.stringify(key)} after ${JSON.stringify(previousKey)})`);
237
- }
238
- previousKey = key;
239
- entries.push([key, reader.str()]);
240
- }
241
- return Object.fromEntries(entries);
235
+ return readCanonicalMap(reader, () => reader.str());
242
236
  }
package/dist/message.js CHANGED
@@ -18,6 +18,13 @@ import { FrameType, KNOWN_FRAME_TYPES, REQUEST_FRAME_TYPES, RESPONSE_FRAME_TYPES
18
18
  import { normalizeRejectionDetails, } from './rejection-details.js';
19
19
  import { decodeRowsSegment } from './segment.js';
20
20
  const MAGIC_BYTES = utf8Encode(SYNC_PACK_MAGIC);
21
+ function definedObject(record) {
22
+ for (const key of Object.keys(record)) {
23
+ if (record[key] === undefined)
24
+ delete record[key];
25
+ }
26
+ return record;
27
+ }
21
28
  function invalid(message) {
22
29
  throw new DecodeError('sync.invalid_request', message);
23
30
  }
@@ -48,12 +55,12 @@ function decodeReqHeader(r, wireVersion) {
48
55
  return value;
49
56
  })
50
57
  : undefined;
51
- return {
58
+ return definedObject({
52
59
  type: 'REQ_HEADER',
53
60
  clientId,
54
61
  schemaVersion,
55
- ...(logEpoch !== undefined ? { logEpoch } : {}),
56
- };
62
+ logEpoch: logEpoch,
63
+ });
57
64
  }
58
65
  function decodePushCommit(r) {
59
66
  const clientCommitId = r.str();
@@ -84,13 +91,13 @@ function decodePushCommit(r) {
84
91
  if (op === 'delete' && payload !== undefined) {
85
92
  invalid('delete operation must not carry a payload');
86
93
  }
87
- operations.push({
94
+ operations.push(definedObject({
88
95
  table,
89
96
  rowId,
90
97
  op,
91
- ...(baseVersion !== undefined ? { baseVersion } : {}),
92
- ...(payload !== undefined ? { payload } : {}),
93
- });
98
+ baseVersion: baseVersion,
99
+ payload: payload,
100
+ }));
94
101
  }
95
102
  return { type: 'PUSH_COMMIT', clientCommitId, operations };
96
103
  }
@@ -117,15 +124,15 @@ function decodeSubscription(r) {
117
124
  const params = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.params'));
118
125
  const cursor = r.i64();
119
126
  const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'));
120
- return {
127
+ return definedObject({
121
128
  type: 'SUBSCRIPTION',
122
129
  id,
123
130
  table,
124
131
  scopes,
125
- ...(params !== undefined ? { params } : {}),
132
+ params: params,
126
133
  cursor,
127
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
128
- };
134
+ bootstrapState: bootstrapState,
135
+ });
129
136
  }
130
137
  function decodeRespHeader(r, wireVersion) {
131
138
  const requiredSchemaVersion = r.opt(() => r.i32());
@@ -135,13 +142,13 @@ function decodeRespHeader(r, wireVersion) {
135
142
  invalid('RESP_HEADER.logEpoch must be non-empty');
136
143
  }
137
144
  const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
138
- return {
145
+ return definedObject({
139
146
  type: 'RESP_HEADER',
140
- ...(requiredSchemaVersion !== undefined ? { requiredSchemaVersion } : {}),
141
- ...(latestSchemaVersion !== undefined ? { latestSchemaVersion } : {}),
142
- ...(logEpoch !== undefined ? { logEpoch } : {}),
143
- ...(resetRequired !== undefined ? { resetRequired } : {}),
144
- };
147
+ requiredSchemaVersion: requiredSchemaVersion,
148
+ latestSchemaVersion: latestSchemaVersion,
149
+ logEpoch: logEpoch,
150
+ resetRequired: resetRequired,
151
+ });
145
152
  }
146
153
  function decodeLease(r) {
147
154
  const leaseId = r.str();
@@ -201,13 +208,13 @@ function decodePushResult(r) {
201
208
  invalid(`invalid push result record status byte ${recordStatus}`);
202
209
  }
203
210
  }
204
- return {
211
+ return definedObject({
205
212
  type: 'PUSH_RESULT',
206
213
  clientCommitId,
207
214
  status,
208
- ...(commitSeq !== undefined ? { commitSeq } : {}),
215
+ commitSeq: commitSeq,
209
216
  results,
210
- };
217
+ });
211
218
  }
212
219
  function decodePushResultDetails(r) {
213
220
  const clientCommitId = r.str();
@@ -293,14 +300,14 @@ function decodeCommit(r) {
293
300
  if (op === 'delete' && (rowVersion !== undefined || row !== undefined)) {
294
301
  invalid('delete change must not carry rowVersion or row');
295
302
  }
296
- changes.push({
303
+ changes.push(definedObject({
297
304
  tableIndex,
298
305
  rowId,
299
306
  op,
300
- ...(rowVersion !== undefined ? { rowVersion } : {}),
307
+ rowVersion: rowVersion,
301
308
  scopes,
302
- ...(row !== undefined ? { row } : {}),
303
- });
309
+ row: row,
310
+ }));
304
311
  }
305
312
  return { type: 'COMMIT', commitSeq, createdAtMs, actorId, tables, changes };
306
313
  }
@@ -326,7 +333,7 @@ function decodeSegmentRef(r) {
326
333
  if ((url === undefined) !== (urlExpiresAtMs === undefined)) {
327
334
  invalid('SEGMENT_REF.urlExpiresAtMs must be present iff url is');
328
335
  }
329
- return {
336
+ return definedObject({
330
337
  type: 'SEGMENT_REF',
331
338
  segmentId,
332
339
  mediaType,
@@ -335,11 +342,11 @@ function decodeSegmentRef(r) {
335
342
  rowCount,
336
343
  asOfCommitSeq,
337
344
  scopeDigest,
338
- ...(rowCursor !== undefined ? { rowCursor } : {}),
339
- ...(nextRowCursor !== undefined ? { nextRowCursor } : {}),
340
- ...(url !== undefined ? { url } : {}),
341
- ...(urlExpiresAtMs !== undefined ? { urlExpiresAtMs } : {}),
342
- };
345
+ rowCursor: rowCursor,
346
+ nextRowCursor: nextRowCursor,
347
+ url: url,
348
+ urlExpiresAtMs: urlExpiresAtMs,
349
+ });
343
350
  }
344
351
  function decodeSegmentInline(r) {
345
352
  const payload = r.raw(r.remaining);
@@ -351,11 +358,11 @@ function decodeSegmentInline(r) {
351
358
  function decodeSubEnd(r) {
352
359
  const nextCursor = r.i64();
353
360
  const bootstrapState = r.opt(() => requireJsonDocument(r.str(), 'SUB_END.bootstrapState'));
354
- return {
361
+ return definedObject({
355
362
  type: 'SUB_END',
356
363
  nextCursor,
357
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
358
- };
364
+ bootstrapState: bootstrapState,
365
+ });
359
366
  }
360
367
  function decodeErrorFrame(r) {
361
368
  const code = r.str();
@@ -364,15 +371,15 @@ function decodeErrorFrame(r) {
364
371
  const retryable = r.bool();
365
372
  const recommendedAction = r.str();
366
373
  const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
367
- return {
374
+ return definedObject({
368
375
  type: 'ERROR',
369
376
  code,
370
377
  message,
371
378
  category,
372
379
  retryable,
373
380
  recommendedAction,
374
- ...(details !== undefined ? { details } : {}),
375
- };
381
+ details: details,
382
+ });
376
383
  }
377
384
  function decodeRequestFrame(frameType, payload, wireVersion) {
378
385
  const r = new ByteReader(payload);
@@ -595,44 +602,47 @@ const OP_BYTES = { upsert: 1, delete: 2 };
595
602
  const PUSH_STATUS_BYTES = { applied: 1, cached: 2, rejected: 3 };
596
603
  const SUB_STATUS_BYTES = { active: 1, revoked: 2, reset: 3 };
597
604
  const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 };
605
+ function encodeInvalid(message) {
606
+ throw new Error(message);
607
+ }
598
608
  function encodeFrame(frame, wireVersion) {
599
609
  const w = new ByteWriter();
600
610
  switch (frame.type) {
601
611
  case 'REQ_HEADER': {
602
612
  if (frame.clientId.length === 0) {
603
- throw new Error('REQ_HEADER.clientId must be non-empty');
613
+ encodeInvalid('REQ_HEADER.clientId must be non-empty');
604
614
  }
605
615
  if (frame.schemaVersion < 1) {
606
- throw new Error('REQ_HEADER.schemaVersion must be >= 1');
616
+ encodeInvalid('REQ_HEADER.schemaVersion must be >= 1');
607
617
  }
608
618
  w.str(frame.clientId);
609
619
  w.i32(frame.schemaVersion);
610
620
  if (wireVersion >= 2) {
611
621
  if (frame.logEpoch === '') {
612
- throw new Error('REQ_HEADER.logEpoch must be non-empty');
622
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
613
623
  }
614
624
  w.opt(frame.logEpoch, (v) => w.str(v));
615
625
  }
616
626
  else if (frame.logEpoch !== undefined) {
617
- throw new Error('REQ_HEADER.logEpoch requires wireVersion 2');
627
+ encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
618
628
  }
619
- return { frameType: FrameType.REQ_HEADER, payload: w.finish() };
629
+ break;
620
630
  }
621
631
  case 'PUSH_COMMIT': {
622
632
  if (frame.clientCommitId.length === 0) {
623
- throw new Error('PUSH_COMMIT.clientCommitId must be non-empty');
633
+ encodeInvalid('PUSH_COMMIT.clientCommitId must be non-empty');
624
634
  }
625
635
  if (frame.operations.length === 0) {
626
- throw new Error('PUSH_COMMIT must carry at least one operation');
636
+ encodeInvalid('PUSH_COMMIT must carry at least one operation');
627
637
  }
628
638
  w.str(frame.clientCommitId);
629
639
  w.u32(frame.operations.length);
630
640
  for (const operation of frame.operations) {
631
641
  if (operation.op === 'upsert' && operation.payload === undefined) {
632
- throw new Error('upsert operation requires a payload');
642
+ encodeInvalid('upsert operation requires a payload');
633
643
  }
634
644
  if (operation.op === 'delete' && operation.payload !== undefined) {
635
- throw new Error('delete operation must not carry a payload');
645
+ encodeInvalid('delete operation must not carry a payload');
636
646
  }
637
647
  w.str(operation.table);
638
648
  w.str(operation.rowId);
@@ -640,17 +650,17 @@ function encodeFrame(frame, wireVersion) {
640
650
  w.opt(operation.baseVersion, (v) => w.i64(v));
641
651
  w.opt(operation.payload, (v) => w.bytes(v));
642
652
  }
643
- return { frameType: FrameType.PUSH_COMMIT, payload: w.finish() };
653
+ break;
644
654
  }
645
655
  case 'PULL_HEADER': {
646
656
  if ((frame.accept & 0xf0) !== 0) {
647
- throw new Error('PULL_HEADER.accept bits 4-7 must be zero');
657
+ encodeInvalid('PULL_HEADER.accept bits 4-7 must be zero');
648
658
  }
649
659
  w.i32(frame.limitCommits);
650
660
  w.i32(frame.limitSnapshotRows);
651
661
  w.i32(frame.maxSnapshotPages);
652
662
  w.u8(frame.accept);
653
- return { frameType: FrameType.PULL_HEADER, payload: w.finish() };
663
+ break;
654
664
  }
655
665
  case 'SUBSCRIPTION': {
656
666
  w.str(frame.id);
@@ -659,41 +669,41 @@ function encodeFrame(frame, wireVersion) {
659
669
  w.opt(frame.params, (v) => w.str(v));
660
670
  w.i64(frame.cursor);
661
671
  w.opt(frame.bootstrapState, (v) => w.str(v));
662
- return { frameType: FrameType.SUBSCRIPTION, payload: w.finish() };
672
+ break;
663
673
  }
664
674
  case 'RESP_HEADER': {
665
675
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
666
676
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
667
677
  if (wireVersion >= 2) {
668
678
  if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
669
- throw new Error('RESP_HEADER.logEpoch is required and non-empty');
679
+ encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
670
680
  }
671
681
  if (frame.resetRequired === undefined) {
672
- throw new Error('RESP_HEADER.resetRequired is required');
682
+ encodeInvalid('RESP_HEADER.resetRequired is required');
673
683
  }
674
684
  w.str(frame.logEpoch);
675
685
  w.bool(frame.resetRequired);
676
686
  }
677
687
  else if (frame.logEpoch !== undefined ||
678
688
  frame.resetRequired !== undefined) {
679
- throw new Error('RESP_HEADER epoch fields require wireVersion 2');
689
+ encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
680
690
  }
681
- return { frameType: FrameType.RESP_HEADER, payload: w.finish() };
691
+ break;
682
692
  }
683
693
  case 'LEASE': {
684
694
  if (frame.leaseId.length === 0) {
685
- throw new Error('LEASE.leaseId must be non-empty');
695
+ encodeInvalid('LEASE.leaseId must be non-empty');
686
696
  }
687
697
  w.str(frame.leaseId);
688
698
  w.i64(frame.expiresAtMs);
689
- return { frameType: FrameType.LEASE, payload: w.finish() };
699
+ break;
690
700
  }
691
701
  case 'PUSH_RESULT': {
692
702
  if (frame.status === 'rejected' && frame.commitSeq !== undefined) {
693
- throw new Error('rejected PUSH_RESULT must not carry a commitSeq');
703
+ encodeInvalid('rejected PUSH_RESULT must not carry a commitSeq');
694
704
  }
695
705
  if (frame.status !== 'rejected' && frame.commitSeq === undefined) {
696
- throw new Error(`${frame.status} PUSH_RESULT requires a commitSeq`);
706
+ encodeInvalid(`${frame.status} PUSH_RESULT requires a commitSeq`);
697
707
  }
698
708
  w.str(frame.clientCommitId);
699
709
  w.u8(PUSH_STATUS_BYTES[frame.status]);
@@ -719,33 +729,30 @@ function encodeFrame(frame, wireVersion) {
719
729
  w.bool(result.retryable);
720
730
  }
721
731
  }
722
- return { frameType: FrameType.PUSH_RESULT, payload: w.finish() };
732
+ break;
723
733
  }
724
734
  case 'PUSH_RESULT_DETAILS': {
725
735
  if (frame.clientCommitId.length === 0) {
726
- throw new Error('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
736
+ encodeInvalid('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
727
737
  }
728
738
  if (frame.entries.length === 0) {
729
- throw new Error('PUSH_RESULT_DETAILS must carry at least one entry');
739
+ encodeInvalid('PUSH_RESULT_DETAILS must carry at least one entry');
730
740
  }
731
741
  const seen = new Set();
732
742
  w.str(frame.clientCommitId);
733
743
  w.u32(frame.entries.length);
734
744
  for (const entry of frame.entries) {
735
745
  if (!Number.isInteger(entry.opIndex) || entry.opIndex < 0) {
736
- throw new Error('PUSH_RESULT_DETAILS.opIndex must be non-negative');
746
+ encodeInvalid('PUSH_RESULT_DETAILS.opIndex must be non-negative');
737
747
  }
738
748
  if (seen.has(entry.opIndex)) {
739
- throw new Error('PUSH_RESULT_DETAILS.opIndex must be unique');
749
+ encodeInvalid('PUSH_RESULT_DETAILS.opIndex must be unique');
740
750
  }
741
751
  seen.add(entry.opIndex);
742
752
  w.i32(entry.opIndex);
743
753
  w.str(JSON.stringify(normalizeRejectionDetails(entry.details)));
744
754
  }
745
- return {
746
- frameType: FrameType.PUSH_RESULT_DETAILS,
747
- payload: w.finish(),
748
- };
755
+ break;
749
756
  }
750
757
  case 'SUB_START': {
751
758
  w.str(frame.id);
@@ -753,7 +760,7 @@ function encodeFrame(frame, wireVersion) {
753
760
  w.str(frame.reasonCode);
754
761
  writeStringListMap(w, frame.effectiveScopes);
755
762
  w.bool(frame.bootstrap);
756
- return { frameType: FrameType.SUB_START, payload: w.finish() };
763
+ break;
757
764
  }
758
765
  case 'COMMIT': {
759
766
  w.i64(frame.commitSeq);
@@ -765,15 +772,15 @@ function encodeFrame(frame, wireVersion) {
765
772
  w.u32(frame.changes.length);
766
773
  for (const change of frame.changes) {
767
774
  if (change.tableIndex >= frame.tables.length) {
768
- throw new Error(`change tableIndex ${change.tableIndex} out of range`);
775
+ encodeInvalid(`change tableIndex ${change.tableIndex} out of range`);
769
776
  }
770
777
  if (change.op === 'upsert' &&
771
778
  (change.rowVersion === undefined || change.row === undefined)) {
772
- throw new Error('upsert change requires rowVersion and row');
779
+ encodeInvalid('upsert change requires rowVersion and row');
773
780
  }
774
781
  if (change.op === 'delete' &&
775
782
  (change.rowVersion !== undefined || change.row !== undefined)) {
776
- throw new Error('delete change must not carry rowVersion or row');
783
+ encodeInvalid('delete change must not carry rowVersion or row');
777
784
  }
778
785
  w.u16(change.tableIndex);
779
786
  w.str(change.rowId);
@@ -782,11 +789,11 @@ function encodeFrame(frame, wireVersion) {
782
789
  writeStringMap(w, change.scopes);
783
790
  w.opt(change.row, (v) => w.bytes(v));
784
791
  }
785
- return { frameType: FrameType.COMMIT, payload: w.finish() };
792
+ break;
786
793
  }
787
794
  case 'SEGMENT_REF': {
788
795
  if ((frame.url === undefined) !== (frame.urlExpiresAtMs === undefined)) {
789
- throw new Error('SEGMENT_REF.urlExpiresAtMs must be present iff url is');
796
+ encodeInvalid('SEGMENT_REF.urlExpiresAtMs must be present iff url is');
790
797
  }
791
798
  w.str(frame.segmentId);
792
799
  w.u8(MEDIA_TYPE_BYTES[frame.mediaType]);
@@ -799,7 +806,7 @@ function encodeFrame(frame, wireVersion) {
799
806
  w.opt(frame.nextRowCursor, (v) => w.str(v));
800
807
  w.opt(frame.url, (v) => w.str(v));
801
808
  w.opt(frame.urlExpiresAtMs, (v) => w.i64(v));
802
- return { frameType: FrameType.SEGMENT_REF, payload: w.finish() };
809
+ break;
803
810
  }
804
811
  case 'SEGMENT_INLINE': {
805
812
  return { frameType: FrameType.SEGMENT_INLINE, payload: frame.payload };
@@ -807,7 +814,7 @@ function encodeFrame(frame, wireVersion) {
807
814
  case 'SUB_END': {
808
815
  w.i64(frame.nextCursor);
809
816
  w.opt(frame.bootstrapState, (v) => w.str(v));
810
- return { frameType: FrameType.SUB_END, payload: w.finish() };
817
+ break;
811
818
  }
812
819
  case 'ERROR': {
813
820
  w.str(frame.code);
@@ -816,25 +823,26 @@ function encodeFrame(frame, wireVersion) {
816
823
  w.bool(frame.retryable);
817
824
  w.str(frame.recommendedAction);
818
825
  w.opt(frame.details, (v) => w.str(v));
819
- return { frameType: FrameType.ERROR, payload: w.finish() };
826
+ break;
820
827
  }
821
828
  case 'UNKNOWN': {
822
829
  if (!Number.isInteger(frame.frameType) ||
823
830
  frame.frameType < 0 ||
824
831
  frame.frameType > 0xff ||
825
832
  KNOWN_FRAME_TYPES.has(frame.frameType)) {
826
- throw new Error(`UNKNOWN frame type ${frame.frameType} collides with a defined frame type`);
833
+ encodeInvalid(`UNKNOWN frame type ${frame.frameType} collides with a defined frame type`);
827
834
  }
828
835
  return { frameType: frame.frameType, payload: frame.payload };
829
836
  }
830
837
  }
838
+ return { frameType: FrameType[frame.type], payload: w.finish() };
831
839
  }
832
840
  // ---------------------------------------------------------------------------
833
841
  // Envelope
834
842
  // ---------------------------------------------------------------------------
835
843
  export function encodeMessage(message) {
836
844
  if (!isSupportedProtocolWireVersion(message.wireVersion)) {
837
- throw new Error(`unsupported wireVersion ${message.wireVersion}`);
845
+ encodeInvalid(`unsupported wireVersion ${message.wireVersion}`);
838
846
  }
839
847
  validateFrameSequence(message);
840
848
  const w = new ByteWriter();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/core",
3
- "version": "0.30.10",
3
+ "version": "0.30.11",
4
4
  "description": "Core protocol types and shared utilities for the Syncular sync framework",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
package/src/bytes.ts CHANGED
@@ -245,11 +245,12 @@ export function writeStringListMap(
245
245
  }
246
246
  }
247
247
 
248
- export function readStringListMap(
248
+ function readCanonicalMap<T>(
249
249
  reader: ByteReader,
250
- ): Record<string, string[]> {
250
+ read: () => T,
251
+ ): Record<string, T> {
251
252
  const count = reader.u32();
252
- const entries: Array<[string, string[]]> = [];
253
+ const entries: Array<[string, T]> = [];
253
254
  let previousKey: string | undefined;
254
255
  for (let i = 0; i < count; i++) {
255
256
  const key = reader.str();
@@ -260,14 +261,22 @@ export function readStringListMap(
260
261
  );
261
262
  }
262
263
  previousKey = key;
263
- const valueCount = reader.u32();
264
- const values: string[] = [];
265
- for (let j = 0; j < valueCount; j++) values.push(reader.str());
266
- entries.push([key, values]);
264
+ entries.push([key, read()]);
267
265
  }
268
266
  return Object.fromEntries(entries);
269
267
  }
270
268
 
269
+ export function readStringListMap(
270
+ reader: ByteReader,
271
+ ): Record<string, string[]> {
272
+ return readCanonicalMap(reader, () => {
273
+ const count = reader.u32();
274
+ const values: string[] = [];
275
+ for (let i = 0; i < count; i++) values.push(reader.str());
276
+ return values;
277
+ });
278
+ }
279
+
271
280
  /** `map` of `str` → `str` (stored scopes on change records, SPEC.md §4.5). */
272
281
  export function writeStringMap(
273
282
  writer: ByteWriter,
@@ -283,19 +292,5 @@ export function writeStringMap(
283
292
  }
284
293
 
285
294
  export function readStringMap(reader: ByteReader): Record<string, string> {
286
- const count = reader.u32();
287
- const entries: Array<[string, string]> = [];
288
- let previousKey: string | undefined;
289
- for (let i = 0; i < count; i++) {
290
- const key = reader.str();
291
- if (previousKey !== undefined && key <= previousKey) {
292
- throw new DecodeError(
293
- 'sync.invalid_request',
294
- `map keys must be unique and in ascending code-unit order (saw ${JSON.stringify(key)} after ${JSON.stringify(previousKey)})`,
295
- );
296
- }
297
- previousKey = key;
298
- entries.push([key, reader.str()]);
299
- }
300
- return Object.fromEntries(entries);
295
+ return readCanonicalMap(reader, () => reader.str());
301
296
  }
package/src/message.ts CHANGED
@@ -257,6 +257,21 @@ export interface ResponseMessage {
257
257
 
258
258
  export type SyncMessage = RequestMessage | ResponseMessage;
259
259
 
260
+ type DefinedObject<T> = {
261
+ [K in keyof T as undefined extends T[K] ? never : K]: T[K];
262
+ } & {
263
+ [K in keyof T as undefined extends T[K] ? K : never]?: Exclude<
264
+ T[K],
265
+ undefined
266
+ >;
267
+ };
268
+ function definedObject<const T extends object>(record: T): DefinedObject<T> {
269
+ for (const key of Object.keys(record) as (keyof T)[]) {
270
+ if (record[key] === undefined) delete record[key];
271
+ }
272
+ return record as DefinedObject<T>;
273
+ }
274
+
260
275
  function invalid(message: string): never {
261
276
  throw new DecodeError('sync.invalid_request', message);
262
277
  }
@@ -288,12 +303,12 @@ function decodeReqHeader(r: ByteReader, wireVersion: number): ReqHeaderFrame {
288
303
  return value;
289
304
  })
290
305
  : undefined;
291
- return {
306
+ return definedObject({
292
307
  type: 'REQ_HEADER',
293
308
  clientId,
294
309
  schemaVersion,
295
- ...(logEpoch !== undefined ? { logEpoch } : {}),
296
- };
310
+ logEpoch: logEpoch,
311
+ });
297
312
  }
298
313
 
299
314
  function decodePushCommit(r: ByteReader): PushCommitFrame {
@@ -325,13 +340,15 @@ function decodePushCommit(r: ByteReader): PushCommitFrame {
325
340
  if (op === 'delete' && payload !== undefined) {
326
341
  invalid('delete operation must not carry a payload');
327
342
  }
328
- operations.push({
329
- table,
330
- rowId,
331
- op,
332
- ...(baseVersion !== undefined ? { baseVersion } : {}),
333
- ...(payload !== undefined ? { payload } : {}),
334
- });
343
+ operations.push(
344
+ definedObject({
345
+ table,
346
+ rowId,
347
+ op,
348
+ baseVersion: baseVersion,
349
+ payload: payload,
350
+ }),
351
+ );
335
352
  }
336
353
  return { type: 'PUSH_COMMIT', clientCommitId, operations };
337
354
  }
@@ -364,15 +381,15 @@ function decodeSubscription(r: ByteReader): SubscriptionFrame {
364
381
  const bootstrapState = r.opt(() =>
365
382
  requireJsonDocument(r.str(), 'SUBSCRIPTION.bootstrapState'),
366
383
  );
367
- return {
384
+ return definedObject({
368
385
  type: 'SUBSCRIPTION',
369
386
  id,
370
387
  table,
371
388
  scopes,
372
- ...(params !== undefined ? { params } : {}),
389
+ params: params,
373
390
  cursor,
374
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
375
- };
391
+ bootstrapState: bootstrapState,
392
+ });
376
393
  }
377
394
 
378
395
  function decodeRespHeader(r: ByteReader, wireVersion: number): RespHeaderFrame {
@@ -383,13 +400,13 @@ function decodeRespHeader(r: ByteReader, wireVersion: number): RespHeaderFrame {
383
400
  invalid('RESP_HEADER.logEpoch must be non-empty');
384
401
  }
385
402
  const resetRequired = wireVersion >= 2 ? r.bool() : undefined;
386
- return {
403
+ return definedObject({
387
404
  type: 'RESP_HEADER',
388
- ...(requiredSchemaVersion !== undefined ? { requiredSchemaVersion } : {}),
389
- ...(latestSchemaVersion !== undefined ? { latestSchemaVersion } : {}),
390
- ...(logEpoch !== undefined ? { logEpoch } : {}),
391
- ...(resetRequired !== undefined ? { resetRequired } : {}),
392
- };
405
+ requiredSchemaVersion: requiredSchemaVersion,
406
+ latestSchemaVersion: latestSchemaVersion,
407
+ logEpoch: logEpoch,
408
+ resetRequired: resetRequired,
409
+ });
393
410
  }
394
411
 
395
412
  function decodeLease(r: ByteReader): LeaseFrame {
@@ -443,13 +460,13 @@ function decodePushResult(r: ByteReader): PushResultFrame {
443
460
  invalid(`invalid push result record status byte ${recordStatus}`);
444
461
  }
445
462
  }
446
- return {
463
+ return definedObject({
447
464
  type: 'PUSH_RESULT',
448
465
  clientCommitId,
449
466
  status,
450
- ...(commitSeq !== undefined ? { commitSeq } : {}),
467
+ commitSeq: commitSeq,
451
468
  results,
452
- };
469
+ });
453
470
  }
454
471
 
455
472
  function decodePushResultDetails(r: ByteReader): PushResultDetailsFrame {
@@ -534,14 +551,16 @@ function decodeCommit(r: ByteReader): CommitFrame {
534
551
  if (op === 'delete' && (rowVersion !== undefined || row !== undefined)) {
535
552
  invalid('delete change must not carry rowVersion or row');
536
553
  }
537
- changes.push({
538
- tableIndex,
539
- rowId,
540
- op,
541
- ...(rowVersion !== undefined ? { rowVersion } : {}),
542
- scopes,
543
- ...(row !== undefined ? { row } : {}),
544
- });
554
+ changes.push(
555
+ definedObject({
556
+ tableIndex,
557
+ rowId,
558
+ op,
559
+ rowVersion: rowVersion,
560
+ scopes,
561
+ row: row,
562
+ }),
563
+ );
545
564
  }
546
565
  return { type: 'COMMIT', commitSeq, createdAtMs, actorId, tables, changes };
547
566
  }
@@ -565,7 +584,7 @@ function decodeSegmentRef(r: ByteReader): SegmentRefFrame {
565
584
  if ((url === undefined) !== (urlExpiresAtMs === undefined)) {
566
585
  invalid('SEGMENT_REF.urlExpiresAtMs must be present iff url is');
567
586
  }
568
- return {
587
+ return definedObject({
569
588
  type: 'SEGMENT_REF',
570
589
  segmentId,
571
590
  mediaType,
@@ -574,11 +593,11 @@ function decodeSegmentRef(r: ByteReader): SegmentRefFrame {
574
593
  rowCount,
575
594
  asOfCommitSeq,
576
595
  scopeDigest,
577
- ...(rowCursor !== undefined ? { rowCursor } : {}),
578
- ...(nextRowCursor !== undefined ? { nextRowCursor } : {}),
579
- ...(url !== undefined ? { url } : {}),
580
- ...(urlExpiresAtMs !== undefined ? { urlExpiresAtMs } : {}),
581
- };
596
+ rowCursor: rowCursor,
597
+ nextRowCursor: nextRowCursor,
598
+ url: url,
599
+ urlExpiresAtMs: urlExpiresAtMs,
600
+ });
582
601
  }
583
602
 
584
603
  function decodeSegmentInline(r: ByteReader): SegmentInlineFrame {
@@ -594,11 +613,11 @@ function decodeSubEnd(r: ByteReader): SubEndFrame {
594
613
  const bootstrapState = r.opt(() =>
595
614
  requireJsonDocument(r.str(), 'SUB_END.bootstrapState'),
596
615
  );
597
- return {
616
+ return definedObject({
598
617
  type: 'SUB_END',
599
618
  nextCursor,
600
- ...(bootstrapState !== undefined ? { bootstrapState } : {}),
601
- };
619
+ bootstrapState: bootstrapState,
620
+ });
602
621
  }
603
622
 
604
623
  function decodeErrorFrame(r: ByteReader): ErrorFrame {
@@ -608,15 +627,15 @@ function decodeErrorFrame(r: ByteReader): ErrorFrame {
608
627
  const retryable = r.bool();
609
628
  const recommendedAction = r.str();
610
629
  const details = r.opt(() => requireJsonDocument(r.str(), 'ERROR.details'));
611
- return {
630
+ return definedObject({
612
631
  type: 'ERROR',
613
632
  code,
614
633
  message,
615
634
  category,
616
635
  retryable,
617
636
  recommendedAction,
618
- ...(details !== undefined ? { details } : {}),
619
- };
637
+ details: details,
638
+ });
620
639
  }
621
640
 
622
641
  function decodeRequestFrame(
@@ -859,6 +878,10 @@ const PUSH_STATUS_BYTES = { applied: 1, cached: 2, rejected: 3 } as const;
859
878
  const SUB_STATUS_BYTES = { active: 1, revoked: 2, reset: 3 } as const;
860
879
  const MEDIA_TYPE_BYTES = { rows: 1, sqlite: 2 } as const;
861
880
 
881
+ function encodeInvalid(message: string): never {
882
+ throw new Error(message);
883
+ }
884
+
862
885
  function encodeFrame(
863
886
  frame: RequestFrame | ResponseFrame,
864
887
  wireVersion: number,
@@ -870,38 +893,38 @@ function encodeFrame(
870
893
  switch (frame.type) {
871
894
  case 'REQ_HEADER': {
872
895
  if (frame.clientId.length === 0) {
873
- throw new Error('REQ_HEADER.clientId must be non-empty');
896
+ encodeInvalid('REQ_HEADER.clientId must be non-empty');
874
897
  }
875
898
  if (frame.schemaVersion < 1) {
876
- throw new Error('REQ_HEADER.schemaVersion must be >= 1');
899
+ encodeInvalid('REQ_HEADER.schemaVersion must be >= 1');
877
900
  }
878
901
  w.str(frame.clientId);
879
902
  w.i32(frame.schemaVersion);
880
903
  if (wireVersion >= 2) {
881
904
  if (frame.logEpoch === '') {
882
- throw new Error('REQ_HEADER.logEpoch must be non-empty');
905
+ encodeInvalid('REQ_HEADER.logEpoch must be non-empty');
883
906
  }
884
907
  w.opt(frame.logEpoch, (v) => w.str(v));
885
908
  } else if (frame.logEpoch !== undefined) {
886
- throw new Error('REQ_HEADER.logEpoch requires wireVersion 2');
909
+ encodeInvalid('REQ_HEADER.logEpoch requires wireVersion 2');
887
910
  }
888
- return { frameType: FrameType.REQ_HEADER, payload: w.finish() };
911
+ break;
889
912
  }
890
913
  case 'PUSH_COMMIT': {
891
914
  if (frame.clientCommitId.length === 0) {
892
- throw new Error('PUSH_COMMIT.clientCommitId must be non-empty');
915
+ encodeInvalid('PUSH_COMMIT.clientCommitId must be non-empty');
893
916
  }
894
917
  if (frame.operations.length === 0) {
895
- throw new Error('PUSH_COMMIT must carry at least one operation');
918
+ encodeInvalid('PUSH_COMMIT must carry at least one operation');
896
919
  }
897
920
  w.str(frame.clientCommitId);
898
921
  w.u32(frame.operations.length);
899
922
  for (const operation of frame.operations) {
900
923
  if (operation.op === 'upsert' && operation.payload === undefined) {
901
- throw new Error('upsert operation requires a payload');
924
+ encodeInvalid('upsert operation requires a payload');
902
925
  }
903
926
  if (operation.op === 'delete' && operation.payload !== undefined) {
904
- throw new Error('delete operation must not carry a payload');
927
+ encodeInvalid('delete operation must not carry a payload');
905
928
  }
906
929
  w.str(operation.table);
907
930
  w.str(operation.rowId);
@@ -909,17 +932,17 @@ function encodeFrame(
909
932
  w.opt(operation.baseVersion, (v) => w.i64(v));
910
933
  w.opt(operation.payload, (v) => w.bytes(v));
911
934
  }
912
- return { frameType: FrameType.PUSH_COMMIT, payload: w.finish() };
935
+ break;
913
936
  }
914
937
  case 'PULL_HEADER': {
915
938
  if ((frame.accept & 0xf0) !== 0) {
916
- throw new Error('PULL_HEADER.accept bits 4-7 must be zero');
939
+ encodeInvalid('PULL_HEADER.accept bits 4-7 must be zero');
917
940
  }
918
941
  w.i32(frame.limitCommits);
919
942
  w.i32(frame.limitSnapshotRows);
920
943
  w.i32(frame.maxSnapshotPages);
921
944
  w.u8(frame.accept);
922
- return { frameType: FrameType.PULL_HEADER, payload: w.finish() };
945
+ break;
923
946
  }
924
947
  case 'SUBSCRIPTION': {
925
948
  w.str(frame.id);
@@ -928,17 +951,17 @@ function encodeFrame(
928
951
  w.opt(frame.params, (v) => w.str(v));
929
952
  w.i64(frame.cursor);
930
953
  w.opt(frame.bootstrapState, (v) => w.str(v));
931
- return { frameType: FrameType.SUBSCRIPTION, payload: w.finish() };
954
+ break;
932
955
  }
933
956
  case 'RESP_HEADER': {
934
957
  w.opt(frame.requiredSchemaVersion, (v) => w.i32(v));
935
958
  w.opt(frame.latestSchemaVersion, (v) => w.i32(v));
936
959
  if (wireVersion >= 2) {
937
960
  if (frame.logEpoch === undefined || frame.logEpoch.length === 0) {
938
- throw new Error('RESP_HEADER.logEpoch is required and non-empty');
961
+ encodeInvalid('RESP_HEADER.logEpoch is required and non-empty');
939
962
  }
940
963
  if (frame.resetRequired === undefined) {
941
- throw new Error('RESP_HEADER.resetRequired is required');
964
+ encodeInvalid('RESP_HEADER.resetRequired is required');
942
965
  }
943
966
  w.str(frame.logEpoch);
944
967
  w.bool(frame.resetRequired);
@@ -946,24 +969,24 @@ function encodeFrame(
946
969
  frame.logEpoch !== undefined ||
947
970
  frame.resetRequired !== undefined
948
971
  ) {
949
- throw new Error('RESP_HEADER epoch fields require wireVersion 2');
972
+ encodeInvalid('RESP_HEADER epoch fields require wireVersion 2');
950
973
  }
951
- return { frameType: FrameType.RESP_HEADER, payload: w.finish() };
974
+ break;
952
975
  }
953
976
  case 'LEASE': {
954
977
  if (frame.leaseId.length === 0) {
955
- throw new Error('LEASE.leaseId must be non-empty');
978
+ encodeInvalid('LEASE.leaseId must be non-empty');
956
979
  }
957
980
  w.str(frame.leaseId);
958
981
  w.i64(frame.expiresAtMs);
959
- return { frameType: FrameType.LEASE, payload: w.finish() };
982
+ break;
960
983
  }
961
984
  case 'PUSH_RESULT': {
962
985
  if (frame.status === 'rejected' && frame.commitSeq !== undefined) {
963
- throw new Error('rejected PUSH_RESULT must not carry a commitSeq');
986
+ encodeInvalid('rejected PUSH_RESULT must not carry a commitSeq');
964
987
  }
965
988
  if (frame.status !== 'rejected' && frame.commitSeq === undefined) {
966
- throw new Error(`${frame.status} PUSH_RESULT requires a commitSeq`);
989
+ encodeInvalid(`${frame.status} PUSH_RESULT requires a commitSeq`);
967
990
  }
968
991
  w.str(frame.clientCommitId);
969
992
  w.u8(PUSH_STATUS_BYTES[frame.status]);
@@ -987,33 +1010,30 @@ function encodeFrame(
987
1010
  w.bool(result.retryable);
988
1011
  }
989
1012
  }
990
- return { frameType: FrameType.PUSH_RESULT, payload: w.finish() };
1013
+ break;
991
1014
  }
992
1015
  case 'PUSH_RESULT_DETAILS': {
993
1016
  if (frame.clientCommitId.length === 0) {
994
- throw new Error('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
1017
+ encodeInvalid('PUSH_RESULT_DETAILS.clientCommitId must be non-empty');
995
1018
  }
996
1019
  if (frame.entries.length === 0) {
997
- throw new Error('PUSH_RESULT_DETAILS must carry at least one entry');
1020
+ encodeInvalid('PUSH_RESULT_DETAILS must carry at least one entry');
998
1021
  }
999
1022
  const seen = new Set<number>();
1000
1023
  w.str(frame.clientCommitId);
1001
1024
  w.u32(frame.entries.length);
1002
1025
  for (const entry of frame.entries) {
1003
1026
  if (!Number.isInteger(entry.opIndex) || entry.opIndex < 0) {
1004
- throw new Error('PUSH_RESULT_DETAILS.opIndex must be non-negative');
1027
+ encodeInvalid('PUSH_RESULT_DETAILS.opIndex must be non-negative');
1005
1028
  }
1006
1029
  if (seen.has(entry.opIndex)) {
1007
- throw new Error('PUSH_RESULT_DETAILS.opIndex must be unique');
1030
+ encodeInvalid('PUSH_RESULT_DETAILS.opIndex must be unique');
1008
1031
  }
1009
1032
  seen.add(entry.opIndex);
1010
1033
  w.i32(entry.opIndex);
1011
1034
  w.str(JSON.stringify(normalizeRejectionDetails(entry.details)));
1012
1035
  }
1013
- return {
1014
- frameType: FrameType.PUSH_RESULT_DETAILS,
1015
- payload: w.finish(),
1016
- };
1036
+ break;
1017
1037
  }
1018
1038
  case 'SUB_START': {
1019
1039
  w.str(frame.id);
@@ -1021,7 +1041,7 @@ function encodeFrame(
1021
1041
  w.str(frame.reasonCode);
1022
1042
  writeStringListMap(w, frame.effectiveScopes);
1023
1043
  w.bool(frame.bootstrap);
1024
- return { frameType: FrameType.SUB_START, payload: w.finish() };
1044
+ break;
1025
1045
  }
1026
1046
  case 'COMMIT': {
1027
1047
  w.i64(frame.commitSeq);
@@ -1032,21 +1052,19 @@ function encodeFrame(
1032
1052
  w.u32(frame.changes.length);
1033
1053
  for (const change of frame.changes) {
1034
1054
  if (change.tableIndex >= frame.tables.length) {
1035
- throw new Error(
1036
- `change tableIndex ${change.tableIndex} out of range`,
1037
- );
1055
+ encodeInvalid(`change tableIndex ${change.tableIndex} out of range`);
1038
1056
  }
1039
1057
  if (
1040
1058
  change.op === 'upsert' &&
1041
1059
  (change.rowVersion === undefined || change.row === undefined)
1042
1060
  ) {
1043
- throw new Error('upsert change requires rowVersion and row');
1061
+ encodeInvalid('upsert change requires rowVersion and row');
1044
1062
  }
1045
1063
  if (
1046
1064
  change.op === 'delete' &&
1047
1065
  (change.rowVersion !== undefined || change.row !== undefined)
1048
1066
  ) {
1049
- throw new Error('delete change must not carry rowVersion or row');
1067
+ encodeInvalid('delete change must not carry rowVersion or row');
1050
1068
  }
1051
1069
  w.u16(change.tableIndex);
1052
1070
  w.str(change.rowId);
@@ -1055,13 +1073,11 @@ function encodeFrame(
1055
1073
  writeStringMap(w, change.scopes);
1056
1074
  w.opt(change.row, (v) => w.bytes(v));
1057
1075
  }
1058
- return { frameType: FrameType.COMMIT, payload: w.finish() };
1076
+ break;
1059
1077
  }
1060
1078
  case 'SEGMENT_REF': {
1061
1079
  if ((frame.url === undefined) !== (frame.urlExpiresAtMs === undefined)) {
1062
- throw new Error(
1063
- 'SEGMENT_REF.urlExpiresAtMs must be present iff url is',
1064
- );
1080
+ encodeInvalid('SEGMENT_REF.urlExpiresAtMs must be present iff url is');
1065
1081
  }
1066
1082
  w.str(frame.segmentId);
1067
1083
  w.u8(MEDIA_TYPE_BYTES[frame.mediaType]);
@@ -1074,7 +1090,7 @@ function encodeFrame(
1074
1090
  w.opt(frame.nextRowCursor, (v) => w.str(v));
1075
1091
  w.opt(frame.url, (v) => w.str(v));
1076
1092
  w.opt(frame.urlExpiresAtMs, (v) => w.i64(v));
1077
- return { frameType: FrameType.SEGMENT_REF, payload: w.finish() };
1093
+ break;
1078
1094
  }
1079
1095
  case 'SEGMENT_INLINE': {
1080
1096
  return { frameType: FrameType.SEGMENT_INLINE, payload: frame.payload };
@@ -1082,7 +1098,7 @@ function encodeFrame(
1082
1098
  case 'SUB_END': {
1083
1099
  w.i64(frame.nextCursor);
1084
1100
  w.opt(frame.bootstrapState, (v) => w.str(v));
1085
- return { frameType: FrameType.SUB_END, payload: w.finish() };
1101
+ break;
1086
1102
  }
1087
1103
  case 'ERROR': {
1088
1104
  w.str(frame.code);
@@ -1091,7 +1107,7 @@ function encodeFrame(
1091
1107
  w.bool(frame.retryable);
1092
1108
  w.str(frame.recommendedAction);
1093
1109
  w.opt(frame.details, (v) => w.str(v));
1094
- return { frameType: FrameType.ERROR, payload: w.finish() };
1110
+ break;
1095
1111
  }
1096
1112
  case 'UNKNOWN': {
1097
1113
  if (
@@ -1100,13 +1116,14 @@ function encodeFrame(
1100
1116
  frame.frameType > 0xff ||
1101
1117
  KNOWN_FRAME_TYPES.has(frame.frameType)
1102
1118
  ) {
1103
- throw new Error(
1119
+ encodeInvalid(
1104
1120
  `UNKNOWN frame type ${frame.frameType} collides with a defined frame type`,
1105
1121
  );
1106
1122
  }
1107
1123
  return { frameType: frame.frameType, payload: frame.payload };
1108
1124
  }
1109
1125
  }
1126
+ return { frameType: FrameType[frame.type], payload: w.finish() };
1110
1127
  }
1111
1128
 
1112
1129
  // ---------------------------------------------------------------------------
@@ -1115,7 +1132,7 @@ function encodeFrame(
1115
1132
 
1116
1133
  export function encodeMessage(message: SyncMessage): Uint8Array {
1117
1134
  if (!isSupportedProtocolWireVersion(message.wireVersion)) {
1118
- throw new Error(`unsupported wireVersion ${message.wireVersion}`);
1135
+ encodeInvalid(`unsupported wireVersion ${message.wireVersion}`);
1119
1136
  }
1120
1137
  validateFrameSequence(message);
1121
1138
  const w = new ByteWriter();