@loro-dev/streams-crdt 0.6.0 → 0.7.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.
@@ -184,7 +184,8 @@ const DEFAULT_RECONNECT_CONFIG = {
184
184
  jitterFraction: .2,
185
185
  maxAttempts: Infinity,
186
186
  connectTimeoutMs: 1e4,
187
- pollTimeoutMs: 3e4
187
+ pollTimeoutMs: 3e4,
188
+ liveInactivityTimeoutMs: 45e3
188
189
  };
189
190
  /**
190
191
  * Apply jitter of +/- fraction to a base delay.
@@ -205,6 +206,127 @@ function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
205
206
  return applyJitter(base, jitterFraction);
206
207
  }
207
208
  //#endregion
209
+ //#region src/deque.ts
210
+ const DEFAULT_COMPACT_THRESHOLD = 64;
211
+ const DEFAULT_MIN_CAPACITY = 16;
212
+ var Deque = class {
213
+ buffer;
214
+ head = 0;
215
+ tail = 0;
216
+ compactThreshold;
217
+ minCapacity;
218
+ constructor(values, options = {}) {
219
+ this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
220
+ this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
221
+ const seed = values == null ? [] : Array.from(values);
222
+ this.buffer = seed.slice();
223
+ this.tail = seed.length;
224
+ }
225
+ get length() {
226
+ return this.tail - this.head;
227
+ }
228
+ isEmpty() {
229
+ return this.length === 0;
230
+ }
231
+ clear() {
232
+ this.buffer = [];
233
+ this.head = 0;
234
+ this.tail = 0;
235
+ }
236
+ peekFront() {
237
+ if (this.length === 0) return;
238
+ const value = this.buffer[this.head];
239
+ return value == null ? void 0 : value;
240
+ }
241
+ peekBack() {
242
+ if (this.length === 0) return;
243
+ const value = this.buffer[this.tail - 1];
244
+ return value == null ? void 0 : value;
245
+ }
246
+ at(index) {
247
+ const length = this.length;
248
+ if (length === 0) return;
249
+ let offset = index;
250
+ if (offset < 0) offset += length;
251
+ if (offset < 0 || offset >= length) return;
252
+ const value = this.buffer[this.head + offset];
253
+ return value == null ? void 0 : value;
254
+ }
255
+ pushBack(value) {
256
+ this.ensureBackCapacity();
257
+ this.buffer[this.tail] = value;
258
+ this.tail += 1;
259
+ return this.length;
260
+ }
261
+ pushFront(value) {
262
+ if (this.head === 0) this.recenter(1, 0);
263
+ this.head -= 1;
264
+ this.buffer[this.head] = value;
265
+ return this.length;
266
+ }
267
+ popFront() {
268
+ if (this.length === 0) return;
269
+ const value = this.buffer[this.head];
270
+ this.buffer[this.head] = null;
271
+ this.head += 1;
272
+ this.compactIfNeeded();
273
+ return value == null ? void 0 : value;
274
+ }
275
+ popBack() {
276
+ if (this.length === 0) return;
277
+ this.tail -= 1;
278
+ const value = this.buffer[this.tail];
279
+ this.buffer[this.tail] = null;
280
+ this.compactIfNeeded();
281
+ return value == null ? void 0 : value;
282
+ }
283
+ toArray() {
284
+ const out = [];
285
+ for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
286
+ return out;
287
+ }
288
+ *[Symbol.iterator]() {
289
+ for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
290
+ }
291
+ ensureBackCapacity() {
292
+ if (this.tail < this.buffer.length) return;
293
+ if (this.head > 0) {
294
+ this.compactToFront();
295
+ if (this.tail < this.buffer.length) return;
296
+ }
297
+ this.recenter(0, 1);
298
+ }
299
+ recenter(extraFront, extraBack) {
300
+ const length = this.length;
301
+ const needed = length + extraFront + extraBack;
302
+ const currentCapacity = this.buffer.length;
303
+ let nextCapacity = Math.max(currentCapacity, this.minCapacity);
304
+ if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
305
+ const free = nextCapacity - length;
306
+ const nextHead = Math.max(extraFront, Math.floor(free / 2));
307
+ const next = new Array(nextCapacity);
308
+ for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
309
+ this.buffer = next;
310
+ this.head = nextHead;
311
+ this.tail = nextHead + length;
312
+ }
313
+ compactIfNeeded() {
314
+ const length = this.length;
315
+ if (length === 0) {
316
+ this.clear();
317
+ return;
318
+ }
319
+ if (this.head < this.compactThreshold || this.head < length) return;
320
+ this.compactToFront();
321
+ }
322
+ compactToFront() {
323
+ const length = this.length;
324
+ this.buffer = this.buffer.slice(this.head, this.tail);
325
+ this.head = 0;
326
+ this.tail = length;
327
+ }
328
+ };
329
+ //#endregion
208
330
  //#region src/items.ts
209
331
  const BINARY_CONTENT_TYPE = "application/octet-stream";
210
332
  function encodeItems(items) {
@@ -235,6 +357,290 @@ function decodeItems(payload) {
235
357
  return out;
236
358
  }
237
359
  //#endregion
360
+ //#region src/payload-protection.ts
361
+ const MAGIC = new Uint8Array([
362
+ 76,
363
+ 83,
364
+ 67,
365
+ 69
366
+ ]);
367
+ const ENVELOPE_VERSION = 1;
368
+ const SUITE_AES_256_GCM = 1;
369
+ const UPDATE_BATCH_KIND = 1;
370
+ const SNAPSHOT_KIND = 2;
371
+ const AES_GCM_NONCE_BYTES = 12;
372
+ const HEADER_BYTES = 10;
373
+ const AES_GCM_TAG_BITS = 128;
374
+ const RAW_AES_256_KEY_BYTES = 32;
375
+ const textEncoder$1 = new TextEncoder();
376
+ const textDecoder = new TextDecoder();
377
+ var PayloadProtectionError = class extends Error {
378
+ reason;
379
+ keyId;
380
+ constructor(reason, message, options = {}) {
381
+ super(message, options.cause == null ? void 0 : { cause: options.cause });
382
+ this.name = "PayloadProtectionError";
383
+ this.reason = reason;
384
+ this.keyId = options.keyId;
385
+ }
386
+ };
387
+ var PayloadProtectionRuntime = class {
388
+ readPolicy;
389
+ writePolicy;
390
+ scope;
391
+ writeKey;
392
+ readKeys;
393
+ importedWriteKey;
394
+ importedReadKeys = /* @__PURE__ */ new Map();
395
+ constructor(options) {
396
+ this.readPolicy = options.readPolicy;
397
+ this.writePolicy = options.writePolicy;
398
+ this.scope = options.scope;
399
+ this.writeKey = options.writeKey;
400
+ this.readKeys = options.readKeys;
401
+ }
402
+ async encodeUpdateItems(clearUpdates) {
403
+ if (this.writePolicy === "plaintext") return clearUpdates;
404
+ return [await this.encryptPayload("update_batch", encodeItems(clearUpdates))];
405
+ }
406
+ measureEncodedUpdateItemsByteLength(clearUpdates) {
407
+ if (this.writePolicy === "plaintext") return encodedItemsByteLength(clearUpdates);
408
+ const writeKey = this.writeKey;
409
+ if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
410
+ const clearBatchBytes = encodedItemsByteLength(clearUpdates);
411
+ const keyIdBytes = textEncoder$1.encode(writeKey.id);
412
+ if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId: writeKey.id });
413
+ return 4 + (HEADER_BYTES + keyIdBytes.byteLength + AES_GCM_NONCE_BYTES + clearBatchBytes + AES_GCM_TAG_BITS / 8);
414
+ }
415
+ async decodeUpdateItems(wireItems) {
416
+ const out = [];
417
+ for (const item of wireItems) {
418
+ if (isEncryptedEnvelope(item)) {
419
+ const clearBatch = await this.decryptPayload("update_batch", item);
420
+ out.push(...decodeItems(clearBatch));
421
+ continue;
422
+ }
423
+ this.assertPlaintextAllowed("update batch");
424
+ out.push(item);
425
+ }
426
+ return out;
427
+ }
428
+ async encodeSnapshot(snapshot) {
429
+ if (this.writePolicy === "plaintext") return snapshot;
430
+ return await this.encryptPayload("snapshot", snapshot);
431
+ }
432
+ async decodeSnapshot(snapshot) {
433
+ if (isEncryptedEnvelope(snapshot)) return await this.decryptPayload("snapshot", snapshot);
434
+ this.assertPlaintextAllowed("snapshot");
435
+ return snapshot;
436
+ }
437
+ async encryptPayload(kind, plaintext) {
438
+ const writeKey = this.writeKey;
439
+ if (writeKey == null || this.scope == null) throw new PayloadProtectionError("encrypt_failed", "E2EE write encryption requires encryption.writeKey and encryption.scope");
440
+ try {
441
+ const key = await (this.importedWriteKey ??= importAesGcmKey(writeKey.key));
442
+ const nonce = createNonce();
443
+ return concatBytes(createEnvelopeHeader(kind, writeKey.id, nonce), new Uint8Array(await getSubtleCrypto().encrypt({
444
+ name: "AES-GCM",
445
+ iv: toArrayBuffer(nonce),
446
+ tagLength: AES_GCM_TAG_BITS,
447
+ additionalData: toArrayBuffer(createAad({
448
+ kind,
449
+ keyId: writeKey.id,
450
+ scope: this.scope
451
+ }))
452
+ }, key, toArrayBuffer(plaintext))));
453
+ } catch (error) {
454
+ if (error instanceof PayloadProtectionError) throw error;
455
+ throw new PayloadProtectionError("encrypt_failed", "failed to encrypt streams-crdt payload", {
456
+ keyId: writeKey.id,
457
+ cause: error
458
+ });
459
+ }
460
+ }
461
+ async decryptPayload(expectedKind, envelope) {
462
+ const parsed = parseEnvelope(envelope);
463
+ if (parsed.kind !== expectedKind) throw new PayloadProtectionError("wrong_payload_kind", `expected ${expectedKind} payload, got ${parsed.kind}`, { keyId: parsed.keyId });
464
+ if (this.scope == null) throw new PayloadProtectionError("missing_read_key", "E2EE read decryption requires encryption.scope", { keyId: parsed.keyId });
465
+ const key = await this.getReadKey(parsed.keyId);
466
+ try {
467
+ return new Uint8Array(await getSubtleCrypto().decrypt({
468
+ name: "AES-GCM",
469
+ iv: toArrayBuffer(parsed.nonce),
470
+ tagLength: AES_GCM_TAG_BITS,
471
+ additionalData: toArrayBuffer(createAad({
472
+ kind: parsed.kind,
473
+ keyId: parsed.keyId,
474
+ scope: this.scope
475
+ }))
476
+ }, key, toArrayBuffer(parsed.ciphertext)));
477
+ } catch (error) {
478
+ throw new PayloadProtectionError("decrypt_failed", "failed to decrypt streams-crdt payload", {
479
+ keyId: parsed.keyId,
480
+ cause: error
481
+ });
482
+ }
483
+ }
484
+ async getReadKey(keyId) {
485
+ const cached = this.importedReadKeys.get(keyId);
486
+ if (cached != null) return await cached;
487
+ const imported = importAesGcmKey((await this.resolveReadKey(keyId)).key);
488
+ this.importedReadKeys.set(keyId, imported);
489
+ return await imported;
490
+ }
491
+ async resolveReadKey(keyId) {
492
+ for (const key of await this.resolveReadKeys()) if (key.id === keyId) return key;
493
+ throw new PayloadProtectionError("missing_read_key", `missing read key for protected streams-crdt payload '${keyId}'`, { keyId });
494
+ }
495
+ async resolveReadKeys() {
496
+ const readKeys = typeof this.readKeys === "function" ? await this.readKeys() : this.readKeys;
497
+ if (readKeys != null) return readKeys;
498
+ return this.writeKey == null ? [] : [this.writeKey];
499
+ }
500
+ assertPlaintextAllowed(label) {
501
+ if (this.readPolicy === "allow-plaintext") return;
502
+ throw new PayloadProtectionError("plaintext_forbidden", `received plaintext streams-crdt ${label} while readPolicy is encrypted-only`);
503
+ }
504
+ };
505
+ function normalizePayloadProtection(options, label = "payloadProtection") {
506
+ if (options == null) return;
507
+ const readPolicy = options.readPolicy ?? "encrypted-only";
508
+ const writePolicy = options.writePolicy ?? "encrypt";
509
+ if (readPolicy !== "encrypted-only" && readPolicy !== "allow-plaintext") throw new Error(`${label}.readPolicy must be encrypted-only or allow-plaintext`);
510
+ if (writePolicy !== "encrypt" && writePolicy !== "plaintext") throw new Error(`${label}.writePolicy must be encrypt or plaintext`);
511
+ const encryption = options.encryption;
512
+ if (encryption != null && encryption.scope == null) throw new Error(`${label}.encryption.scope is required when encryption is configured`);
513
+ if (writePolicy === "encrypt" && encryption?.writeKey == null) throw new Error(`${label}.encryption.writeKey is required when writePolicy is encrypt`);
514
+ if (writePolicy === "encrypt" && encryption?.scope == null) throw new Error(`${label}.encryption.scope is required when writePolicy is encrypt`);
515
+ validateKey(encryption?.writeKey, `${label}.encryption.writeKey`);
516
+ const readKeys = normalizeReadKeys(encryption?.readKeys, `${label}.encryption.readKeys`);
517
+ return new PayloadProtectionRuntime({
518
+ readPolicy,
519
+ writePolicy,
520
+ scope: encryption?.scope,
521
+ writeKey: encryption?.writeKey,
522
+ readKeys
523
+ });
524
+ }
525
+ function isPayloadProtectionError(error) {
526
+ return error instanceof PayloadProtectionError;
527
+ }
528
+ function isEncryptedEnvelope(payload) {
529
+ return payload.byteLength >= MAGIC.byteLength && MAGIC.every((byte, index) => payload[index] === byte);
530
+ }
531
+ function createEnvelopeHeader(kind, keyId, nonce) {
532
+ const keyIdBytes = textEncoder$1.encode(keyId);
533
+ if (keyIdBytes.byteLength === 0 || keyIdBytes.byteLength > 255) throw new PayloadProtectionError("encrypt_failed", "E2EE key id must be 1..255 UTF-8 bytes", { keyId });
534
+ if (nonce.byteLength !== AES_GCM_NONCE_BYTES) throw new PayloadProtectionError("encrypt_failed", `AES-GCM nonce must be ${AES_GCM_NONCE_BYTES} bytes`, { keyId });
535
+ const header = new Uint8Array(HEADER_BYTES + keyIdBytes.byteLength + nonce.byteLength);
536
+ header.set(MAGIC, 0);
537
+ header[4] = ENVELOPE_VERSION;
538
+ header[5] = encodeKind(kind);
539
+ header[6] = SUITE_AES_256_GCM;
540
+ header[7] = keyIdBytes.byteLength;
541
+ header[8] = nonce.byteLength;
542
+ header[9] = 0;
543
+ header.set(keyIdBytes, HEADER_BYTES);
544
+ header.set(nonce, HEADER_BYTES + keyIdBytes.byteLength);
545
+ return header;
546
+ }
547
+ function parseEnvelope(payload) {
548
+ if (!isEncryptedEnvelope(payload) || payload.byteLength < HEADER_BYTES) throw new PayloadProtectionError("invalid_envelope", "invalid streams-crdt encrypted payload envelope");
549
+ const version = payload[4];
550
+ const suite = payload[6];
551
+ const keyIdLen = payload[7] ?? 0;
552
+ const nonceLen = payload[8] ?? 0;
553
+ const reserved = payload[9];
554
+ if (version !== ENVELOPE_VERSION || suite !== SUITE_AES_256_GCM || nonceLen !== AES_GCM_NONCE_BYTES || reserved !== 0) throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload envelope");
555
+ const kind = decodeKind(payload[5]);
556
+ const keyIdStart = HEADER_BYTES;
557
+ const nonceStart = keyIdStart + keyIdLen;
558
+ const ciphertextStart = nonceStart + nonceLen;
559
+ if (keyIdLen === 0 || payload.byteLength <= ciphertextStart) throw new PayloadProtectionError("invalid_envelope", "truncated streams-crdt encrypted payload envelope");
560
+ return {
561
+ kind,
562
+ keyId: textDecoder.decode(payload.slice(keyIdStart, nonceStart)),
563
+ nonce: payload.slice(nonceStart, ciphertextStart),
564
+ ciphertext: payload.slice(ciphertextStart)
565
+ };
566
+ }
567
+ function encodeKind(kind) {
568
+ return kind === "update_batch" ? UPDATE_BATCH_KIND : SNAPSHOT_KIND;
569
+ }
570
+ function decodeKind(encoded) {
571
+ if (encoded === UPDATE_BATCH_KIND) return "update_batch";
572
+ if (encoded === SNAPSHOT_KIND) return "snapshot";
573
+ throw new PayloadProtectionError("invalid_envelope", "unsupported streams-crdt encrypted payload kind");
574
+ }
575
+ function createNonce() {
576
+ const nonce = new Uint8Array(AES_GCM_NONCE_BYTES);
577
+ getCrypto().getRandomValues(nonce);
578
+ return nonce;
579
+ }
580
+ function createAad(input) {
581
+ return textEncoder$1.encode(JSON.stringify({
582
+ keyId: input.keyId,
583
+ kind: input.kind,
584
+ protocol: "loro-streams-crdt-payload-protection",
585
+ scope: normalizeScope(input.scope),
586
+ suite: "aes_256_gcm",
587
+ version: ENVELOPE_VERSION
588
+ }));
589
+ }
590
+ function normalizeScope(scope) {
591
+ return typeof scope === "string" ? scope : `bucket:${scope.bucketId}\nstream:${scope.streamId}`;
592
+ }
593
+ function concatBytes(left, right) {
594
+ const out = new Uint8Array(left.byteLength + right.byteLength);
595
+ out.set(left);
596
+ out.set(right, left.byteLength);
597
+ return out;
598
+ }
599
+ function encodedItemsByteLength(items) {
600
+ return items.reduce((sum, item) => sum + 4 + item.byteLength, 0);
601
+ }
602
+ async function importAesGcmKey(key) {
603
+ if (isCryptoKey(key)) return key;
604
+ validateRawKey(key);
605
+ return await getSubtleCrypto().importKey("raw", toArrayBuffer(key), "AES-GCM", false, ["encrypt", "decrypt"]);
606
+ }
607
+ function toArrayBuffer(bytes) {
608
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
609
+ }
610
+ function validateKey(key, label) {
611
+ if (key == null) return;
612
+ if (key.id.trim().length === 0) throw new Error(`${label}.id must be non-empty`);
613
+ if (key.key instanceof Uint8Array) validateRawKey(key.key);
614
+ }
615
+ function normalizeReadKeys(readKeys, label) {
616
+ if (readKeys == null) return;
617
+ if (typeof readKeys === "function") return async () => {
618
+ const resolved = await readKeys();
619
+ validateKeys(resolved, label);
620
+ return resolved;
621
+ };
622
+ validateKeys(readKeys, label);
623
+ return readKeys;
624
+ }
625
+ function validateKeys(keys, label) {
626
+ for (const [index, key] of keys.entries()) validateKey(key, `${label}[${index}]`);
627
+ }
628
+ function isCryptoKey(key) {
629
+ return typeof CryptoKey !== "undefined" && key instanceof CryptoKey;
630
+ }
631
+ function validateRawKey(key) {
632
+ if (key.byteLength !== RAW_AES_256_KEY_BYTES) throw new Error(`E2EE raw AES-GCM keys must be ${RAW_AES_256_KEY_BYTES} bytes`);
633
+ }
634
+ function getCrypto() {
635
+ if (globalThis.crypto == null) throw new PayloadProtectionError("encrypt_failed", "Web Crypto is unavailable in this runtime");
636
+ return globalThis.crypto;
637
+ }
638
+ function getSubtleCrypto() {
639
+ const subtle = getCrypto().subtle;
640
+ if (subtle == null) throw new PayloadProtectionError("encrypt_failed", "SubtleCrypto is unavailable in this runtime");
641
+ return subtle;
642
+ }
643
+ //#endregion
238
644
  //#region src/transport-internal.ts
