@evolu/common 6.0.1-preview.1 → 6.0.1-preview.10

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.
@@ -1,5 +1,5 @@
1
1
  import { isNonEmptyArray, isNonEmptyReadonlyArray } from "../Array.js";
2
- import { assertNonEmptyArray } from "../Assert.js";
2
+ import { assert, assertNonEmptyArray } from "../Assert.js";
3
3
  import { createCallbacks } from "../Callbacks.js";
4
4
  import { ConsoleDep } from "../Console.js";
5
5
  import { SymmetricCryptoDecryptError } from "../Crypto.js";
@@ -44,9 +44,9 @@ import {
44
44
  SubscribedQueries,
45
45
  } from "./Query.js";
46
46
  import {
47
- assertValidEvoluSchema,
48
47
  CreateQuery,
49
48
  EvoluSchema,
49
+ evoluSchemaToDbSchema,
50
50
  insertable,
51
51
  Mutation,
52
52
  MutationKind,
@@ -54,7 +54,7 @@ import {
54
54
  MutationOptions,
55
55
  updateable,
56
56
  upsertable,
57
- validEvoluSchemaToDbSchema,
57
+ ValidateSchema,
58
58
  ValidMutationSize,
59
59
  ValidMutationSizeError,
60
60
  } from "./Schema.js";
@@ -88,7 +88,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
88
88
  * All this function does is compile the Kysely query and serialize it into a
89
89
  * unique string. Both operations are fast and cheap.
90
90
  *
91
- * For mutations, use {@link Evolu.insert} and {@link Evolu.update}.
91
+ * For mutations, use {@link Evolu#insert} and {@link Evolu#update}.
92
92
  *
93
93
  * ### Example
94
94
  *
@@ -111,7 +111,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
111
111
  * A returned promise always resolves successfully because there is no reason
112
112
  * why loading should fail. All data are local, and the query is typed. A
113
113
  * serious unexpected Evolu error shall be handled with
114
- * {@link Evolu.subscribeError}.
114
+ * {@link Evolu#subscribeError}.
115
115
  *
116
116
  * Loading is batched, and returned promises are cached, so there is no need
117
117
  * for an additional cache. Evolu's internal cache is invalidated on
@@ -144,7 +144,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
144
144
  * If you are curious why Evolu does not do that for all queries by default,
145
145
  * the answer is simple: performance. Tracking changes is costly and
146
146
  * meaningful only for visible (hence subscribed) queries anyway. To subscribe
147
- * to a query, use {@link Evolu.subscribeQuery}.
147
+ * to a query, use {@link Evolu#subscribeQuery}.
148
148
  *
149
149
  * ### Example
150
150
  *
@@ -300,7 +300,7 @@ export interface Evolu<S extends EvoluSchema = EvoluSchema> {
300
300
 
301
301
  /**
302
302
  * Restore {@link AppOwner} with all their synced data. It uses
303
- * {@link Evolu.resetAppOwner}, so be careful.
303
+ * {@link Evolu#resetAppOwner}, so be careful.
304
304
  */
305
305
  readonly restoreAppOwner: (
306
306
  mnemonic: Mnemonic,
@@ -431,9 +431,7 @@ let tabId: Id | null = null;
431
431
  export const createEvolu =
432
432
  (deps: EvoluDeps) =>
433
433
  <S extends EvoluSchema>(
434
- // TODO: Validate missing Id, unsupported types, used default types via TS types
435
- // with type errors messages as we had it in the old Evolu.
436
- schema: S,
434
+ schema: ValidateSchema<S> extends never ? S : ValidateSchema<S>,
437
435
  partialConfig: Partial<EvoluConfigWithInitialData<S>> = {},
438
436
  ): Evolu<S> => {
439
437
  const config = { ...defaultConfig, ...partialConfig };
@@ -441,11 +439,14 @@ export const createEvolu =
441
439
  let evolu = evoluInstances.get(config.name);
442
440
 
443
441
  if (evolu == null) {
444
- evolu = createEvoluInstance(deps)(schema, config as IntentionalNever);
442
+ evolu = createEvoluInstance(deps)(
443
+ schema as EvoluSchema,
444
+ config as IntentionalNever,
445
+ );
445
446
  evoluInstances.set(config.name, evolu);
446
447
  } else {
447
448
  // Hot reloading. Note that indexes are intentionally omitted.
448
- evolu.ensureSchema(schema);
449
+ evolu.ensureSchema(schema as EvoluSchema);
449
450
  }
450
451
 
451
452
  return evolu as IntentionalNever;
@@ -554,10 +555,7 @@ const createEvoluInstance =
554
555
  }
555
556
  });
556
557
 
557
- const dbSchema = validEvoluSchemaToDbSchema(
558
- assertValidEvoluSchema(schema),
559
- indexes,
560
- );
558
+ const dbSchema = evoluSchemaToDbSchema(schema, indexes);
561
559
 
562
560
  const mutationTypesCache = new Map<
563
561
  MutationKind,
@@ -592,21 +590,17 @@ const createEvoluInstance =
592
590
  if (initialData)
593
591
  initialData({
594
592
  insert: (table, props) => {
595
- const Type = getMutationType(table, "insert");
596
593
  const id = createId(deps);
594
+ const values = getMutationType(table, "insert").fromUnknown(props);
597
595
 
598
- const result = Type.fromUnknown(props);
599
-
600
- if (result.ok) {
601
- initialDataDbChanges.push({
602
- id,
603
- table,
604
- values: result.value,
605
- } as unknown as DbChange);
596
+ if (values.ok) {
597
+ const dbChange = { table, id, values: values.value };
598
+ assertValidDbChange(dbChange);
599
+ initialDataDbChanges.push(dbChange);
606
600
  return ok({ id });
607
601
  }
608
602
 
609
- return result;
603
+ return values;
610
604
  },
611
605
  });
612
606
 
@@ -654,14 +648,14 @@ const createEvoluInstance =
654
648
  } else {
655
649
  // Remove `id` from values.
656
650
  const { id: _id, ...values } = result.value;
657
- // EvoluSchema Types ensure valid types.
658
- const change = { table, id, values } as unknown as DbChange;
659
- mutateMicrotaskQueue.push([change, options?.onComplete]);
651
+ const dbChange = { table, id, values };
652
+ assertValidDbChange(dbChange);
653
+ mutateMicrotaskQueue.push([dbChange, options?.onComplete]);
660
654
  }
661
655
 
662
656
  if (mutateMicrotaskQueue.length === 1)
663
657
  queueMicrotask(() => {
664
- const changes = [];
658
+ const changes: Array<DbChange> = [];
665
659
  const onCompletes = [];
666
660
 
667
661
  for (const [change, onComplete] of mutateMicrotaskQueue) {
@@ -807,6 +801,7 @@ const createEvoluInstance =
807
801
  const onCompleteId = callbacks.register(() => {
808
802
  resolve();
809
803
  });
804
+
810
805
  dbWorker.postMessage({
811
806
  type: "reset",
812
807
  onCompleteId,
@@ -822,11 +817,8 @@ const createEvoluInstance =
822
817
 
823
818
  ensureSchema: (schema) => {
824
819
  mutationTypesCache.clear();
825
- const validSchema = assertValidEvoluSchema(schema);
826
- dbWorker.postMessage({
827
- type: "ensureDbSchema",
828
- dbSchema: validEvoluSchemaToDbSchema(validSchema),
829
- });
820
+ const dbSchema = evoluSchemaToDbSchema(schema);
821
+ dbWorker.postMessage({ type: "ensureDbSchema", dbSchema });
830
822
  },
831
823
 
832
824
  exportDatabase: () => {
@@ -945,3 +937,14 @@ const createLoadingPromises = (
945
937
 
946
938
  return loadingPromises;
947
939
  };
940
+
941
+ const assertValidDbChange: (dbChange: {
942
+ table: string;
943
+ id: Id;
944
+ values: unknown;
945
+ }) => asserts dbChange is DbChange = (dbChange) => {
946
+ assert(
947
+ DbChange.is(dbChange),
948
+ `Failed to create DbChange for table "${dbChange.table}". If you see this message, you either disabled EvoluSchema validation or Evolu has a bug - please report it.`,
949
+ );
950
+ };
@@ -15,21 +15,39 @@
15
15
  *
16
16
  * ### Message Structure
17
17
  *
18
- * | Field | Notes |
19
- * | :----------------------------- | :------------------------- |
20
- * | **Header** | |
21
- * | - {@link protocolVersion} | |
22
- * | - {@link OwnerId} | |
23
- * | - {@link ProtocolErrorCode} | In non-initiator response. |
24
- * | **Messages** | |
25
- * | - {@link NonNegativeInt} | A number of messages. |
26
- * | - {@link EncryptedCrdtMessage} | |
27
- * | - {@link WriteKey} | In initiator request. |
28
- * | **Ranges** | |
29
- * | - {@link NonNegativeInt} | Number of ranges. |
30
- * | - {@link Range} | |
18
+ * | Field | Notes |
19
+ * | :----------------------------- | :------------------------ |
20
+ * | **Header** | |
21
+ * | - {@link protocolVersion} | |
22
+ * | - {@link OwnerId} | {@link Owner} |
23
+ * | **Initiator** | |
24
+ * | - {@link WriteKeyMode} | |
25
+ * | - {@link WriteKey} | If WriteKeyMode >= 1 |
26
+ * | - {@link WriteKey} | If WriteKeyMode = 2 (new) |
27
+ * | **Non-initiator** | |
28
+ * | - {@link ProtocolErrorCode} | |
29
+ * | **Messages** | |
30
+ * | - {@link NonNegativeInt} | A number of messages. |
31
+ * | - {@link EncryptedCrdtMessage} | |
32
+ * | **Ranges** | |
33
+ * | - {@link NonNegativeInt} | Number of ranges. |
34
+ * | - {@link Range} | |
31
35
  *
32
- * Every protocol message belongs to an {@link Owner}.
36
+ * ### WriteKey Validation
37
+ *
38
+ * The initiator sends WriteKeyMode and optionally one or two WriteKeys. One key
39
+ * for write operations and two for key rotation (current and new). Note that
40
+ * it's ok to not send any key if initiator is going to be synced with readonly
41
+ * owner. The non-initiator validates them immediately after parsing the
42
+ * initiator header, before processing any messages or ranges.
43
+ *
44
+ * ### WriteKey Rotation
45
+ *
46
+ * When initiator's {@link WriteKeyMode} is `Rotation`, two WriteKeys are
47
+ * present:
48
+ *
49
+ * 1. Current WriteKey (for validation)
50
+ * 2. New WriteKey (to be stored)
33
51
  *
34
52
  * ### Synchronization
35
53
  *
@@ -127,10 +145,8 @@ import { isNonEmptyReadonlyArray, NonEmptyReadonlyArray } from "../Array.js";
127
145
  import { assert } from "../Assert.js";
128
146
  import {
129
147
  Buffer,
130
- BufferError,
131
148
  bytesToHex,
132
149
  bytesToUtf8,
133
- concatBytes,
134
150
  createBuffer,
135
151
  hexToBytes,
136
152
  utf8ToBytes,
@@ -144,7 +160,7 @@ import {
144
160
  } from "../Crypto.js";
145
161
  import { eqArrayNumber } from "../Eq.js";
146
162
  import { computeBalancedBuckets } from "../Number.js";
147
- import { objectToEntries, ReadonlyRecord } from "../Object.js";
163
+ import { objectToEntries } from "../Object.js";
148
164
  import { err, ok, Result } from "../Result.js";
149
165
  import { SqliteValue } from "../Sqlite.js";
150
166
  import {
@@ -157,11 +173,12 @@ import {
157
173
  NanoId,
158
174
  NonNegativeInt,
159
175
  Number,
176
+ object,
160
177
  PositiveInt,
178
+ record,
161
179
  } from "../Type.js";
162
180
  import { Brand, Predicate } from "../Types.js";
163
181
  import {
164
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
165
182
  Owner,
166
183
  OwnerId,
167
184
  OwnerWithWriteAccess,
@@ -203,6 +220,14 @@ export const ProtocolErrorCode = {
203
220
  type ProtocolErrorCode =
204
221
  (typeof ProtocolErrorCode)[keyof typeof ProtocolErrorCode];
205
222
 
223
+ export const WriteKeyMode = {
224
+ None: 0,
225
+ Single: 1,
226
+ Rotation: 2,
227
+ } as const;
228
+
229
+ type WriteKeyMode = (typeof WriteKeyMode)[keyof typeof WriteKeyMode];
230
+
206
231
  /**
207
232
  * Evolu Protocol Storage
208
233
  *
@@ -247,17 +272,15 @@ export interface Storage {
247
272
  callback: (timestamp: BinaryTimestamp, index: NonNegativeInt) => boolean,
248
273
  ) => void;
249
274
 
250
- /**
251
- * Authorizes the initiator's {@link WriteKey} for the given
252
- * {@link BinaryOwnerId}.
253
- *
254
- * For a client that does not expect foreign writes, return `false`.
255
- */
275
+ /** Validates the {@link WriteKey} for the given {@link Owner}. */
256
276
  readonly validateWriteKey: (
257
277
  ownerId: BinaryOwnerId,
258
278
  writeKey: WriteKey,
259
279
  ) => boolean;
260
280
 
281
+ /** Sets the {@link WriteKey} for the given {@link Owner}. */
282
+ readonly setWriteKey: (ownerId: BinaryOwnerId, writeKey: WriteKey) => boolean;
283
+
261
284
  /** Write encrypted {@link CrdtMessage}s to storage. */
262
285
  readonly writeMessages: (
263
286
  ownerId: BinaryOwnerId,
@@ -269,6 +292,9 @@ export interface Storage {
269
292
  ownerId: BinaryOwnerId,
270
293
  timestamp: BinaryTimestamp,
271
294
  ) => EncryptedDbChange | null;
295
+
296
+ /** Delete all data for the given {@link Owner}. */
297
+ readonly deleteOwner: (ownerId: BinaryOwnerId) => boolean;
272
298
  }
273
299
 
274
300
  export interface StorageDep {
@@ -293,15 +319,23 @@ export interface CrdtMessage {
293
319
  readonly change: DbChange;
294
320
  }
295
321
 
322
+ /**
323
+ * Base64Url string with maximum length of 256 characters. Encoding strings as
324
+ * Base64UrlString saves up to 25% in size compared to regular strings.
325
+ */
326
+ export const Base64Url256 = maxLength(256)(Base64Url);
327
+ export type Base64Url256 = typeof Base64Url256.Type;
328
+
296
329
  /**
297
330
  * A DbChange is a change to a table row. Together with a unique
298
331
  * {@link Timestamp}, it forms a {@link CrdtMessage}.
299
332
  */
300
- export interface DbChange {
301
- readonly table: Base64Url256;
302
- readonly id: Id;
303
- readonly values: ReadonlyRecord<Base64Url256, SqliteValue>;
304
- }
333
+ export const DbChange = object({
334
+ table: Base64Url256,
335
+ id: Id,
336
+ values: record(Base64Url256, SqliteValue),
337
+ });
338
+ export type DbChange = typeof DbChange.Type;
305
339
 
306
340
  export const RangeType = {
307
341
  Fingerprint: 1,
@@ -425,6 +459,7 @@ export const createProtocolMessageFromCrdtMessages =
425
459
  maxSize?: PositiveInt,
426
460
  ): ProtocolMessage => {
427
461
  const buffer = createProtocolMessageBuffer(owner.id, {
462
+ type: "initiator",
428
463
  totalMaxSize: maxSize ?? maxProtocolMessageSize,
429
464
  writeKey: owner.writeKey,
430
465
  });
@@ -480,7 +515,7 @@ export const createProtocolMessageFromCrdtMessages =
480
515
  export const createProtocolMessageForSync =
481
516
  (deps: StorageDep) =>
482
517
  (ownerId: OwnerId): ProtocolMessage | null => {
483
- const buffer = createProtocolMessageBuffer(ownerId);
518
+ const buffer = createProtocolMessageBuffer(ownerId, { type: "initiator" });
484
519
  const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
485
520
 
486
521
  const size = deps.storage.getSize(binaryOwnerId);
@@ -498,6 +533,19 @@ export const createProtocolMessageForSync =
498
533
  return buffer.unwrap();
499
534
  };
500
535
 
536
+ /** Creates a ProtocolMessage for {@link WriteKey} rotation. */
537
+ export const createProtocolMessageForWriteKeyRotation = (
538
+ ownerId: OwnerId,
539
+ currentWriteKey: WriteKey,
540
+ newWriteKey: WriteKey,
541
+ ): ProtocolMessage => {
542
+ const buffer = createProtocolMessageBuffer(ownerId, {
543
+ type: "initiator",
544
+ writeKey: [currentWriteKey, newWriteKey],
545
+ });
546
+ return buffer.unwrap();
547
+ };
548
+
501
549
  /**
502
550
  * Mutable builder for constructing {@link ProtocolMessage} respecting size
503
551
  * limits.
@@ -525,16 +573,22 @@ export interface ProtocolMessageBuffer {
525
573
  export const createProtocolMessageBuffer = (
526
574
  ownerId: OwnerId,
527
575
  options: {
528
- readonly errorCode?: ProtocolErrorCode;
529
- readonly writeKey?: WriteKey;
530
576
  readonly totalMaxSize?: PositiveInt | undefined;
531
577
  readonly rangesMaxSize?: PositiveInt | undefined;
532
578
  readonly version?: NonNegativeInt;
533
- } = {},
579
+ } & (
580
+ | {
581
+ readonly type: "initiator";
582
+ /** Single key or [current, new] for rotation. */
583
+ readonly writeKey?: WriteKey | readonly [WriteKey, WriteKey];
584
+ }
585
+ | {
586
+ readonly type: "non-initiator";
587
+ readonly errorCode: ProtocolErrorCode;
588
+ }
589
+ ),
534
590
  ): ProtocolMessageBuffer => {
535
591
  const {
536
- errorCode,
537
- writeKey,
538
592
  totalMaxSize = maxProtocolMessageSize,
539
593
  rangesMaxSize = maxProtocolMessageRangesSize,
540
594
  version = protocolVersion,
@@ -555,7 +609,21 @@ export const createProtocolMessageBuffer = (
555
609
 
556
610
  encodeNonNegativeInt(buffers.header, version);
557
611
  buffers.header.extend(ownerIdToBinaryOwnerId(ownerId));
558
- if (errorCode != null) buffers.header.extend([errorCode]);
612
+
613
+ if (options.type === "initiator") {
614
+ if (!options.writeKey) {
615
+ buffers.header.extend([WriteKeyMode.None]);
616
+ } else if (!Array.isArray(options.writeKey)) {
617
+ buffers.header.extend([WriteKeyMode.Single]);
618
+ buffers.header.extend(options.writeKey as WriteKey);
619
+ } else {
620
+ buffers.header.extend([WriteKeyMode.Rotation]);
621
+ buffers.header.extend(options.writeKey[0] as WriteKey); // current
622
+ buffers.header.extend(options.writeKey[1] as WriteKey); // new
623
+ }
624
+ } else {
625
+ buffers.header.extend([options.errorCode]);
626
+ }
559
627
 
560
628
  let isLastRangeInfinite = false;
561
629
 
@@ -567,10 +635,7 @@ export const createProtocolMessageBuffer = (
567
635
  const getHeaderAndMessagesSize = () =>
568
636
  buffers.header.getLength() +
569
637
  buffers.messages.timestamps.getLength() +
570
- buffers.messages.dbChanges.getLength() +
571
- (buffers.messages.timestamps.getCount() > 0 && writeKey
572
- ? writeKeyLength
573
- : 0);
638
+ buffers.messages.dbChanges.getLength();
574
639
 
575
640
  const getRangesSize = () =>
576
641
  buffers.ranges.timestamps.getCount() > 0
@@ -685,8 +750,6 @@ export const createProtocolMessageBuffer = (
685
750
 
686
751
  buffers.messages.timestamps.append(buffers.header);
687
752
  buffers.header.extend(buffers.messages.dbChanges.unwrap());
688
- if (buffers.messages.timestamps.getCount() > 0 && writeKey)
689
- buffers.header.extend(writeKey);
690
753
 
691
754
  if (buffers.ranges.timestamps.getCount() > 0) {
692
755
  buffers.ranges.timestamps.append(buffers.header);
@@ -836,8 +899,6 @@ export const applyProtocolMessageAsClient =
836
899
  });
837
900
  }
838
901
 
839
- const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
840
-
841
902
  const errorCode = input.shift() as ProtocolErrorCode;
842
903
  if (errorCode !== ProtocolErrorCode.NoError) {
843
904
  switch (errorCode) {
@@ -864,6 +925,7 @@ export const applyProtocolMessageAsClient =
864
925
  }
865
926
 
866
927
  const messages = decodeMessages(input);
928
+ const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
867
929
 
868
930
  if (
869
931
  isNonEmptyReadonlyArray(messages) &&
@@ -877,6 +939,7 @@ export const applyProtocolMessageAsClient =
877
939
  if (writeKey == null) return ok(null);
878
940
 
879
941
  const output = createProtocolMessageBuffer(ownerId, {
942
+ type: "initiator",
880
943
  writeKey,
881
944
  totalMaxSize,
882
945
  rangesMaxSize,
@@ -911,8 +974,7 @@ export const applyProtocolMessageAsRelay =
911
974
  version = protocolVersion,
912
975
  ): Result<ProtocolMessage | null, ProtocolInvalidDataError> =>
913
976
  tryDecodeProtocolData(inputMessage, (input) => {
914
- const requestedVersion = decodeNonNegativeInt(input);
915
- const ownerId = decodeOwnerId(input);
977
+ const [requestedVersion, ownerId] = decodeVersionAndOwner(input);
916
978
  const binaryOwnerId = ownerIdToBinaryOwnerId(ownerId);
917
979
 
918
980
  if (requestedVersion !== version) {
@@ -925,44 +987,89 @@ export const applyProtocolMessageAsRelay =
925
987
 
926
988
  subscribe?.(ownerId);
927
989
 
928
- const messages = decodeMessages(input);
990
+ const writeKeyMode = input.shift() as WriteKeyMode;
991
+ let writeKey: WriteKey | undefined;
992
+ let newWriteKey: WriteKey | undefined;
993
+
994
+ if (writeKeyMode !== WriteKeyMode.None) {
995
+ writeKey = input.shiftN(writeKeyLength) as WriteKey;
996
+ switch (writeKeyMode) {
997
+ case WriteKeyMode.Single:
998
+ break;
999
+ case WriteKeyMode.Rotation:
1000
+ newWriteKey = input.shiftN(writeKeyLength) as WriteKey;
1001
+ break;
1002
+ default:
1003
+ throw new ProtocolDecodeError(
1004
+ `Invalid WriteKeyMode: ${writeKeyMode}`,
1005
+ );
1006
+ }
1007
+ }
929
1008
 
930
- if (isNonEmptyReadonlyArray(messages)) {
931
- const messagesEnd = inputMessage.length - input.getLength();
932
- const writeKey = input.shiftN(writeKeyLength) as WriteKey;
1009
+ if (writeKey) {
1010
+ const isValid = deps.storage.validateWriteKey(binaryOwnerId, writeKey);
1011
+ if (!isValid) {
1012
+ return ok(
1013
+ createProtocolMessageBuffer(ownerId, {
1014
+ type: "non-initiator",
1015
+ errorCode: ProtocolErrorCode.WriteKeyError,
1016
+ }).unwrap(),
1017
+ );
1018
+ }
933
1019
 
934
- const writeKeyIsValid = deps.storage.validateWriteKey(
935
- binaryOwnerId,
936
- writeKey,
937
- );
1020
+ if (newWriteKey) {
1021
+ const rotationSuccess = deps.storage.setWriteKey(
1022
+ binaryOwnerId,
1023
+ newWriteKey,
1024
+ );
1025
+ if (!rotationSuccess) {
1026
+ return ok(
1027
+ createProtocolMessageBuffer(ownerId, {
1028
+ type: "non-initiator",
1029
+ errorCode: ProtocolErrorCode.WriteError,
1030
+ }).unwrap(),
1031
+ );
1032
+ }
1033
+ }
1034
+ }
1035
+
1036
+ const messages = decodeMessages(input);
938
1037
 
939
- if (!writeKeyIsValid)
1038
+ if (isNonEmptyReadonlyArray(messages)) {
1039
+ if (!writeKey)
940
1040
  return ok(
941
1041
  createProtocolMessageBuffer(ownerId, {
1042
+ type: "non-initiator",
942
1043
  errorCode: ProtocolErrorCode.WriteKeyError,
943
1044
  }).unwrap(),
944
1045
  );
945
1046
 
946
- if (broadcast) {
947
- // Instead of encoding a new protocol message, we reuse the inputMessage.
948
- const broadcastMessage = concatBytes(
949
- inputMessage.slice(0, 17),
950
- new Uint8Array([ProtocolErrorCode.NoError]),
951
- inputMessage.slice(17, messagesEnd),
952
- ) as ProtocolMessage;
953
-
954
- broadcast(ownerId, broadcastMessage);
1047
+ // Only broadcast if there's no ranges.
1048
+ if (broadcast && input.getLength() === 0) {
1049
+ const broadcastBuffer = createProtocolMessageBuffer(ownerId, {
1050
+ type: "non-initiator",
1051
+ errorCode: ProtocolErrorCode.NoError,
1052
+ totalMaxSize,
1053
+ rangesMaxSize,
1054
+ version,
1055
+ });
1056
+ for (const message of messages) {
1057
+ broadcastBuffer.addMessage(message);
1058
+ }
1059
+ broadcast(ownerId, broadcastBuffer.unwrap());
955
1060
  }
956
1061
 
957
1062
  if (!deps.storage.writeMessages(binaryOwnerId, messages))
958
1063
  return ok(
959
1064
  createProtocolMessageBuffer(ownerId, {
1065
+ type: "non-initiator",
960
1066
  errorCode: ProtocolErrorCode.WriteError,
961
1067
  }).unwrap(),
962
1068
  );
963
1069
  }
964
1070
 
965
1071
  const output = createProtocolMessageBuffer(ownerId, {
1072
+ type: "non-initiator",
966
1073
  errorCode: ProtocolErrorCode.NoError,
967
1074
  totalMaxSize,
968
1075
  rangesMaxSize,
@@ -983,18 +1090,19 @@ const tryDecodeProtocolData = <T, E>(
983
1090
  try {
984
1091
  return callback(createBuffer(data));
985
1092
  } catch (error: unknown) {
986
- if (error instanceof ProtocolDecodeError || error instanceof BufferError)
987
- return err<ProtocolInvalidDataError>({
988
- type: "ProtocolInvalidDataError",
989
- data,
990
- error,
991
- });
992
-
993
- throw error;
1093
+ return err<ProtocolInvalidDataError>({
1094
+ type: "ProtocolInvalidDataError",
1095
+ data,
1096
+ error,
1097
+ });
994
1098
  }
995
1099
  };
996
1100
 
997
1101
  const decodeVersionAndOwner = (input: Buffer): [NonNegativeInt, OwnerId] => {
1102
+ // This structure must never change across protocol versions. The version
1103
+ // and owner ID must always be the first two fields in every protocol message
1104
+ // to enable version negotiation and owner identification before any other
1105
+ // processing occurs.
998
1106
  const version = decodeNonNegativeInt(input);
999
1107
  const ownerId = decodeOwnerId(input);
1000
1108
  return [version, ownerId];
@@ -1052,6 +1160,7 @@ const sync =
1052
1160
  return ok(null);
1053
1161
  }
1054
1162
  const message = createProtocolMessageBuffer(binaryOwnerId, {
1163
+ type: "non-initiator",
1055
1164
  errorCode: ProtocolErrorCode.SyncError,
1056
1165
  });
1057
1166
  return ok(message.unwrap());
@@ -1434,13 +1543,6 @@ export const ownerIdToBinaryOwnerId = (ownerId: OwnerId): BinaryOwnerId =>
1434
1543
  export const binaryOwnerIdToOwnerId = (binaryOwnerId: BinaryOwnerId): OwnerId =>
1435
1544
  decodeOwnerId(createBuffer(binaryOwnerId));
1436
1545
 
1437
- /**
1438
- * Base64Url string with maximum length of 256 characters. Encoding strings as
1439
- * Base64UrlString saves up to 25% in size compared to regular strings.
1440
- */
1441
- export const Base64Url256 = maxLength(256)(Base64Url);
1442
- export type Base64Url256 = typeof Base64Url256.Type;
1443
-
1444
1546
  /**
1445
1547
  * Union type for all variants of Base64Url strings with limited length. All
1446
1548
  * these types use Base64Url alphabet and are < 256 characters.
@@ -1895,3 +1997,27 @@ export const decodeSqliteValue = (buffer: Buffer): SqliteValue => {
1895
1997
  throw new ProtocolDecodeError("invalid ProtocolValueType");
1896
1998
  }
1897
1999
  };
2000
+
2001
+ /**
2002
+ * Decodes a ProtocolMessage into a readable JSON object for debugging.
2003
+ *
2004
+ * Note: This is a stub for future implementation. It should use:
2005
+ *
2006
+ * - DecodeVersionAndOwner
2007
+ * - DecodeError or decodeWriteKeys (depending on context)
2008
+ * - DecodeMessages
2009
+ * - DecodeRanges
2010
+ *
2011
+ * If you want to help, please contribute to this function.
2012
+ */
2013
+ export const decodeProtocolMessageToJson = (
2014
+ _protocolMessage: ProtocolMessage,
2015
+ _isInitiator: boolean,
2016
+ ): unknown => {
2017
+ // TODO: Implement using
2018
+ // - decodeVersionAndOwner
2019
+ // -- decodeError or decodeWriteKeys (should be refactored out),
2020
+ // -- decodeMessages, and decodeRanges.
2021
+ // This is a stub for PRs and community contributions.
2022
+ throw new Error("decodeProtocolMessageToJson is not implemented yet.");
2023
+ };