@loro-dev/streams-crdt 0.6.1 → 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.
@@ -206,6 +206,127 @@ function computeRetryDelay(attempt, config = DEFAULT_RECONNECT_CONFIG) {
206
206
  return applyJitter(base, jitterFraction);
207
207
  }
208
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
209
330
  //#region src/items.ts
210
331
  const BINARY_CONTENT_TYPE = "application/octet-stream";
211
332
  function encodeItems(items) {
@@ -236,6 +357,290 @@ function decodeItems(payload) {
236
357
  return out;
237
358
  }
238
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
239
644
  //#region src/transport-internal.ts
240
645
  function createProducerId() {
241
646
  if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
@@ -382,6 +787,12 @@ function classifyFetchError(err) {
382
787
  retriable: true,
383
788
  atMs: Date.now()
384
789
  };
790
+ if (isPayloadProtectionError(err)) return {
791
+ code: "protocol",
792
+ message: err.message,
793
+ retriable: false,
794
+ atMs: Date.now()
795
+ };
385
796
  return {
386
797
  code: "internal",
387
798
  message,
@@ -430,6 +841,13 @@ function toTransportError(error) {
430
841
  retryable: true,
431
842
  message: error.message
432
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
+ };
433
851
  if (error instanceof TypeError) return {
434
852
  code: "network_error",
435
853
  retryable: true,
@@ -782,16 +1200,19 @@ var StreamsTransportClient = class {
782
1200
  var TransportCursorManager = class {
783
1201
  adapter;
784
1202
  decodeSnapshot;
1203
+ decodeUpdateItems;
785
1204
  saveRemoteCursor;
786
1205
  constructor(options) {
787
1206
  this.adapter = options.adapter;
788
1207
  this.decodeSnapshot = options.decodeSnapshot;
1208
+ this.decodeUpdateItems = options.decodeUpdateItems;
789
1209
  this.saveRemoteCursor = options.saveRemoteCursor;
790
1210
  }
791
1211
  async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
792
1212
  let nextVersion = cursor.serverLowerBoundVersion;
793
1213
  if (snapshot.byteLength > 0) nextVersion = await this.adapter.applySnapshot(this.decodeSnapshot == null ? snapshot : await this.decodeSnapshot(snapshot));
794
- 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);
795
1216
  return await this.finalizeCursor({
796
1217
  ...cursor,
797
1218
  nextOffset,
@@ -811,8 +1232,9 @@ var TransportCursorManager = class {
811
1232
  async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
812
1233
  const previousVersion = cursor.serverLowerBoundVersion;
813
1234
  let nextVersion = previousVersion;
814
- if (result.updates.length > 0) {
815
- 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);
816
1238
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
817
1239
  }
818
1240
  return await this.finalizeCursor({
@@ -824,8 +1246,9 @@ var TransportCursorManager = class {
824
1246
  async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
825
1247
  const previousVersion = cursor.serverLowerBoundVersion;
826
1248
  let nextVersion = previousVersion;
827
- if (updates.length > 0) {
828
- 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);
829
1252
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
830
1253
  }
831
1254
  return await this.finalizeCursor({
@@ -837,126 +1260,9 @@ var TransportCursorManager = class {
837
1260
  async finalizeCursor(cursor, source) {
838
1261
  return await this.saveRemoteCursor(cursor, source);
839
1262
  }
840
- };
841
- //#endregion
842
- //#region src/deque.ts
843
- const DEFAULT_COMPACT_THRESHOLD = 64;
844
- const DEFAULT_MIN_CAPACITY = 16;
845
- var Deque = class {
846
- buffer;
847
- head = 0;
848
- tail = 0;
849
- compactThreshold;
850
- minCapacity;
851
- constructor(values, options = {}) {
852
- this.compactThreshold = Math.max(1, options.compactThreshold ?? DEFAULT_COMPACT_THRESHOLD);
853
- this.minCapacity = Math.max(1, options.minCapacity ?? DEFAULT_MIN_CAPACITY);
854
- const seed = values == null ? [] : Array.from(values);
855
- this.buffer = seed.slice();
856
- this.tail = seed.length;
857
- }
858
- get length() {
859
- return this.tail - this.head;
860
- }
861
- isEmpty() {
862
- return this.length === 0;
863
- }
864
- clear() {
865
- this.buffer = [];
866
- this.head = 0;
867
- this.tail = 0;
868
- }
869
- peekFront() {
870
- if (this.length === 0) return;
871
- const value = this.buffer[this.head];
872
- return value == null ? void 0 : value;
873
- }
874
- peekBack() {
875
- if (this.length === 0) return;
876
- const value = this.buffer[this.tail - 1];
877
- return value == null ? void 0 : value;
878
- }
879
- at(index) {
880
- const length = this.length;
881
- if (length === 0) return;
882
- let offset = index;
883
- if (offset < 0) offset += length;
884
- if (offset < 0 || offset >= length) return;
885
- const value = this.buffer[this.head + offset];
886
- return value == null ? void 0 : value;
887
- }
888
- pushBack(value) {
889
- this.ensureBackCapacity();
890
- this.buffer[this.tail] = value;
891
- this.tail += 1;
892
- return this.length;
893
- }
894
- pushFront(value) {
895
- if (this.head === 0) this.recenter(1, 0);
896
- this.head -= 1;
897
- this.buffer[this.head] = value;
898
- return this.length;
899
- }
900
- popFront() {
901
- if (this.length === 0) return;
902
- const value = this.buffer[this.head];
903
- this.buffer[this.head] = null;
904
- this.head += 1;
905
- this.compactIfNeeded();
906
- return value == null ? void 0 : value;
907
- }
908
- popBack() {
909
- if (this.length === 0) return;
910
- this.tail -= 1;
911
- const value = this.buffer[this.tail];
912
- this.buffer[this.tail] = null;
913
- this.compactIfNeeded();
914
- return value == null ? void 0 : value;
915
- }
916
- toArray() {
917
- const out = [];
918
- for (let i = this.head; i < this.tail; i += 1) out.push(this.buffer[i]);
919
- return out;
920
- }
921
- *[Symbol.iterator]() {
922
- for (let i = this.head; i < this.tail; i += 1) yield this.buffer[i];
923
- }
924
- ensureBackCapacity() {
925
- if (this.tail < this.buffer.length) return;
926
- if (this.head > 0) {
927
- this.compactToFront();
928
- if (this.tail < this.buffer.length) return;
929
- }
930
- this.recenter(0, 1);
931
- }
932
- recenter(extraFront, extraBack) {
933
- const length = this.length;
934
- const needed = length + extraFront + extraBack;
935
- const currentCapacity = this.buffer.length;
936
- let nextCapacity = Math.max(currentCapacity, this.minCapacity);
937
- if (nextCapacity < needed) nextCapacity = Math.max(nextCapacity * 2, needed, this.minCapacity);
938
- const free = nextCapacity - length;
939
- const nextHead = Math.max(extraFront, Math.floor(free / 2));
940
- const next = new Array(nextCapacity);
941
- for (let i = 0; i < length; i += 1) next[nextHead + i] = this.buffer[this.head + i];
942
- this.buffer = next;
943
- this.head = nextHead;
944
- this.tail = nextHead + length;
945
- }
946
- compactIfNeeded() {
947
- const length = this.length;
948
- if (length === 0) {
949
- this.clear();
950
- return;
951
- }
952
- if (this.head < this.compactThreshold || this.head < length) return;
953
- this.compactToFront();
954
- }
955
- compactToFront() {
956
- const length = this.length;
957
- this.buffer = this.buffer.slice(this.head, this.tail);
958
- this.head = 0;
959
- this.tail = length;
1263
+ async decodeRemoteUpdateItems(items) {
1264
+ if (items.length === 0) return [];
1265
+ return this.decodeUpdateItems == null ? [...items] : await this.decodeUpdateItems(items);
960
1266
  }
961
1267
  };
962
1268
  //#endregion
@@ -1102,7 +1408,8 @@ function createJoinState(cursor, liveMode = "sse") {
1102
1408
  liveMode,
1103
1409
  snapshot: createTransportSnapshotState(),
1104
1410
  started: Promise.resolve(),
1105
- syncWaiters: []
1411
+ syncWaiters: [],
1412
+ initialSyncFailed: false
1106
1413
  };
1107
1414
  }
1108
1415
  async function disposeJoinState(state, options) {
@@ -1113,6 +1420,7 @@ async function disposeJoinState(state, options) {
1113
1420
  const unsubscribeLocal = state.unsubscribeLocal;
1114
1421
  state.unsubscribeLocal = void 0;
1115
1422
  requestController?.abort();
1423
+ state.retrySleepController.abort();
1116
1424
  unsubscribeLocal?.();
1117
1425
  const disposedError = /* @__PURE__ */ new Error("subscription closed");
1118
1426
  for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
@@ -1132,6 +1440,10 @@ var StreamsCrdt = class StreamsCrdt {
1132
1440
  cursorManager;
1133
1441
  volatileCursorManager;
1134
1442
  decodeSnapshot;
1443
+ encodeSnapshot;
1444
+ decodeUpdateItems;
1445
+ encodeUpdateItems;
1446
+ measureUpdateItemsByteLength;
1135
1447
  reconnectConfig;
1136
1448
  debug;
1137
1449
  beforeRemoteCursorSave;
@@ -1139,6 +1451,10 @@ var StreamsCrdt = class StreamsCrdt {
1139
1451
  producerId = createProducerId();
1140
1452
  producerEpoch = 0;
1141
1453
  nextProducerSeq = 0;
1454
+ pendingWriteOnly = new Deque();
1455
+ writeOnlyCursor;
1456
+ unsubscribeWriteOnlyLocal;
1457
+ writeOnlyDisabledReason;
1142
1458
  joinState;
1143
1459
  opQueue = Promise.resolve();
1144
1460
  /**
@@ -1163,8 +1479,17 @@ var StreamsCrdt = class StreamsCrdt {
1163
1479
  this.debug = options.debug ?? false;
1164
1480
  this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
1165
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`);
1166
1487
  const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
1167
- 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);
1168
1493
  this.client = new StreamsTransportClient({
1169
1494
  streamUrl: this.streamUrl,
1170
1495
  adapter: this.adapter,
@@ -1177,7 +1502,7 @@ var StreamsCrdt = class StreamsCrdt {
1177
1502
  this.snapshotManager = new TransportSnapshotManager({
1178
1503
  adapter: this.adapter,
1179
1504
  config: snapshotUploadConfig,
1180
- encodeSnapshot: snapshotCodec?.compress,
1505
+ encodeSnapshot: this.encodeSnapshot,
1181
1506
  readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
1182
1507
  putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
1183
1508
  enqueueExclusive: (task) => this.enqueueExclusive(task),
@@ -1185,11 +1510,31 @@ var StreamsCrdt = class StreamsCrdt {
1185
1510
  logError: (message, error, detail) => this.logError(message, error, detail),
1186
1511
  isCurrentState: (state) => this.joinState === state
1187
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
+ };
1188
1532
  }
1189
1533
  /**
1190
1534
  * Stops any active live join and releases transport-owned state.
1191
1535
  */
1192
1536
  async close() {
1537
+ this.disableWriteOnlyMode("transport closed");
1193
1538
  const state = this.joinState;
1194
1539
  if (state != null) await this.disposeJoinState(state);
1195
1540
  }
@@ -1206,7 +1551,27 @@ var StreamsCrdt = class StreamsCrdt {
1206
1551
  rejoin() {
1207
1552
  const state = this.joinState;
1208
1553
  if (state == null || state.closed) return;
1209
- if (state.readSubStatus === "ok" && state.writeSubStatus === "ok") 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;
1210
1575
  state.retryAttempt = 0;
1211
1576
  state.retrySleepController.abort();
1212
1577
  state.retrySleepController = new AbortController();
@@ -1240,6 +1605,33 @@ var StreamsCrdt = class StreamsCrdt {
1240
1605
  }
1241
1606
  }
1242
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
+ /**
1243
1635
  * Explicitly creates the target stream.
1244
1636
  */
1245
1637
  async createStream() {
@@ -1283,6 +1675,7 @@ var StreamsCrdt = class StreamsCrdt {
1283
1675
  */
1284
1676
  async sync() {
1285
1677
  try {
1678
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after sync() on the same transport");
1286
1679
  return {
1287
1680
  ok: true,
1288
1681
  value: { cursor: await this.syncOnce() }
@@ -1295,27 +1688,75 @@ var StreamsCrdt = class StreamsCrdt {
1295
1688
  }
1296
1689
  }
1297
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
+ /**
1298
1730
  * Replays remote state, flushes queued local updates, then enters live read mode.
1299
1731
  */
1300
1732
  async join(params) {
1733
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after join() on the same transport");
1301
1734
  const existing = this.joinState;
1302
- if (existing != null) try {
1303
- await existing.started;
1304
- return {
1735
+ if (existing != null) {
1736
+ if (existing.initialSyncFailed && !existing.closed) return {
1305
1737
  ok: true,
1306
1738
  value: this.createSubscription(existing, params)
1307
1739
  };
1308
- } catch (error) {
1309
- return {
1310
- ok: false,
1311
- error: toTransportError(error)
1312
- };
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
+ }
1313
1752
  }
1314
1753
  const state = createJoinState(createInitialRemoteCursor({
1315
1754
  streamUrl: this.streamUrl,
1316
1755
  nextOffset: "-1",
1317
1756
  serverLowerBoundVersion: this.adapter.emptyVersion()
1318
1757
  }));
1758
+ const statusListener = params?.onStatusChange;
1759
+ if (statusListener != null) state.statusListeners.add(statusListener);
1319
1760
  state.started = this.startJoin(state);
1320
1761
  this.joinState = state;
1321
1762
  try {
@@ -1325,10 +1766,20 @@ var StreamsCrdt = class StreamsCrdt {
1325
1766
  value: this.createSubscription(state, params)
1326
1767
  };
1327
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
+ }
1328
1779
  await this.disposeJoinState(state);
1329
1780
  return {
1330
1781
  ok: false,
1331
- error: toTransportError(error)
1782
+ error: transportError
1332
1783
  };
1333
1784
  }
1334
1785
  }
@@ -1336,8 +1787,10 @@ var StreamsCrdt = class StreamsCrdt {
1336
1787
  state.refCount += 1;
1337
1788
  const statusListener = params?.onStatusChange;
1338
1789
  if (statusListener != null) {
1339
- state.statusListeners.add(statusListener);
1340
- statusListener(state.status);
1790
+ if (!state.statusListeners.has(statusListener)) {
1791
+ state.statusListeners.add(statusListener);
1792
+ statusListener(state.status);
1793
+ }
1341
1794
  }
1342
1795
  let unsubscribed = false;
1343
1796
  return {
@@ -1353,7 +1806,10 @@ var StreamsCrdt = class StreamsCrdt {
1353
1806
  return state.status === "joined";
1354
1807
  },
1355
1808
  get status() {
1356
- return state.status;
1809
+ return state.status === "connecting" ? "reconnecting" : state.status;
1810
+ },
1811
+ get lastError() {
1812
+ return state.lastError;
1357
1813
  },
1358
1814
  onStatusChange: (listener) => {
1359
1815
  state.statusListeners.add(listener);
@@ -1363,6 +1819,20 @@ var StreamsCrdt = class StreamsCrdt {
1363
1819
  };
1364
1820
  },
1365
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
+ }
1366
1836
  await this.adapter.flushLocalExports?.();
1367
1837
  if (state.closed) throw new Error("subscription closed");
1368
1838
  const remaining = state.pendingLocal.length;
@@ -1405,12 +1875,14 @@ var StreamsCrdt = class StreamsCrdt {
1405
1875
  if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
1406
1876
  }
1407
1877
  if (state.closed) return;
1878
+ state.lastError = void 0;
1408
1879
  this.setReadSubStatus(state, "ok");
1409
1880
  this.setWriteSubStatus(state, "ok");
1410
1881
  this.runReadLoop(state);
1411
1882
  } catch (error) {
1412
1883
  if (state.closed) return;
1413
1884
  this.snapshotManager.dispose(state);
1885
+ state.lastError = toTransportError(error);
1414
1886
  this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
1415
1887
  state.readSubStatus = "error";
1416
1888
  state.writeSubStatus = "error";
@@ -1533,7 +2005,8 @@ var StreamsCrdt = class StreamsCrdt {
1533
2005
  for (let attempt = 0; attempt < 3; attempt += 1) {
1534
2006
  let result;
1535
2007
  try {
1536
- result = await this.client.appendUpdates(batch.updates, producerSession);
2008
+ const wireUpdates = this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates);
2009
+ result = await this.client.appendUpdates(wireUpdates, producerSession);
1537
2010
  } catch (error) {
1538
2011
  if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
1539
2012
  await this.ensureStreamExists();
@@ -1597,6 +2070,7 @@ var StreamsCrdt = class StreamsCrdt {
1597
2070
  return new TransportCursorManager({
1598
2071
  adapter,
1599
2072
  decodeSnapshot: this.decodeSnapshot,
2073
+ decodeUpdateItems: this.decodeUpdateItems,
1600
2074
  saveRemoteCursor
1601
2075
  });
1602
2076
  }
@@ -1718,6 +2192,9 @@ var StreamsCrdt = class StreamsCrdt {
1718
2192
  }
1719
2193
  });
1720
2194
  this.setReadSubStatus(state, "ok");
2195
+ }).catch((flushError) => {
2196
+ this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
2197
+ capturedController?.abort(flushError);
1721
2198
  });
1722
2199
  }, () => {
1723
2200
  resetInactivity();
@@ -1738,10 +2215,12 @@ var StreamsCrdt = class StreamsCrdt {
1738
2215
  continue;
1739
2216
  }
1740
2217
  if (sseResult.kind === "not-found") {
1741
- this.logError("live read failed", new HttpError("stream not found", 404), {
2218
+ const notFoundError = new HttpError("stream not found", 404);
2219
+ this.logError("live read failed", notFoundError, {
1742
2220
  streamUrl: this.streamUrl,
1743
2221
  code: "stream_not_found"
1744
2222
  });
2223
+ state.lastError = toTransportError(notFoundError);
1745
2224
  this.setReadSubStatus(state, "error");
1746
2225
  return;
1747
2226
  }
@@ -1767,12 +2246,32 @@ var StreamsCrdt = class StreamsCrdt {
1767
2246
  streamUrl: this.streamUrl,
1768
2247
  attempts: state.retryAttempt
1769
2248
  });
2249
+ state.lastError = {
2250
+ code: "timeout",
2251
+ retryable: true,
2252
+ phase: "poll",
2253
+ message: "SSE inactivity timeout exhausted retry budget"
2254
+ };
1770
2255
  this.setReadSubStatus(state, "disconnected");
1771
2256
  return;
1772
2257
  }
1773
2258
  state.retryAttempt += 1;
1774
2259
  continue;
1775
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
+ }
1776
2275
  state.retryAttempt = 0;
1777
2276
  this.setReadSubStatus(state, "reconnecting");
1778
2277
  continue;
@@ -1783,6 +2282,7 @@ var StreamsCrdt = class StreamsCrdt {
1783
2282
  streamUrl: this.streamUrl,
1784
2283
  code: classified.code
1785
2284
  });
2285
+ state.lastError = toTransportError(error);
1786
2286
  this.setReadSubStatus(state, "error");
1787
2287
  return;
1788
2288
  }
@@ -1793,6 +2293,7 @@ var StreamsCrdt = class StreamsCrdt {
1793
2293
  streamUrl: this.streamUrl,
1794
2294
  attempts: state.retryAttempt
1795
2295
  });
2296
+ state.lastError = toTransportError(error);
1796
2297
  this.setReadSubStatus(state, "disconnected");
1797
2298
  return;
1798
2299
  }
@@ -1810,20 +2311,19 @@ var StreamsCrdt = class StreamsCrdt {
1810
2311
  *
1811
2312
  * Returns `undefined` when the queue is empty.
1812
2313
  */
1813
- drainPendingBatch(state) {
1814
- const first = state.pendingLocal.peekFront();
2314
+ async drainQueuedBatch(pendingLocal) {
2315
+ const first = pendingLocal.peekFront();
1815
2316
  if (first == null) return;
1816
2317
  const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
1817
- let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1818
2318
  let mergedVersion = first.version;
1819
2319
  const allUpdates = Array.from(first.updates);
1820
2320
  let count = 1;
1821
- while (count < state.pendingLocal.length) {
1822
- const next = state.pendingLocal.at(count);
1823
- const nextBytes = next.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1824
- 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;
1825
2326
  allUpdates.push(...next.updates);
1826
- totalBytes += nextBytes;
1827
2327
  mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
1828
2328
  count += 1;
1829
2329
  }
@@ -1839,12 +2339,18 @@ var StreamsCrdt = class StreamsCrdt {
1839
2339
  count
1840
2340
  };
1841
2341
  }
2342
+ drainPendingBatch(state) {
2343
+ return this.drainQueuedBatch(state.pendingLocal);
2344
+ }
2345
+ drainPendingWriteOnlyBatch() {
2346
+ return this.drainQueuedBatch(this.pendingWriteOnly);
2347
+ }
1842
2348
  async flushPendingLocal(state) {
1843
2349
  if (state.flushingLocal || state.closed) return;
1844
2350
  state.flushingLocal = true;
1845
2351
  try {
1846
2352
  while (!state.closed) {
1847
- const drained = this.drainPendingBatch(state);
2353
+ const drained = await this.drainPendingBatch(state);
1848
2354
  if (drained == null) return;
1849
2355
  let attempts = 0;
1850
2356
  while (!state.closed) try {
@@ -1861,12 +2367,14 @@ var StreamsCrdt = class StreamsCrdt {
1861
2367
  streamUrl: this.streamUrl,
1862
2368
  code: classified.code
1863
2369
  });
2370
+ state.lastError = toTransportError(error);
1864
2371
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
1865
2372
  this.setWriteSubStatus(state, "error");
1866
2373
  return;
1867
2374
  }
1868
2375
  const maxAttempts = this.reconnectConfig.maxAttempts;
1869
2376
  if (attempts >= maxAttempts) {
2377
+ state.lastError = toTransportError(error);
1870
2378
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
1871
2379
  this.setWriteSubStatus(state, "error");
1872
2380
  return;
@@ -1922,6 +2430,7 @@ var StreamsCrdt = class StreamsCrdt {
1922
2430
  const status = this.computeOverallStatus(state);
1923
2431
  if (state.status === status) return;
1924
2432
  state.status = status;
2433
+ if (status === "joined") state.lastError = void 0;
1925
2434
  for (const listener of state.statusListeners) listener(status);
1926
2435
  }
1927
2436
  computeOverallStatus(state) {
@@ -1937,6 +2446,17 @@ var StreamsCrdt = class StreamsCrdt {
1937
2446
  this.opQueue = run.then(() => void 0, () => void 0);
1938
2447
  return run;
1939
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
+ }
1940
2460
  logDebug(message, detail) {
1941
2461
  if (!this.debug) return;
1942
2462
  if (detail == null) {
@@ -1988,6 +2508,6 @@ function createStreamUrl(input) {
1988
2508
  return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
1989
2509
  }
1990
2510
  //#endregion
1991
- 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 };
1992
2512
 
1993
- //# sourceMappingURL=stream-id-0dTFsiaO.js.map
2513
+ //# sourceMappingURL=stream-id-CbVwCS3g.js.map