239
645
  function createProducerId() {
240
646
  if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
@@ -381,6 +787,12 @@ function classifyFetchError(err) {
381
787
  retriable: true,
382
788
  atMs: Date.now()
383
789
  };
790
+ if (isPayloadProtectionError(err)) return {
791
+ code: "protocol",
792
+ message: err.message,
793
+ retriable: false,
794
+ atMs: Date.now()
795
+ };
384
796
  return {
385
797
  code: "internal",
386
798
  message,
@@ -429,6 +841,13 @@ function toTransportError(error) {
429
841
  retryable: true,
430
842
  message: error.message
431
843
  };
844
+ if (isPayloadProtectionError(error)) return {
845
+ code: "payload_protection_error",
846
+ retryable: false,
847
+ reason: error.reason,
848
+ keyId: error.keyId,
849
+ message: error.message
850
+ };
432
851
  if (error instanceof TypeError) return {
433
852
  code: "network_error",
434
853
  retryable: true,
@@ -677,13 +1096,29 @@ var StreamsTransportClient = class {
677
1096
  async readItemsSse(initialOffset, cursor, onControl, onOpen, signal) {
678
1097
  const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal);
679
1098
  if (opened.kind !== "ok") return opened;
680
- const result = await opened.session.continue(onControl);
681
- if (result.paused) await opened.session.cancel();
682
- return {
683
- kind: "ok",
684
- latestOffset: result.latestOffset,
685
- latestCursor: result.latestCursor
686
- };
1099
+ let abortHandler;
1100
+ const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
1101
+ abortHandler = () => {
1102
+ opened.session.cancel();
1103
+ reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
1104
+ };
1105
+ signal.addEventListener("abort", abortHandler, { once: true });
1106
+ }) : null;
1107
+ try {
1108
+ if (signal?.aborted) {
1109
+ await opened.session.cancel();
1110
+ throw signal.reason ?? new DOMException("The operation was aborted.", "AbortError");
1111
+ }
1112
+ const result = abortRace != null ? await Promise.race([opened.session.continue(onControl), abortRace]) : await opened.session.continue(onControl);
1113
+ if (result.paused) await opened.session.cancel();
1114
+ return {
1115
+ kind: "ok",
1116
+ latestOffset: result.latestOffset,
1117
+ latestCursor: result.latestCursor
1118
+ };
1119
+ } finally {
1120
+ if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
1121
+ }
687
1122
  }
688
1123
  async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
689
1124
  const result = await this.client.openSseSession({
@@ -765,16 +1200,19 @@ var StreamsTransportClient = class {
765
1200
  var TransportCursorManager = class {
766
1201
  adapter;
767
1202
  decodeSnapshot;
1203
+ decodeUpdateItems;
768
1204
  saveRemoteCursor;
769
1205
  constructor(options) {
770
1206
  this.adapter = options.adapter;
771
1207
  this.decodeSnapshot = options.decodeSnapshot;
1208
+ this.decodeUpdateItems = options.decodeUpdateItems;
772
1209
  this.saveRemoteCursor = options.saveRemoteCursor;
773
1210
  }
774
1211
  async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
775
1212
  let nextVersion = cursor.serverLowerBoundVersion;
776
1213
  if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot));
777
- if (updates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates([...updates], nextVersion);
1214
+ const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1215
+ if (clearUpdates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
778
1216
  return await this.finalizeCursor({
779
1217
  ...cursor,
780
1218
  nextOffset,
@@ -794,8 +1232,9 @@ var TransportCursorManager = class {
794
1232
  async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
795
1233
  const previousVersion = cursor.serverLowerBoundVersion;
796
1234
  let nextVersion = previousVersion;
797
- if (result.updates.length > 0) {
798
- nextVersion = await this.adapter.applyRemoteUpdates([...result.updates], nextVersion);
1235
+ const clearUpdates = await this.decodeRemoteUpdateItems(result.updates);
1236
+ if (clearUpdates.length > 0) {
1237
+ nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
799
1238
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
800
1239
  }
801
1240
  return await this.finalizeCursor({
@@ -807,8 +1246,9 @@ var TransportCursorManager = class {
807
1246
  async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
808
1247
  const previousVersion = cursor.serverLowerBoundVersion;
809
1248
  let nextVersion = previousVersion;
810
- if (updates.length > 0) {
811
- nextVersion = await this.adapter.applyRemoteUpdates([...updates], nextVersion);
1249
+ const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1250
+ if (clearUpdates.length > 0) {
1251
+ nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
812
1252
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
813
1253
  }
814
1254
  return await this.finalizeCursor({
@@ -820,126 +1260,9 @@ var TransportCursorManager = class {
820
1260
  async finalizeCursor(cursor, source) {
821
1261
  return await this.saveRemoteCursor(cursor, source);
822
1262
  }
823
- };
824
- //#endregion
825
- //#region src/deque.ts
826
- const DEFAULT_COMPACT_THRESHOLD = 64;
827
- const DEFAULT_MIN_CAPACITY = 16;
828
- var Deque = class {
829
- buffer;
830
- head = 0;
831
- tail = 0;
832
- compactThreshold;
833
- minCapacity;
834
- constructor(values, options = {}) {
835
- this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
836
- this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
837
- const seed = values == null ? [] : Array.from(values);
838
- this.buffer = seed.slice();
839
- this.tail = seed.length;
840
- }
841
- get length() {
842
- return this.tail - this.head;
843
- }
844
- isEmpty() {
845
- return this.length === 0;
846
- }
847
- clear() {
848
- this.buffer = [];
849
- this.head = 0;
850
- this.tail = 0;
851
- }
852
- peekFront() {
853
- if (this.length === 0) return;
854
- const value = this.buffer[this.head];
855
- return value == null ? void 0 : value;
856
- }
857
- peekBack() {
858
- if (this.length === 0) return;
859
- const value = this.buffer[this.tail - 1];
860
- return value == null ? void 0 : value;
861
- }
862
- at(index) {
863
- const length = this.length;
864
- if (length === 0) return;
865
- let offset = index;
866
- if (offset < 0) offset += length;
867
- if (offset < 0 || offset >= length) return;
868
- const value = this.buffer[this.head + offset];
869
- return value == null ? void 0 : value;
870
- }
871
- pushBack(value) {
872
- this.ensureBackCapacity();
873
- this.buffer[this.tail] = value;
874
- this.tail += 1;
875
- return this.length;
876
- }
877
- pushFront(value) {
878
- if (this.head === 0) this.recenter(1, 0);
879
- this.head -= 1;
880
- this.buffer[this.head] = value;
881
- return this.length;
882
- }
883
- popFront() {
884
- if (this.length === 0) return;
885
- const value = this.buffer[this.head];
886
- this.buffer[this.head] = null;
887
- this.head += 1;
888
- this.compactIfNeeded();
889
- return value == null ? void 0 : value;
890
- }
891
- popBack() {
892
- if (this.length === 0) return;
893
- this.tail -= 1;
894
- const value = this.buffer[this.tail];
895
- this.buffer[this.tail] = null;
896
- this.compactIfNeeded();
897
- return value == null ? void 0 : value;
898
- }
899
- toArray() {
900
- const out = [];
901
- for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
902
- return out;
903
- }
904
- *[Symbol.iterator]() {
905
- for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
906
- }
907
- ensureBackCapacity() {
908
- if (this.tail < this.buffer.length) return;
909
- if (this.head > 0) {
910
- this.compactToFront();
911
- if (this.tail < this.buffer.length) return;
912
- }
913
- this.recenter(0, 1);
914
- }
915
- recenter(extraFront, extraBack) {
916
- const length = this.length;
917
- const needed = length + extraFront + extraBack;
918
- const currentCapacity = this.buffer.length;
919
- let nextCapacity = Math.max(currentCapacity, this.minCapacity);
920
- if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
921
- const free = nextCapacity - length;
922
- const nextHead = Math.max(extraFront, Math.floor(free / 2));
923
- const next = new Array(nextCapacity);
924
- for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
925
- this.buffer = next;
926
- this.head = nextHead;
927
- this.tail = nextHead + length;
928
- }
929
- compactIfNeeded() {
930
- const length = this.length;
931
- if (length === 0) {
932
- this.clear();
933
- return;
934
- }
935
- if (this.head < this.compactThreshold || this.head < length) return;
936
- this.compactToFront();
937
- }
938
- compactToFront() {
939
- const length = this.length;
940
- this.buffer = this.buffer.slice(this.head, this.tail);
941
- this.head = 0;
942
- this.tail = length;
1263
+ async decodeRemoteUpdateItems(items) {
1264
+ if (items.length === 0) return [];
1265
+ return this.decodeUpdateItems == null ? [...items] : await this.decodeUpdateItems(items);
943
1266
  }
944
1267
  };
945
1268
  //#endregion
@@ -1074,6 +1397,8 @@ function createJoinState(cursor, liveMode = "sse") {
1074
1397
  refCount: 0,
1075
1398
  closed: false,
1076
1399
  status: "connecting",
1400
+ readSubStatus: "connecting",
1401
+ writeSubStatus: "ok",
1077
1402
  statusListeners: /* @__PURE__ */ new Set(),
1078
1403
  pendingLocal: new Deque(),
1079
1404
  flushingLocal: false,
@@ -1083,7 +1408,8 @@ function createJoinState(cursor, liveMode = "sse") {
1083
1408
  liveMode,
1084
1409
  snapshot: createTransportSnapshotState(),
1085
1410
  started: Promise.resolve(),
1086
- syncWaiters: []
1411
+ syncWaiters: [],
1412
+ initialSyncFailed: false
1087
1413
  };
1088
1414
  }
1089
1415
  async function disposeJoinState(state, options) {
@@ -1094,6 +1420,7 @@ async function disposeJoinState(state, options) {
1094
1420
  const unsubscribeLocal = state.unsubscribeLocal;
1095
1421
  state.unsubscribeLocal = void 0;
1096
1422
  requestController?.abort();
1423
+ state.retrySleepController.abort();
1097
1424
  unsubscribeLocal?.();
1098
1425
  const disposedError = /* @__PURE__ */ new Error("subscription closed");
1099
1426
  for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
@@ -1113,6 +1440,10 @@ var StreamsCrdt = class StreamsCrdt {
1113
1440
  cursorManager;
1114
1441
  volatileCursorManager;
1115
1442
  decodeSnapshot;
1443
+ encodeSnapshot;
1444
+ decodeUpdateItems;
1445
+ encodeUpdateItems;
1446
+ measureUpdateItemsByteLength;
1116
1447
  reconnectConfig;
1117
1448
  debug;
1118
1449
  beforeRemoteCursorSave;
@@ -1120,6 +1451,10 @@ var StreamsCrdt = class StreamsCrdt {
1120
1451
  producerId = createProducerId();
1121
1452
  producerEpoch = 0;
1122
1453
  nextProducerSeq = 0;
1454
+ pendingWriteOnly = new Deque();
1455
+ writeOnlyCursor;
1456
+ unsubscribeWriteOnlyLocal;
1457
+ writeOnlyDisabledReason;
1123
1458
  joinState;
1124
1459
  opQueue = Promise.resolve();
1125
1460
  /**
@@ -1144,8 +1479,17 @@ var StreamsCrdt = class StreamsCrdt {
1144
1479
  this.debug = options.debug ?? false;
1145
1480
  this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
1146
1481
  const snapshotCodec = normalizeSnapshotCodec(options.snapshotCodec);
1482
+ if (options.e2ee != null && options.payloadProtection != null) throw new Error("Specify only one of e2ee or payloadProtection");
1483
+ const payloadProtectionOption = options.e2ee ?? options.payloadProtection;
1484
+ const payloadProtectionLabel = options.e2ee != null ? "e2ee" : "payloadProtection";
1485
+ const payloadProtection = normalizePayloadProtection(payloadProtectionOption, payloadProtectionLabel);
1486
+ if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error(`snapshotUpload is not supported when ${payloadProtectionLabel}.writePolicy is plaintext`);
1147
1487
  const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
1148
- this.decodeSnapshot = snapshotCodec?.decompress;
1488
+ this.decodeSnapshot = this.composeSnapshotDecoder(payloadProtection, snapshotCodec?.decompress);
1489
+ this.encodeSnapshot = this.composeSnapshotEncoder(payloadProtection, snapshotCodec?.compress);
1490
+ this.decodeUpdateItems = payloadProtection == null ? void 0 : (items) => payloadProtection.decodeUpdateItems(items);
1491
+ this.encodeUpdateItems = payloadProtection == null ? void 0 : (updates) => payloadProtection.encodeUpdateItems(updates);
1492
+ this.measureUpdateItemsByteLength = payloadProtection == null ? void 0 : (updates) => payloadProtection.measureEncodedUpdateItemsByteLength(updates);
1149
1493
  this.client = new StreamsTransportClient({
1150
1494
  streamUrl: this.streamUrl,
1151
1495
  adapter: this.adapter,
@@ -1158,7 +1502,7 @@ var StreamsCrdt = class StreamsCrdt {
1158
1502
  this.snapshotManager = new TransportSnapshotManager({
1159
1503
  adapter: this.adapter,
1160
1504
  config: snapshotUploadConfig,
1161
- encodeSnapshot: snapshotCodec?.compress,
1505
+ encodeSnapshot: this.encodeSnapshot,
1162
1506
  readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
1163
1507
  putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
1164
1508
  enqueueExclusive: (task) => this.enqueueExclusive(task),
@@ -1166,11 +1510,31 @@ var StreamsCrdt = class StreamsCrdt {
1166
1510
  logError: (message, error, detail) => this.logError(message, error, detail),
1167
1511
  isCurrentState: (state) => this.joinState === state
1168
1512
  });
1513
+ this.writeOnlyCursor = this.createInitialCursor();
1514
+ this.unsubscribeWriteOnlyLocal = this.adapter.subscribeLocalUpdates((batch) => {
1515
+ if (this.writeOnlyDisabledReason != null) return;
1516
+ this.pendingWriteOnly.pushBack(batch);
1517
+ });
1518
+ }
1519
+ composeSnapshotDecoder(payloadProtection, decompressSnapshot) {
1520
+ if (payloadProtection == null && decompressSnapshot == null) return;
1521
+ return async (snapshot) => {
1522
+ const clearSnapshot = payloadProtection == null ? snapshot : await payloadProtection.decodeSnapshot(snapshot);
1523
+ return decompressSnapshot == null ? clearSnapshot : await decompressSnapshot(clearSnapshot);
1524
+ };
1525
+ }
1526
+ composeSnapshotEncoder(payloadProtection, compressSnapshot) {
1527
+ if (payloadProtection == null && compressSnapshot == null) return;
1528
+ return async (snapshot) => {
1529
+ const compressedSnapshot = compressSnapshot == null ? snapshot : await compressSnapshot(snapshot);
1530
+ return payloadProtection == null ? compressedSnapshot : await payloadProtection.encodeSnapshot(compressedSnapshot);
1531
+ };
1169
1532
  }
1170
1533
  /**
1171
1534
  * Stops any active live join and releases transport-owned state.
1172
1535
  */
1173
1536
  async close() {
1537
+ this.disableWriteOnlyMode("transport closed");
1174
1538
  const state = this.joinState;
1175
1539
  if (state != null) await this.disposeJoinState(state);
1176
1540
  }
@@ -1178,22 +1542,51 @@ var StreamsCrdt = class StreamsCrdt {
1178
1542
  * Resets the retry counter and immediately attempts to reconnect.
1179
1543
  *
1180
1544
  * Use this after device wake or network recovery to skip any pending
1181
- * backoff delay. No-op when the room is already joined or no join is active.
1545
+ * backoff delay. No-op when the room is already fully healthy (both
1546
+ * read and write sub-statuses are "ok") or no join is active.
1182
1547
  *
1183
- * If the room reached `"disconnected"` status (only possible with a finite
1184
- * `maxAttempts` config), this restarts the read loop.
1548
+ * If the room reached `"disconnected"` or `"error"` status, this
1549
+ * restarts the affected loop(s).
1185
1550
  */
1186
1551
  rejoin() {
1187
1552
  const state = this.joinState;
1188
- if (state == null || state.closed || state.status === "joined") return;
1189
- const wasDisconnected = state.status === "disconnected";
1553
+ if (state == null || state.closed) return;
1554
+ if (state.initialSyncFailed) {
1555
+ state.initialSyncFailed = false;
1556
+ state.lastError = void 0;
1557
+ state.retryAttempt = 0;
1558
+ state.retrySleepController.abort();
1559
+ state.retrySleepController = new AbortController();
1560
+ state.readSubStatus = "connecting";
1561
+ state.writeSubStatus = "ok";
1562
+ this.recomputeJoinStatus(state);
1563
+ state.started = this.startJoin(state);
1564
+ state.started.catch((error) => {
1565
+ if (state.closed) return;
1566
+ const transportError = toTransportError(error);
1567
+ if (transportError.retryable) {
1568
+ state.initialSyncFailed = true;
1569
+ state.lastError = transportError;
1570
+ } else this.disposeJoinState(state);
1571
+ });
1572
+ return;
1573
+ }
1574
+ if ((state.readSubStatus === "ok" || state.readSubStatus === "connecting") && state.writeSubStatus === "ok") return;
1190
1575
  state.retryAttempt = 0;
1191
1576
  state.retrySleepController.abort();
1192
1577
  state.retrySleepController = new AbortController();
1193
- if (wasDisconnected) {
1194
- this.setJoinStatus(state, "reconnecting");
1578
+ const previousController = state.requestController;
1579
+ state.requestController = new AbortController();
1580
+ previousController?.abort();
1581
+ if (state.readSubStatus === "disconnected" || state.readSubStatus === "error") {
1582
+ this.setReadSubStatus(state, "reconnecting");
1195
1583
  this.runReadLoop(state);
1196
1584
  }
1585
+ if (state.writeSubStatus === "error") {
1586
+ state.writeSubStatus = state.pendingLocal.length > 0 ? "reconnecting" : "ok";
1587
+ this.recomputeJoinStatus(state);
1588
+ if (state.pendingLocal.length > 0) this.flushPendingLocal(state);
1589
+ }
1197
1590
  }
1198
1591
  /**
1199
1592
  * Reads the latest remote snapshot offset, if the server exposes one.
@@ -1212,6 +1605,33 @@ var StreamsCrdt = class StreamsCrdt {
1212
1605
  }
1213
1606
  }
1214
1607
  /**
1608
+ * Uploads a full adapter snapshot at the current remote tail.
1609
+ *
1610
+ * @internal Intended for end-to-end tests that must deterministically create
1611
+ * a remote snapshot without waiting for the best-effort join() debounce.
1612
+ */
1613
+ async uploadSnapshotForTesting() {
1614
+ try {
1615
+ await this.adapter.flushLocalExports?.();
1616
+ const cursor = await this.syncOnce();
1617
+ const rawSnapshot = this.adapter.exportSnapshot();
1618
+ const snapshot = this.encodeSnapshot == null ? rawSnapshot : await this.encodeSnapshot(rawSnapshot);
1619
+ await this.client.putSnapshotAt(cursor.nextOffset, snapshot);
1620
+ return {
1621
+ ok: true,
1622
+ value: {
1623
+ snapshotOffset: cursor.nextOffset,
1624
+ snapshotByteLength: snapshot.byteLength
1625
+ }
1626
+ };
1627
+ } catch (error) {
1628
+ return {
1629
+ ok: false,
1630
+ error: toTransportError(error)
1631
+ };
1632
+ }
1633
+ }
1634
+ /**
1215
1635
  * Explicitly creates the target stream.
1216
1636
  */
1217
1637
  async createStream() {
@@ -1255,6 +1675,7 @@ var StreamsCrdt = class StreamsCrdt {
1255
1675
  */
1256
1676
  async sync() {
1257
1677
  try {
1678
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after sync() on the same transport");
1258
1679
  return {
1259
1680
  ok: true,
1260
1681
  value: { cursor: await this.syncOnce() }
@@ -1267,27 +1688,75 @@ var StreamsCrdt = class StreamsCrdt {
1267
1688
  }
1268
1689
  }
1269
1690
  /**
1691
+ * Appends local CRDT updates without reading the stream first.
1692
+ *
1693
+ * Use this for write-only actors whose auth token may POST to the stream but
1694
+ * may not bootstrap, catch up, or join live reads. The method does not save a
1695
+ * RemoteCursorStore cursor and does not apply remote updates.
1696
+ */
1697
+ async appendWriteOnly() {
1698
+ return await this.enqueueExclusive(async () => {
1699
+ try {
1700
+ if (this.writeOnlyDisabledReason != null) throw new Error(this.writeOnlyDisabledReason);
1701
+ await this.adapter.flushLocalExports?.();
1702
+ const drained = await this.drainPendingWriteOnlyBatch();
1703
+ if (drained == null) return {
1704
+ ok: true,
1705
+ value: {
1706
+ appended: false,
1707
+ localVersion: this.writeOnlyCursor.serverLowerBoundVersion
1708
+ }
1709
+ };
1710
+ const nextCursor = await this.appendLocalBatch(this.writeOnlyCursor, drained.batch, false);
1711
+ for (let i = 0; i < drained.count; i += 1) this.pendingWriteOnly.popFront();
1712
+ this.writeOnlyCursor = nextCursor;
1713
+ return {
1714
+ ok: true,
1715
+ value: {
1716
+ appended: true,
1717
+ nextOffset: nextCursor.nextOffset,
1718
+ localVersion: nextCursor.serverLowerBoundVersion
1719
+ }
1720
+ };
1721
+ } catch (error) {
1722
+ return {
1723
+ ok: false,
1724
+ error: toTransportError(error)
1725
+ };
1726
+ }
1727
+ });
1728
+ }
1729
+ /**
1270
1730
  * Replays remote state, flushes queued local updates, then enters live read mode.
1271
1731
  */
1272
1732
  async join(params) {
1733
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after join() on the same transport");
1273
1734
  const existing = this.joinState;
1274
- if (existing != null) try {
1275
- await existing.started;
1276
- return {
1735
+ if (existing != null) {
1736
+ if (existing.initialSyncFailed && !existing.closed) return {
1277
1737
  ok: true,
1278
1738
  value: this.createSubscription(existing, params)
1279
1739
  };
1280
- } catch (error) {
1281
- return {
1282
- ok: false,
1283
- error: toTransportError(error)
1284
- };
1740
+ try {
1741
+ await existing.started;
1742
+ return {
1743
+ ok: true,
1744
+ value: this.createSubscription(existing, params)
1745
+ };
1746
+ } catch (error) {
1747
+ return {
1748
+ ok: false,
1749
+ error: toTransportError(error)
1750
+ };
1751
+ }
1285
1752
  }
1286
1753
  const state = createJoinState(createInitialRemoteCursor({
1287
1754
  streamUrl: this.streamUrl,
1288
1755
  nextOffset: "-1",
1289
1756
  serverLowerBoundVersion: this.adapter.emptyVersion()
1290
1757
  }));
1758
+ const statusListener = params?.onStatusChange;
1759
+ if (statusListener != null) state.statusListeners.add(statusListener);
1291
1760
  state.started = this.startJoin(state);
1292
1761
  this.joinState = state;
1293
1762
  try {
@@ -1297,10 +1766,20 @@ var StreamsCrdt = class StreamsCrdt {
1297
1766
  value: this.createSubscription(state, params)
1298
1767
  };
1299
1768
  } catch (error) {
1769
+ const transportError = toTransportError(error);
1770
+ if (transportError.retryable && !state.closed) {
1771
+ state.initialSyncFailed = true;
1772
+ state.lastError = transportError;
1773
+ if (statusListener != null) state.statusListeners.delete(statusListener);
1774
+ return {
1775
+ ok: false,
1776
+ error: transportError
1777
+ };
1778
+ }
1300
1779
  await this.disposeJoinState(state);
1301
1780
  return {
1302
1781
  ok: false,
1303
- error: toTransportError(error)
1782
+ error: transportError
1304
1783
  };
1305
1784
  }
1306
1785
  }
@@ -1308,8 +1787,10 @@ var StreamsCrdt = class StreamsCrdt {
1308
1787
  state.refCount += 1;
1309
1788
  const statusListener = params?.onStatusChange;
1310
1789
  if (statusListener != null) {
1311
- state.statusListeners.add(statusListener);
1312
- statusListener(state.status);
1790
+ if (!state.statusListeners.has(statusListener)) {
1791
+ state.statusListeners.add(statusListener);
1792
+ statusListener(state.status);
1793
+ }
1313
1794
  }
1314
1795
  let unsubscribed = false;
1315
1796
  return {
@@ -1325,7 +1806,10 @@ var StreamsCrdt = class StreamsCrdt {
1325
1806
  return state.status === "joined";
1326
1807
  },
1327
1808
  get status() {
1328
- return state.status;
1809
+ return state.status === "connecting" ? "reconnecting" : state.status;
1810
+ },
1811
+ get lastError() {
1812
+ return state.lastError;
1329
1813
  },
1330
1814
  onStatusChange: (listener) => {
1331
1815
  state.statusListeners.add(listener);
@@ -1335,6 +1819,20 @@ var StreamsCrdt = class StreamsCrdt {
1335
1819
  };
1336
1820
  },
1337
1821
  waitUntilSynced: async () => {
1822
+ if (state.initialSyncFailed) {
1823
+ const msg = state.lastError?.code ? `initial sync failed: ${state.lastError.code}` : "initial sync failed";
1824
+ throw new Error(msg);
1825
+ }
1826
+ if (state.readSubStatus === "connecting") try {
1827
+ await state.started;
1828
+ } catch (error) {
1829
+ const te = toTransportError(error);
1830
+ throw new Error(te.code ? `initial sync failed: ${te.code}` : "initial sync failed");
1831
+ }
1832
+ if (state.writeSubStatus === "error") {
1833
+ const msg = state.lastError?.code ? `local append failed: ${state.lastError.code}` : "local append failed";
1834
+ throw new Error(msg);
1835
+ }
1338
1836
  await this.adapter.flushLocalExports?.();
1339
1837
  if (state.closed) throw new Error("subscription closed");
1340
1838
  const remaining = state.pendingLocal.length;
@@ -1377,13 +1875,18 @@ var StreamsCrdt = class StreamsCrdt {
1377
1875
  if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
1378
1876
  }
1379
1877
  if (state.closed) return;
1380
- this.setJoinStatus(state, "joined");
1878
+ state.lastError = void 0;
1879
+ this.setReadSubStatus(state, "ok");
1880
+ this.setWriteSubStatus(state, "ok");
1381
1881
  this.runReadLoop(state);
1382
1882
  } catch (error) {
1383
1883
  if (state.closed) return;
1384
1884
  this.snapshotManager.dispose(state);
1885
+ state.lastError = toTransportError(error);
1385
1886
  this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
1386
- this.setJoinStatus(state, "error");
1887
+ state.readSubStatus = "error";
1888
+ state.writeSubStatus = "error";
1889
+ this.recomputeJoinStatus(state);
1387
1890
  throw error;
1388
1891
  }
1389
1892
  }
@@ -1435,7 +1938,7 @@ var StreamsCrdt = class StreamsCrdt {
1435
1938
  return caughtUp;
1436
1939
  } catch (error) {
1437
1940
  if (!isStreamNotFoundError(error) || !this.createStreamIfMissing) throw error;
1438
- await this.client.createStreamRequest();
1941
+ await this.ensureStreamExists();
1439
1942
  return await this.bootstrapState(this.createInitialCursor());
1440
1943
  }
1441
1944
  }
@@ -1500,7 +2003,17 @@ var StreamsCrdt = class StreamsCrdt {
1500
2003
  nextProducerSeq: this.nextProducerSeq
1501
2004
  };
1502
2005
  for (let attempt = 0; attempt < 3; attempt += 1) {
1503
- const result = await this.client.appendUpdates(batch.updates, producerSession);
2006
+ let result;
2007
+ try {
2008
+ const wireUpdates = this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates);
2009
+ result = await this.client.appendUpdates(wireUpdates, producerSession);
2010
+ } catch (error) {
2011
+ if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
2012
+ await this.ensureStreamExists();
2013
+ continue;
2014
+ }
2015
+ throw error;
2016
+ }
1504
2017
  if (result.kind === "ok") {
1505
2018
  this.producerEpoch = result.ackEpoch;
1506
2019
  this.nextProducerSeq = result.ackSeq + 1;
@@ -1526,6 +2039,17 @@ var StreamsCrdt = class StreamsCrdt {
1526
2039
  }
1527
2040
  throw new Error("append retry budget exhausted");
1528
2041
  }
2042
+ /**
2043
+ * Ensures the target stream exists, creating it if necessary.
2044
+ * Tolerates 409 Conflict (stream already created concurrently).
2045
+ */
2046
+ async ensureStreamExists() {
2047
+ try {
2048
+ await this.client.createStreamRequest();
2049
+ } catch (error) {
2050
+ if (!(error instanceof HttpError && error.status === 409)) throw error;
2051
+ }
2052
+ }
1529
2053
  exportUpdates(fromVersion) {
1530
2054
  const exported = this.adapter.exportUpdates(fromVersion);
1531
2055
  if (exported == null) return { nextRefVersion: fromVersion };
@@ -1546,6 +2070,7 @@ var StreamsCrdt = class StreamsCrdt {
1546
2070
  return new TransportCursorManager({
1547
2071
  adapter,
1548
2072
  decodeSnapshot: this.decodeSnapshot,
2073
+ decodeUpdateItems: this.decodeUpdateItems,
1549
2074
  saveRemoteCursor
1550
2075
  });
1551
2076
  }
@@ -1589,121 +2114,193 @@ var StreamsCrdt = class StreamsCrdt {
1589
2114
  }
1590
2115
  async runReadLoop(state) {
1591
2116
  state.requestController ??= new AbortController();
1592
- while (!state.closed) try {
1593
- if (state.liveMode === "long-poll") {
1594
- const requestOffset = state.cursor.nextOffset;
1595
- const response = await this.client.readFromOffset(requestOffset, state.streamCursor, "long-poll", state.requestController?.signal);
1596
- if (response.kind === "gone") {
1597
- const bootstrapped = await this.bootstrapState({
1598
- ...state.cursor,
1599
- nextOffset: "-1",
1600
- serverLowerBoundVersion: this.adapter.emptyVersion()
2117
+ while (!state.closed) {
2118
+ const capturedController = state.requestController;
2119
+ let inactivityTimer = null;
2120
+ let inactivityTriggered = false;
2121
+ const clearInactivity = () => {
2122
+ if (inactivityTimer != null) {
2123
+ clearTimeout(inactivityTimer);
2124
+ inactivityTimer = null;
2125
+ }
2126
+ };
2127
+ const resetInactivity = () => {
2128
+ clearInactivity();
2129
+ const timeoutMs = this.reconnectConfig.liveInactivityTimeoutMs;
2130
+ if (timeoutMs != null && timeoutMs > 0 && !state.closed) inactivityTimer = setTimeout(() => {
2131
+ inactivityTriggered = true;
2132
+ capturedController?.abort();
2133
+ }, timeoutMs);
2134
+ };
2135
+ try {
2136
+ if (state.liveMode === "long-poll") {
2137
+ const requestOffset = state.cursor.nextOffset;
2138
+ const response = await this.client.readFromOffset(requestOffset, state.streamCursor, "long-poll", capturedController?.signal);
2139
+ if (response.kind === "gone") {
2140
+ const bootstrapped = await this.bootstrapState({
2141
+ ...state.cursor,
2142
+ nextOffset: "-1",
2143
+ serverLowerBoundVersion: this.adapter.emptyVersion()
2144
+ });
2145
+ state.cursor = bootstrapped.cursor;
2146
+ state.streamCursor = bootstrapped.streamCursor;
2147
+ state.retryAttempt = 0;
2148
+ continue;
2149
+ }
2150
+ const result = response.value;
2151
+ if (isReadNoProgress(requestOffset, result)) throw new Error(`catch-up made no progress at offset ${requestOffset}; server returned empty updates and unchanged next offset with up_to_date=false`);
2152
+ await this.enqueueExclusive(async () => {
2153
+ if (state.closed) return;
2154
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
2155
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
2156
+ });
2157
+ if (result.streamCursor != null) state.streamCursor = result.streamCursor;
1601
2158
  });
1602
- state.cursor = bootstrapped.cursor;
1603
- state.streamCursor = bootstrapped.streamCursor;
2159
+ if (state.closed) return;
1604
2160
  state.retryAttempt = 0;
2161
+ this.setReadSubStatus(state, "ok");
2162
+ await sleep(0);
1605
2163
  continue;
1606
2164
  }
1607
- const result = response.value;
1608
- if (isReadNoProgress(requestOffset, result)) throw new Error(`catch-up made no progress at offset ${requestOffset}; server returned empty updates and unchanged next offset with up_to_date=false`);
1609
- await this.enqueueExclusive(async () => {
1610
- if (state.closed) return;
1611
- state.cursor = await this.cursorManager.applyLiveResult(state.cursor, requestOffset, result, (previousVersion, nextVersion) => {
1612
- this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
2165
+ const sseRequestOffset = state.cursor.nextOffset;
2166
+ const pendingRemote = [];
2167
+ let flushChain = Promise.resolve();
2168
+ resetInactivity();
2169
+ const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
2170
+ resetInactivity();
2171
+ pendingRemote.push({
2172
+ updates,
2173
+ nextOffset,
2174
+ streamCursor
1613
2175
  });
1614
- if (result.streamCursor != null) state.streamCursor = result.streamCursor;
1615
- });
1616
- if (state.closed) return;
1617
- state.retryAttempt = 0;
1618
- this.setJoinStatus(state, "joined");
1619
- await sleep(0);
1620
- continue;
1621
- }
1622
- const sseRequestOffset = state.cursor.nextOffset;
1623
- const pendingRemote = [];
1624
- let flushChain = Promise.resolve();
1625
- const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
1626
- pendingRemote.push({
1627
- updates,
1628
- nextOffset,
1629
- streamCursor
1630
- });
1631
- flushChain = flushChain.then(async () => {
1632
- const events = pendingRemote.splice(0);
1633
- if (events.length === 0 || state.closed) return;
1634
- const allUpdates = events.flatMap((e) => e.updates);
1635
- const lastEvent = events[events.length - 1];
1636
- await this.enqueueExclusive(async () => {
1637
- if (state.closed) return;
1638
- state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
1639
- nextOffset: lastEvent.nextOffset,
1640
- updates: allUpdates
1641
- }, (previousVersion, nextVersion) => {
1642
- this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
2176
+ flushChain = flushChain.then(async () => {
2177
+ const events = pendingRemote.splice(0);
2178
+ if (events.length === 0 || state.closed) return;
2179
+ const allUpdates = events.flatMap((e) => e.updates);
2180
+ const lastEvent = events[events.length - 1];
2181
+ await this.enqueueExclusive(async () => {
2182
+ if (state.closed) return;
2183
+ state.cursor = await this.cursorManager.applyLiveResult(state.cursor, sseRequestOffset, {
2184
+ nextOffset: lastEvent.nextOffset,
2185
+ updates: allUpdates
2186
+ }, (previousVersion, nextVersion) => {
2187
+ this.snapshotManager.observeRemoteVersion(state, previousVersion, nextVersion);
2188
+ });
2189
+ for (let i = events.length - 1; i >= 0; i--) if (events[i].streamCursor != null) {
2190
+ state.streamCursor = events[i].streamCursor;
2191
+ break;
2192
+ }
1643
2193
  });
1644
- for (let i = events.length - 1; i >= 0; i--) if (events[i].streamCursor != null) {
1645
- state.streamCursor = events[i].streamCursor;
1646
- break;
1647
- }
2194
+ this.setReadSubStatus(state, "ok");
2195
+ }).catch((flushError) => {
2196
+ this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
2197
+ capturedController?.abort(flushError);
2198
+ });
2199
+ }, () => {
2200
+ resetInactivity();
2201
+ this.setReadSubStatus(state, "ok");
2202
+ }, capturedController?.signal);
2203
+ clearInactivity();
2204
+ await flushChain;
2205
+ if (state.closed) return;
2206
+ if (sseResult.kind === "ok") {
2207
+ state.retryAttempt = 0;
2208
+ state.streamCursor = sseResult.latestCursor;
2209
+ await sleep(0);
2210
+ continue;
2211
+ }
2212
+ if (sseResult.kind === "unsupported") {
2213
+ state.liveMode = "long-poll";
2214
+ await sleep(0);
2215
+ continue;
2216
+ }
2217
+ if (sseResult.kind === "not-found") {
2218
+ const notFoundError = new HttpError("stream not found", 404);
2219
+ this.logError("live read failed", notFoundError, {
2220
+ streamUrl: this.streamUrl,
2221
+ code: "stream_not_found"
1648
2222
  });
1649
- this.setJoinStatus(state, "joined");
2223
+ state.lastError = toTransportError(notFoundError);
2224
+ this.setReadSubStatus(state, "error");
2225
+ return;
2226
+ }
2227
+ const bootstrapped = await this.bootstrapState({
2228
+ ...state.cursor,
2229
+ nextOffset: "-1",
2230
+ serverLowerBoundVersion: this.adapter.emptyVersion()
1650
2231
  });
1651
- }, () => {
1652
- this.setJoinStatus(state, "joined");
1653
- }, state.requestController?.signal);
1654
- await flushChain;
1655
- if (state.closed) return;
1656
- if (sseResult.kind === "ok") {
2232
+ state.cursor = bootstrapped.cursor;
2233
+ state.streamCursor = bootstrapped.streamCursor;
1657
2234
  state.retryAttempt = 0;
1658
- state.streamCursor = sseResult.latestCursor;
1659
- await sleep(0);
1660
- continue;
1661
- }
1662
- if (sseResult.kind === "unsupported") {
1663
- state.liveMode = "long-poll";
1664
- await sleep(0);
1665
- continue;
1666
- }
1667
- if (sseResult.kind === "not-found") {
1668
- this.logError("live read failed", new HttpError("stream not found", 404), {
1669
- streamUrl: this.streamUrl,
1670
- code: "stream_not_found"
1671
- });
1672
- this.setJoinStatus(state, "error");
1673
- return;
1674
- }
1675
- const bootstrapped = await this.bootstrapState({
1676
- ...state.cursor,
1677
- nextOffset: "-1",
1678
- serverLowerBoundVersion: this.adapter.emptyVersion()
1679
- });
1680
- state.cursor = bootstrapped.cursor;
1681
- state.streamCursor = bootstrapped.streamCursor;
1682
- state.retryAttempt = 0;
1683
- } catch (error) {
1684
- if (state.closed) return;
1685
- const classified = classifyFetchError(error);
1686
- if (!classified.retriable || isAuthOrProtocolError(error)) {
1687
- this.logError("live read failed", error, {
1688
- streamUrl: this.streamUrl,
1689
- code: classified.code
1690
- });
1691
- this.setJoinStatus(state, "error");
1692
- return;
1693
- }
1694
- this.setJoinStatus(state, "reconnecting");
1695
- const maxAttempts = this.reconnectConfig.maxAttempts;
1696
- if (state.retryAttempt >= maxAttempts) {
1697
- this.logError("live read reconnect exhausted", error, {
1698
- streamUrl: this.streamUrl,
1699
- attempts: state.retryAttempt
1700
- });
1701
- this.setJoinStatus(state, "disconnected");
1702
- return;
2235
+ } catch (error) {
2236
+ clearInactivity();
2237
+ if (state.closed) return;
2238
+ if (capturedController?.signal.aborted) {
2239
+ if (inactivityTriggered) {
2240
+ if (state.requestController === capturedController) state.requestController = new AbortController();
2241
+ this.logDebug("sse inactivity timeout", { streamUrl: this.streamUrl });
2242
+ this.setReadSubStatus(state, "reconnecting");
2243
+ const maxAttempts = this.reconnectConfig.maxAttempts;
2244
+ if (state.retryAttempt >= maxAttempts) {
2245
+ this.logError("live read reconnect exhausted (inactivity)", error, {
2246
+ streamUrl: this.streamUrl,
2247
+ attempts: state.retryAttempt
2248
+ });
2249
+ state.lastError = {
2250
+ code: "timeout",
2251
+ retryable: true,
2252
+ phase: "poll",
2253
+ message: "SSE inactivity timeout exhausted retry budget"
2254
+ };
2255
+ this.setReadSubStatus(state, "disconnected");
2256
+ return;
2257
+ }
2258
+ state.retryAttempt += 1;
2259
+ continue;
2260
+ }
2261
+ const abortReason = capturedController.signal.reason;
2262
+ const flushError = abortReason instanceof Error ? abortReason : error;
2263
+ if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
2264
+ const flushClassified = classifyFetchError(flushError);
2265
+ if (!flushClassified.retriable || isAuthOrProtocolError(flushError)) {
2266
+ this.logError("live read failed (flush chain)", flushError, {
2267
+ streamUrl: this.streamUrl,
2268
+ code: flushClassified.code
2269
+ });
2270
+ state.lastError = toTransportError(flushError);
2271
+ this.setReadSubStatus(state, "error");
2272
+ return;
2273
+ }
2274
+ }
2275
+ state.retryAttempt = 0;
2276
+ this.setReadSubStatus(state, "reconnecting");
2277
+ continue;
2278
+ }
2279
+ const classified = classifyFetchError(error);
2280
+ if (!classified.retriable || isAuthOrProtocolError(error)) {
2281
+ this.logError("live read failed", error, {
2282
+ streamUrl: this.streamUrl,
2283
+ code: classified.code
2284
+ });
2285
+ state.lastError = toTransportError(error);
2286
+ this.setReadSubStatus(state, "error");
2287
+ return;
2288
+ }
2289
+ this.setReadSubStatus(state, "reconnecting");
2290
+ const maxAttempts = this.reconnectConfig.maxAttempts;
2291
+ if (state.retryAttempt >= maxAttempts) {
2292
+ this.logError("live read reconnect exhausted", error, {
2293
+ streamUrl: this.streamUrl,
2294
+ attempts: state.retryAttempt
2295
+ });
2296
+ state.lastError = toTransportError(error);
2297
+ this.setReadSubStatus(state, "disconnected");
2298
+ return;
2299
+ }
2300
+ const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
2301
+ state.retryAttempt += 1;
2302
+ await sleep(delay, state.retrySleepController.signal);
1703
2303
  }
1704
- const delay = computeRetryDelay(state.retryAttempt, this.reconnectConfig);
1705
- state.retryAttempt += 1;
1706
- await sleep(delay, state.retrySleepController.signal);
1707
2304
  }
1708
2305
  }
1709
2306
  /** Maximum encoded size (bytes) for a single batched append. */
@@ -1714,20 +2311,19 @@ var StreamsCrdt = class StreamsCrdt {
1714
2311
  *
1715
2312
  * Returns `undefined` when the queue is empty.
1716
2313
  */
1717
- drainPendingBatch(state) {
1718
- const first = state.pendingLocal.peekFront();
2314
+ async drainQueuedBatch(pendingLocal) {
2315
+ const first = pendingLocal.peekFront();
1719
2316
  if (first == null) return;
1720
2317
  const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
1721
- let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1722
2318
  let mergedVersion = first.version;
1723
2319
  const allUpdates = Array.from(first.updates);
1724
2320
  let count = 1;
1725
- while (count < state.pendingLocal.length) {
1726
- const next = state.pendingLocal.at(count);
1727
- const nextBytes = next.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1728
- if (totalBytes + nextBytes > maxBytes) break;
2321
+ await this.measureAppendBodyByteLength(allUpdates);
2322
+ while (count < pendingLocal.length) {
2323
+ const next = pendingLocal.at(count);
2324
+ const candidateUpdates = [...allUpdates, ...next.updates];
2325
+ if (await this.measureAppendBodyByteLength(candidateUpdates) > maxBytes) break;
1729
2326
  allUpdates.push(...next.updates);
1730
- totalBytes += nextBytes;
1731
2327
  mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
1732
2328
  count += 1;
1733
2329
  }
@@ -1743,19 +2339,25 @@ var StreamsCrdt = class StreamsCrdt {
1743
2339
  count
1744
2340
  };
1745
2341
  }
2342
+ drainPendingBatch(state) {
2343
+ return this.drainQueuedBatch(state.pendingLocal);
2344
+ }
2345
+ drainPendingWriteOnlyBatch() {
2346
+ return this.drainQueuedBatch(this.pendingWriteOnly);
2347
+ }
1746
2348
  async flushPendingLocal(state) {
1747
2349
  if (state.flushingLocal || state.closed) return;
1748
2350
  state.flushingLocal = true;
1749
2351
  try {
1750
2352
  while (!state.closed) {
1751
- const drained = this.drainPendingBatch(state);
2353
+ const drained = await this.drainPendingBatch(state);
1752
2354
  if (drained == null) return;
1753
2355
  let attempts = 0;
1754
2356
  while (!state.closed) try {
1755
2357
  state.cursor = await this.enqueueExclusive(async () => this.appendLocalBatch(state.cursor, drained.batch));
1756
2358
  for (let i = 0; i < drained.count; i += 1) state.pendingLocal.popFront();
1757
2359
  this.notifySyncWaiters(state, drained.count);
1758
- this.setJoinStatus(state, "joined");
2360
+ this.setWriteSubStatus(state, "ok");
1759
2361
  this.snapshotManager.schedule(state);
1760
2362
  break;
1761
2363
  } catch (error) {
@@ -1765,17 +2367,19 @@ var StreamsCrdt = class StreamsCrdt {
1765
2367
  streamUrl: this.streamUrl,
1766
2368
  code: classified.code
1767
2369
  });
2370
+ state.lastError = toTransportError(error);
1768
2371
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
1769
- this.setJoinStatus(state, "error");
2372
+ this.setWriteSubStatus(state, "error");
1770
2373
  return;
1771
2374
  }
1772
2375
  const maxAttempts = this.reconnectConfig.maxAttempts;
1773
2376
  if (attempts >= maxAttempts) {
2377
+ state.lastError = toTransportError(error);
1774
2378
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
1775
- this.setJoinStatus(state, "error");
2379
+ this.setWriteSubStatus(state, "error");
1776
2380
  return;
1777
2381
  }
1778
- this.setJoinStatus(state, "reconnecting");
2382
+ this.setWriteSubStatus(state, "reconnecting");
1779
2383
  const delay = computeRetryDelay(attempts, this.reconnectConfig);
1780
2384
  attempts += 1;
1781
2385
  await sleep(delay, state.retrySleepController.signal);
@@ -1812,16 +2416,47 @@ var StreamsCrdt = class StreamsCrdt {
1812
2416
  beforeRemoteCursorSave: this.beforeRemoteCursorSave
1813
2417
  });
1814
2418
  }
1815
- setJoinStatus(state, status) {
2419
+ setReadSubStatus(state, sub) {
2420
+ if (state.readSubStatus === sub) return;
2421
+ state.readSubStatus = sub;
2422
+ this.recomputeJoinStatus(state);
2423
+ }
2424
+ setWriteSubStatus(state, sub) {
2425
+ if (state.writeSubStatus === sub) return;
2426
+ state.writeSubStatus = sub;
2427
+ this.recomputeJoinStatus(state);
2428
+ }
2429
+ recomputeJoinStatus(state) {
2430
+ const status = this.computeOverallStatus(state);
1816
2431
  if (state.status === status) return;
1817
2432
  state.status = status;
2433
+ if (status === "joined") state.lastError = void 0;
1818
2434
  for (const listener of state.statusListeners) listener(status);
1819
2435
  }
2436
+ computeOverallStatus(state) {
2437
+ const { readSubStatus, writeSubStatus } = state;
2438
+ if (readSubStatus === "error" || writeSubStatus === "error") return "error";
2439
+ if (readSubStatus === "disconnected") return "disconnected";
2440
+ if (readSubStatus === "connecting") return "connecting";
2441
+ if (readSubStatus === "reconnecting" || writeSubStatus === "reconnecting") return "reconnecting";
2442
+ return "joined";
2443
+ }
1820
2444
  enqueueExclusive(task) {
1821
2445
  const run = this.opQueue.then(task, task);
1822
2446
  this.opQueue = run.then(() => void 0, () => void 0);
1823
2447
  return run;
1824
2448
  }
2449
+ async measureAppendBodyByteLength(updates) {
2450
+ if (this.measureUpdateItemsByteLength != null) return await this.measureUpdateItemsByteLength(updates);
2451
+ return updates.reduce((sum, update) => sum + 4 + update.byteLength, 0);
2452
+ }
2453
+ disableWriteOnlyMode(reason) {
2454
+ if (this.writeOnlyDisabledReason != null) return;
2455
+ this.writeOnlyDisabledReason = reason;
2456
+ this.pendingWriteOnly.clear();
2457
+ this.unsubscribeWriteOnlyLocal?.();
2458
+ this.unsubscribeWriteOnlyLocal = void 0;
2459
+ }
1825
2460
  logDebug(message, detail) {
1826
2461
  if (!this.debug) return;
1827
2462
  if (detail == null) {
@@ -1873,6 +2508,6 @@ function createStreamUrl(input) {
1873
2508
  return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
1874
2509
  }
1875
2510
  //#endregion
1876
- export { InMemoryRemoteCursorStore as a, StreamsCrdt as i, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
2511
+ export { PayloadProtectionError as a, createInitialRemoteCursor as c, StreamsCrdt as i, isValidBucketId as n, InMemoryRemoteCursorStore as o, isValidRillId as r, IndexedDbRemoteCursorStore as s, createStreamUrl as t };
1877
2512
 
1878
- //# sourceMappingURL=stream-id-DBym_2Mj.js.map
2513
+ //# sourceMappingURL=stream-id-CbVwCS3g.js.map