@loro-dev/streams-crdt 0.6.1 → 0.7.1

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,
@@ -568,8 +986,8 @@ var StreamsTransportClient = class {
568
986
  if (result.ok) return result.result.deleted;
569
987
  throw this.toTransportFailure(result.result);
570
988
  }
571
- async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal) {
572
- return this.readItemsSse(offset, cursor, onControl, onOpen, signal);
989
+ async readFromOffsetSseItems(offset, cursor, onControl, onOpen, signal, onActivity) {
990
+ return this.readItemsSse(offset, cursor, onControl, onOpen, signal, onActivity);
573
991
  }
574
992
  async readFromOffset(offset, cursor, live, signal) {
575
993
  const result = await this.client.readOnce({
@@ -675,8 +1093,8 @@ var StreamsTransportClient = class {
675
1093
  }
676
1094
  }
677
1095
  }
678
- async readItemsSse(initialOffset, cursor, onControl, onOpen, signal) {
679
- const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal);
1096
+ async readItemsSse(initialOffset, cursor, onControl, onOpen, signal, onActivity) {
1097
+ const opened = await this.openItemsSseSession(initialOffset, cursor, onOpen, signal, onActivity);
680
1098
  if (opened.kind !== "ok") return opened;
681
1099
  let abortHandler;
682
1100
  const abortRace = signal != null && !signal.aborted ? new Promise((_, reject) => {
@@ -702,11 +1120,12 @@ var StreamsTransportClient = class {
702
1120
  if (abortHandler != null && signal != null) signal.removeEventListener("abort", abortHandler);
703
1121
  }
704
1122
  }
705
- async openItemsSseSession(initialOffset, cursor, onOpen, signal) {
1123
+ async openItemsSseSession(initialOffset, cursor, onOpen, signal, onActivity) {
706
1124
  const result = await this.client.openSseSession({
707
1125
  offset: initialOffset,
708
1126
  cursor,
709
- signal
1127
+ signal,
1128
+ onActivity
710
1129
  });
711
1130
  if (!result.ok) {
712
1131
  if (result.result.code === "gone") return { kind: "gone" };
@@ -782,16 +1201,19 @@ var StreamsTransportClient = class {
782
1201
  var TransportCursorManager = class {
783
1202
  adapter;
784
1203
  decodeSnapshot;
1204
+ decodeUpdateItems;
785
1205
  saveRemoteCursor;
786
1206
  constructor(options) {
787
1207
  this.adapter = options.adapter;
788
1208
  this.decodeSnapshot = options.decodeSnapshot;
1209
+ this.decodeUpdateItems = options.decodeUpdateItems;
789
1210
  this.saveRemoteCursor = options.saveRemoteCursor;
790
1211
  }
791
1212
  async applyBootstrapPayload(cursor, snapshot, updates, nextOffset) {
792
1213
  let nextVersion = cursor.serverLowerBoundVersion;
793
1214
  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);
1215
+ const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1216
+ if (clearUpdates.length > 0) nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
795
1217
  return await this.finalizeCursor({
796
1218
  ...cursor,
797
1219
  nextOffset,
@@ -811,8 +1233,9 @@ var TransportCursorManager = class {
811
1233
  async applyLiveResult(cursor, requestOffset, result, onRemoteVersionApplied) {
812
1234
  const previousVersion = cursor.serverLowerBoundVersion;
813
1235
  let nextVersion = previousVersion;
814
- if (result.updates.length > 0) {
815
- nextVersion = await this.adapter.applyRemoteUpdates([...result.updates], nextVersion);
1236
+ const clearUpdates = await this.decodeRemoteUpdateItems(result.updates);
1237
+ if (clearUpdates.length > 0) {
1238
+ nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
816
1239
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
817
1240
  }
818
1241
  return await this.finalizeCursor({
@@ -824,8 +1247,9 @@ var TransportCursorManager = class {
824
1247
  async applyRemotePayload(cursor, updates, nextOffset, source, onRemoteVersionApplied) {
825
1248
  const previousVersion = cursor.serverLowerBoundVersion;
826
1249
  let nextVersion = previousVersion;
827
- if (updates.length > 0) {
828
- nextVersion = await this.adapter.applyRemoteUpdates([...updates], nextVersion);
1250
+ const clearUpdates = await this.decodeRemoteUpdateItems(updates);
1251
+ if (clearUpdates.length > 0) {
1252
+ nextVersion = await this.adapter.applyRemoteUpdates(clearUpdates, nextVersion);
829
1253
  await onRemoteVersionApplied?.(previousVersion, nextVersion);
830
1254
  }
831
1255
  return await this.finalizeCursor({
@@ -837,126 +1261,9 @@ var TransportCursorManager = class {
837
1261
  async finalizeCursor(cursor, source) {
838
1262
  return await this.saveRemoteCursor(cursor, source);
839
1263
  }
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;
1264
+ async decodeRemoteUpdateItems(items) {
1265
+ if (items.length === 0) return [];
1266
+ return this.decodeUpdateItems == null ? [...items] : await this.decodeUpdateItems(items);
960
1267
  }
961
1268
  };
962
1269
  //#endregion
@@ -1102,7 +1409,8 @@ function createJoinState(cursor, liveMode = "sse") {
1102
1409
  liveMode,
1103
1410
  snapshot: createTransportSnapshotState(),
1104
1411
  started: Promise.resolve(),
1105
- syncWaiters: []
1412
+ syncWaiters: [],
1413
+ initialSyncFailed: false
1106
1414
  };
1107
1415
  }
1108
1416
  async function disposeJoinState(state, options) {
@@ -1113,6 +1421,7 @@ async function disposeJoinState(state, options) {
1113
1421
  const unsubscribeLocal = state.unsubscribeLocal;
1114
1422
  state.unsubscribeLocal = void 0;
1115
1423
  requestController?.abort();
1424
+ state.retrySleepController.abort();
1116
1425
  unsubscribeLocal?.();
1117
1426
  const disposedError = /* @__PURE__ */ new Error("subscription closed");
1118
1427
  for (const waiter of state.syncWaiters.splice(0)) waiter.reject(disposedError);
@@ -1120,6 +1429,16 @@ async function disposeJoinState(state, options) {
1120
1429
  }
1121
1430
  //#endregion
1122
1431
  //#region src/transport-runtime.ts
1432
+ const MIN_RECONNECT_DELAY_MS = 1e3;
1433
+ const MIN_SUCCESSFUL_SSE_DURATION_MS = 5e3;
1434
+ function reconnectDelayWithFloor(attempt, config) {
1435
+ return Math.max(computeRetryDelay(attempt, config), MIN_RECONNECT_DELAY_MS);
1436
+ }
1437
+ function fastSseCloseDelay(startedAtMs) {
1438
+ const elapsedMs = Math.max(0, Date.now() - startedAtMs);
1439
+ if (elapsedMs >= MIN_SUCCESSFUL_SSE_DURATION_MS) return 0;
1440
+ return Math.max(MIN_RECONNECT_DELAY_MS, MIN_SUCCESSFUL_SSE_DURATION_MS - elapsedMs);
1441
+ }
1123
1442
  /**
1124
1443
  * Durable Streams transport runtime bound to one stream URL and one local CRDT adapter.
1125
1444
  */
@@ -1132,6 +1451,10 @@ var StreamsCrdt = class StreamsCrdt {
1132
1451
  cursorManager;
1133
1452
  volatileCursorManager;
1134
1453
  decodeSnapshot;
1454
+ encodeSnapshot;
1455
+ decodeUpdateItems;
1456
+ encodeUpdateItems;
1457
+ measureUpdateItemsByteLength;
1135
1458
  reconnectConfig;
1136
1459
  debug;
1137
1460
  beforeRemoteCursorSave;
@@ -1139,6 +1462,10 @@ var StreamsCrdt = class StreamsCrdt {
1139
1462
  producerId = createProducerId();
1140
1463
  producerEpoch = 0;
1141
1464
  nextProducerSeq = 0;
1465
+ pendingWriteOnly = new Deque();
1466
+ writeOnlyCursor;
1467
+ unsubscribeWriteOnlyLocal;
1468
+ writeOnlyDisabledReason;
1142
1469
  joinState;
1143
1470
  opQueue = Promise.resolve();
1144
1471
  /**
@@ -1163,8 +1490,17 @@ var StreamsCrdt = class StreamsCrdt {
1163
1490
  this.debug = options.debug ?? false;
1164
1491
  this.beforeRemoteCursorSave = composeBeforeRemoteCursorSaveHooks(options.beforeRemoteCursorSave, ...options.beforeRemoteCursorSaveHooks ?? []);
1165
1492
  const snapshotCodec = normalizeSnapshotCodec(options.snapshotCodec);
1493
+ if (options.e2ee != null && options.payloadProtection != null) throw new Error("Specify only one of e2ee or payloadProtection");
1494
+ const payloadProtectionOption = options.e2ee ?? options.payloadProtection;
1495
+ const payloadProtectionLabel = options.e2ee != null ? "e2ee" : "payloadProtection";
1496
+ const payloadProtection = normalizePayloadProtection(payloadProtectionOption, payloadProtectionLabel);
1497
+ if (payloadProtection?.writePolicy === "plaintext" && options.snapshotUpload != null && options.snapshotUpload.enabled !== false) throw new Error(`snapshotUpload is not supported when ${payloadProtectionLabel}.writePolicy is plaintext`);
1166
1498
  const snapshotUploadConfig = normalizeSnapshotUploadConfig(options.snapshotUpload);
1167
- this.decodeSnapshot = snapshotCodec?.decompress;
1499
+ this.decodeSnapshot = this.composeSnapshotDecoder(payloadProtection, snapshotCodec?.decompress);
1500
+ this.encodeSnapshot = this.composeSnapshotEncoder(payloadProtection, snapshotCodec?.compress);
1501
+ this.decodeUpdateItems = payloadProtection == null ? void 0 : (items) => payloadProtection.decodeUpdateItems(items);
1502
+ this.encodeUpdateItems = payloadProtection == null ? void 0 : (updates) => payloadProtection.encodeUpdateItems(updates);
1503
+ this.measureUpdateItemsByteLength = payloadProtection == null ? void 0 : (updates) => payloadProtection.measureEncodedUpdateItemsByteLength(updates);
1168
1504
  this.client = new StreamsTransportClient({
1169
1505
  streamUrl: this.streamUrl,
1170
1506
  adapter: this.adapter,
@@ -1177,7 +1513,7 @@ var StreamsCrdt = class StreamsCrdt {
1177
1513
  this.snapshotManager = new TransportSnapshotManager({
1178
1514
  adapter: this.adapter,
1179
1515
  config: snapshotUploadConfig,
1180
- encodeSnapshot: snapshotCodec?.compress,
1516
+ encodeSnapshot: this.encodeSnapshot,
1181
1517
  readSnapshotOffsetValue: () => this.client.readSnapshotOffsetValue(),
1182
1518
  putSnapshotAt: (snapshotOffset, snapshot) => this.client.putSnapshotAt(snapshotOffset, snapshot),
1183
1519
  enqueueExclusive: (task) => this.enqueueExclusive(task),
@@ -1185,11 +1521,31 @@ var StreamsCrdt = class StreamsCrdt {
1185
1521
  logError: (message, error, detail) => this.logError(message, error, detail),
1186
1522
  isCurrentState: (state) => this.joinState === state
1187
1523
  });
1524
+ this.writeOnlyCursor = this.createInitialCursor();
1525
+ this.unsubscribeWriteOnlyLocal = this.adapter.subscribeLocalUpdates((batch) => {
1526
+ if (this.writeOnlyDisabledReason != null) return;
1527
+ this.pendingWriteOnly.pushBack(batch);
1528
+ });
1529
+ }
1530
+ composeSnapshotDecoder(payloadProtection, decompressSnapshot) {
1531
+ if (payloadProtection == null && decompressSnapshot == null) return;
1532
+ return async (snapshot) => {
1533
+ const clearSnapshot = payloadProtection == null ? snapshot : await payloadProtection.decodeSnapshot(snapshot);
1534
+ return decompressSnapshot == null ? clearSnapshot : await decompressSnapshot(clearSnapshot);
1535
+ };
1536
+ }
1537
+ composeSnapshotEncoder(payloadProtection, compressSnapshot) {
1538
+ if (payloadProtection == null && compressSnapshot == null) return;
1539
+ return async (snapshot) => {
1540
+ const compressedSnapshot = compressSnapshot == null ? snapshot : await compressSnapshot(snapshot);
1541
+ return payloadProtection == null ? compressedSnapshot : await payloadProtection.encodeSnapshot(compressedSnapshot);
1542
+ };
1188
1543
  }
1189
1544
  /**
1190
1545
  * Stops any active live join and releases transport-owned state.
1191
1546
  */
1192
1547
  async close() {
1548
+ this.disableWriteOnlyMode("transport closed");
1193
1549
  const state = this.joinState;
1194
1550
  if (state != null) await this.disposeJoinState(state);
1195
1551
  }
@@ -1206,7 +1562,27 @@ var StreamsCrdt = class StreamsCrdt {
1206
1562
  rejoin() {
1207
1563
  const state = this.joinState;
1208
1564
  if (state == null || state.closed) return;
1209
- if (state.readSubStatus === "ok" && state.writeSubStatus === "ok") return;
1565
+ if (state.initialSyncFailed) {
1566
+ state.initialSyncFailed = false;
1567
+ state.lastError = void 0;
1568
+ state.retryAttempt = 0;
1569
+ state.retrySleepController.abort();
1570
+ state.retrySleepController = new AbortController();
1571
+ state.readSubStatus = "connecting";
1572
+ state.writeSubStatus = "ok";
1573
+ this.recomputeJoinStatus(state);
1574
+ state.started = this.startJoin(state);
1575
+ state.started.catch((error) => {
1576
+ if (state.closed) return;
1577
+ const transportError = toTransportError(error);
1578
+ if (transportError.retryable) {
1579
+ state.initialSyncFailed = true;
1580
+ state.lastError = transportError;
1581
+ } else this.disposeJoinState(state);
1582
+ });
1583
+ return;
1584
+ }
1585
+ if ((state.readSubStatus === "ok" || state.readSubStatus === "connecting") && state.writeSubStatus === "ok") return;
1210
1586
  state.retryAttempt = 0;
1211
1587
  state.retrySleepController.abort();
1212
1588
  state.retrySleepController = new AbortController();
@@ -1240,6 +1616,33 @@ var StreamsCrdt = class StreamsCrdt {
1240
1616
  }
1241
1617
  }
1242
1618
  /**
1619
+ * Uploads a full adapter snapshot at the current remote tail.
1620
+ *
1621
+ * @internal Intended for end-to-end tests that must deterministically create
1622
+ * a remote snapshot without waiting for the best-effort join() debounce.
1623
+ */
1624
+ async uploadSnapshotForTesting() {
1625
+ try {
1626
+ await this.adapter.flushLocalExports?.();
1627
+ const cursor = await this.syncOnce();
1628
+ const rawSnapshot = this.adapter.exportSnapshot();
1629
+ const snapshot = this.encodeSnapshot == null ? rawSnapshot : await this.encodeSnapshot(rawSnapshot);
1630
+ await this.client.putSnapshotAt(cursor.nextOffset, snapshot);
1631
+ return {
1632
+ ok: true,
1633
+ value: {
1634
+ snapshotOffset: cursor.nextOffset,
1635
+ snapshotByteLength: snapshot.byteLength
1636
+ }
1637
+ };
1638
+ } catch (error) {
1639
+ return {
1640
+ ok: false,
1641
+ error: toTransportError(error)
1642
+ };
1643
+ }
1644
+ }
1645
+ /**
1243
1646
  * Explicitly creates the target stream.
1244
1647
  */
1245
1648
  async createStream() {
@@ -1283,6 +1686,7 @@ var StreamsCrdt = class StreamsCrdt {
1283
1686
  */
1284
1687
  async sync() {
1285
1688
  try {
1689
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after sync() on the same transport");
1286
1690
  return {
1287
1691
  ok: true,
1288
1692
  value: { cursor: await this.syncOnce() }
@@ -1295,27 +1699,75 @@ var StreamsCrdt = class StreamsCrdt {
1295
1699
  }
1296
1700
  }
1297
1701
  /**
1702
+ * Appends local CRDT updates without reading the stream first.
1703
+ *
1704
+ * Use this for write-only actors whose auth token may POST to the stream but
1705
+ * may not bootstrap, catch up, or join live reads. The method does not save a
1706
+ * RemoteCursorStore cursor and does not apply remote updates.
1707
+ */
1708
+ async appendWriteOnly() {
1709
+ return await this.enqueueExclusive(async () => {
1710
+ try {
1711
+ if (this.writeOnlyDisabledReason != null) throw new Error(this.writeOnlyDisabledReason);
1712
+ await this.adapter.flushLocalExports?.();
1713
+ const drained = await this.drainPendingWriteOnlyBatch();
1714
+ if (drained == null) return {
1715
+ ok: true,
1716
+ value: {
1717
+ appended: false,
1718
+ localVersion: this.writeOnlyCursor.serverLowerBoundVersion
1719
+ }
1720
+ };
1721
+ const nextCursor = await this.appendLocalBatch(this.writeOnlyCursor, drained.batch, false);
1722
+ for (let i = 0; i < drained.count; i += 1) this.pendingWriteOnly.popFront();
1723
+ this.writeOnlyCursor = nextCursor;
1724
+ return {
1725
+ ok: true,
1726
+ value: {
1727
+ appended: true,
1728
+ nextOffset: nextCursor.nextOffset,
1729
+ localVersion: nextCursor.serverLowerBoundVersion
1730
+ }
1731
+ };
1732
+ } catch (error) {
1733
+ return {
1734
+ ok: false,
1735
+ error: toTransportError(error)
1736
+ };
1737
+ }
1738
+ });
1739
+ }
1740
+ /**
1298
1741
  * Replays remote state, flushes queued local updates, then enters live read mode.
1299
1742
  */
1300
1743
  async join(params) {
1744
+ this.disableWriteOnlyMode("appendWriteOnly cannot be used after join() on the same transport");
1301
1745
  const existing = this.joinState;
1302
- if (existing != null) try {
1303
- await existing.started;
1304
- return {
1746
+ if (existing != null) {
1747
+ if (existing.initialSyncFailed && !existing.closed) return {
1305
1748
  ok: true,
1306
1749
  value: this.createSubscription(existing, params)
1307
1750
  };
1308
- } catch (error) {
1309
- return {
1310
- ok: false,
1311
- error: toTransportError(error)
1312
- };
1751
+ try {
1752
+ await existing.started;
1753
+ return {
1754
+ ok: true,
1755
+ value: this.createSubscription(existing, params)
1756
+ };
1757
+ } catch (error) {
1758
+ return {
1759
+ ok: false,
1760
+ error: toTransportError(error)
1761
+ };
1762
+ }
1313
1763
  }
1314
1764
  const state = createJoinState(createInitialRemoteCursor({
1315
1765
  streamUrl: this.streamUrl,
1316
1766
  nextOffset: "-1",
1317
1767
  serverLowerBoundVersion: this.adapter.emptyVersion()
1318
1768
  }));
1769
+ const statusListener = params?.onStatusChange;
1770
+ if (statusListener != null) state.statusListeners.add(statusListener);
1319
1771
  state.started = this.startJoin(state);
1320
1772
  this.joinState = state;
1321
1773
  try {
@@ -1325,10 +1777,20 @@ var StreamsCrdt = class StreamsCrdt {
1325
1777
  value: this.createSubscription(state, params)
1326
1778
  };
1327
1779
  } catch (error) {
1780
+ const transportError = toTransportError(error);
1781
+ if (transportError.retryable && !state.closed) {
1782
+ state.initialSyncFailed = true;
1783
+ state.lastError = transportError;
1784
+ if (statusListener != null) state.statusListeners.delete(statusListener);
1785
+ return {
1786
+ ok: false,
1787
+ error: transportError
1788
+ };
1789
+ }
1328
1790
  await this.disposeJoinState(state);
1329
1791
  return {
1330
1792
  ok: false,
1331
- error: toTransportError(error)
1793
+ error: transportError
1332
1794
  };
1333
1795
  }
1334
1796
  }
@@ -1336,8 +1798,10 @@ var StreamsCrdt = class StreamsCrdt {
1336
1798
  state.refCount += 1;
1337
1799
  const statusListener = params?.onStatusChange;
1338
1800
  if (statusListener != null) {
1339
- state.statusListeners.add(statusListener);
1340
- statusListener(state.status);
1801
+ if (!state.statusListeners.has(statusListener)) {
1802
+ state.statusListeners.add(statusListener);
1803
+ statusListener(state.status);
1804
+ }
1341
1805
  }
1342
1806
  let unsubscribed = false;
1343
1807
  return {
@@ -1353,7 +1817,10 @@ var StreamsCrdt = class StreamsCrdt {
1353
1817
  return state.status === "joined";
1354
1818
  },
1355
1819
  get status() {
1356
- return state.status;
1820
+ return state.status === "connecting" ? "reconnecting" : state.status;
1821
+ },
1822
+ get lastError() {
1823
+ return state.lastError;
1357
1824
  },
1358
1825
  onStatusChange: (listener) => {
1359
1826
  state.statusListeners.add(listener);
@@ -1363,6 +1830,20 @@ var StreamsCrdt = class StreamsCrdt {
1363
1830
  };
1364
1831
  },
1365
1832
  waitUntilSynced: async () => {
1833
+ if (state.initialSyncFailed) {
1834
+ const msg = state.lastError?.code ? `initial sync failed: ${state.lastError.code}` : "initial sync failed";
1835
+ throw new Error(msg);
1836
+ }
1837
+ if (state.readSubStatus === "connecting") try {
1838
+ await state.started;
1839
+ } catch (error) {
1840
+ const te = toTransportError(error);
1841
+ throw new Error(te.code ? `initial sync failed: ${te.code}` : "initial sync failed");
1842
+ }
1843
+ if (state.writeSubStatus === "error") {
1844
+ const msg = state.lastError?.code ? `local append failed: ${state.lastError.code}` : "local append failed";
1845
+ throw new Error(msg);
1846
+ }
1366
1847
  await this.adapter.flushLocalExports?.();
1367
1848
  if (state.closed) throw new Error("subscription closed");
1368
1849
  const remaining = state.pendingLocal.length;
@@ -1405,12 +1886,14 @@ var StreamsCrdt = class StreamsCrdt {
1405
1886
  if (state.status === "error" || state.status === "disconnected") throw new Error("join local sync failed before subscription became ready");
1406
1887
  }
1407
1888
  if (state.closed) return;
1889
+ state.lastError = void 0;
1408
1890
  this.setReadSubStatus(state, "ok");
1409
1891
  this.setWriteSubStatus(state, "ok");
1410
1892
  this.runReadLoop(state);
1411
1893
  } catch (error) {
1412
1894
  if (state.closed) return;
1413
1895
  this.snapshotManager.dispose(state);
1896
+ state.lastError = toTransportError(error);
1414
1897
  this.logError("join first sync failed", error, { streamUrl: this.streamUrl });
1415
1898
  state.readSubStatus = "error";
1416
1899
  state.writeSubStatus = "error";
@@ -1533,7 +2016,8 @@ var StreamsCrdt = class StreamsCrdt {
1533
2016
  for (let attempt = 0; attempt < 3; attempt += 1) {
1534
2017
  let result;
1535
2018
  try {
1536
- result = await this.client.appendUpdates(batch.updates, producerSession);
2019
+ const wireUpdates = this.encodeUpdateItems == null ? batch.updates : await this.encodeUpdateItems(batch.updates);
2020
+ result = await this.client.appendUpdates(wireUpdates, producerSession);
1537
2021
  } catch (error) {
1538
2022
  if (isStreamNotFoundError(error) && this.createStreamIfMissing) {
1539
2023
  await this.ensureStreamExists();
@@ -1597,6 +2081,7 @@ var StreamsCrdt = class StreamsCrdt {
1597
2081
  return new TransportCursorManager({
1598
2082
  adapter,
1599
2083
  decodeSnapshot: this.decodeSnapshot,
2084
+ decodeUpdateItems: this.decodeUpdateItems,
1600
2085
  saveRemoteCursor
1601
2086
  });
1602
2087
  }
@@ -1692,7 +2177,9 @@ var StreamsCrdt = class StreamsCrdt {
1692
2177
  const pendingRemote = [];
1693
2178
  let flushChain = Promise.resolve();
1694
2179
  resetInactivity();
2180
+ const sseStartedAtMs = Date.now();
1695
2181
  const sseResult = await this.client.readFromOffsetSseItems(sseRequestOffset, state.streamCursor, (updates, nextOffset, _upToDate, streamCursor) => {
2182
+ if (capturedController?.signal.aborted) return "stop";
1696
2183
  resetInactivity();
1697
2184
  pendingRemote.push({
1698
2185
  updates,
@@ -1701,7 +2188,7 @@ var StreamsCrdt = class StreamsCrdt {
1701
2188
  });
1702
2189
  flushChain = flushChain.then(async () => {
1703
2190
  const events = pendingRemote.splice(0);
1704
- if (events.length === 0 || state.closed) return;
2191
+ if (events.length === 0 || state.closed || capturedController?.signal.aborted) return;
1705
2192
  const allUpdates = events.flatMap((e) => e.updates);
1706
2193
  const lastEvent = events[events.length - 1];
1707
2194
  await this.enqueueExclusive(async () => {
@@ -1718,18 +2205,21 @@ var StreamsCrdt = class StreamsCrdt {
1718
2205
  }
1719
2206
  });
1720
2207
  this.setReadSubStatus(state, "ok");
2208
+ }).catch((flushError) => {
2209
+ this.logError("sse flush chain error", flushError, { streamUrl: this.streamUrl });
2210
+ capturedController?.abort(flushError);
1721
2211
  });
1722
2212
  }, () => {
1723
2213
  resetInactivity();
1724
2214
  this.setReadSubStatus(state, "ok");
1725
- }, capturedController?.signal);
2215
+ }, capturedController?.signal, resetInactivity);
1726
2216
  clearInactivity();
1727
2217
  await flushChain;
1728
2218
  if (state.closed) return;
1729
2219
  if (sseResult.kind === "ok") {
1730
2220
  state.retryAttempt = 0;
1731
2221
  state.streamCursor = sseResult.latestCursor;
1732
- await sleep(0);
2222
+ await sleep(fastSseCloseDelay(sseStartedAtMs), state.retrySleepController.signal);
1733
2223
  continue;
1734
2224
  }
1735
2225
  if (sseResult.kind === "unsupported") {
@@ -1738,10 +2228,12 @@ var StreamsCrdt = class StreamsCrdt {
1738
2228
  continue;
1739
2229
  }
1740
2230
  if (sseResult.kind === "not-found") {
1741
- this.logError("live read failed", new HttpError("stream not found", 404), {
2231
+ const notFoundError = new HttpError("stream not found", 404);
2232
+ this.logError("live read failed", notFoundError, {
1742
2233
  streamUrl: this.streamUrl,
1743
2234
  code: "stream_not_found"
1744
2235
  });
2236
+ state.lastError = toTransportError(notFoundError);
1745
2237
  this.setReadSubStatus(state, "error");
1746
2238
  return;
1747
2239
  }
@@ -1767,12 +2259,33 @@ var StreamsCrdt = class StreamsCrdt {
1767
2259
  streamUrl: this.streamUrl,
1768
2260
  attempts: state.retryAttempt
1769
2261
  });
2262
+ state.lastError = {
2263
+ code: "timeout",
2264
+ retryable: true,
2265
+ phase: "poll",
2266
+ message: "SSE inactivity timeout exhausted retry budget"
2267
+ };
1770
2268
  this.setReadSubStatus(state, "disconnected");
1771
2269
  return;
1772
2270
  }
1773
2271
  state.retryAttempt += 1;
2272
+ await sleep(reconnectDelayWithFloor(state.retryAttempt - 1, this.reconnectConfig), state.retrySleepController.signal);
1774
2273
  continue;
1775
2274
  }
2275
+ const abortReason = capturedController.signal.reason;
2276
+ const flushError = abortReason instanceof Error ? abortReason : error;
2277
+ if (flushError instanceof Error && !(flushError instanceof DOMException && flushError.name === "AbortError")) {
2278
+ const flushClassified = classifyFetchError(flushError);
2279
+ if (!flushClassified.retriable || isAuthOrProtocolError(flushError)) {
2280
+ this.logError("live read failed (flush chain)", flushError, {
2281
+ streamUrl: this.streamUrl,
2282
+ code: flushClassified.code
2283
+ });
2284
+ state.lastError = toTransportError(flushError);
2285
+ this.setReadSubStatus(state, "error");
2286
+ return;
2287
+ }
2288
+ }
1776
2289
  state.retryAttempt = 0;
1777
2290
  this.setReadSubStatus(state, "reconnecting");
1778
2291
  continue;
@@ -1783,6 +2296,7 @@ var StreamsCrdt = class StreamsCrdt {
1783
2296
  streamUrl: this.streamUrl,
1784
2297
  code: classified.code
1785
2298
  });
2299
+ state.lastError = toTransportError(error);
1786
2300
  this.setReadSubStatus(state, "error");
1787
2301
  return;
1788
2302
  }
@@ -1793,6 +2307,7 @@ var StreamsCrdt = class StreamsCrdt {
1793
2307
  streamUrl: this.streamUrl,
1794
2308
  attempts: state.retryAttempt
1795
2309
  });
2310
+ state.lastError = toTransportError(error);
1796
2311
  this.setReadSubStatus(state, "disconnected");
1797
2312
  return;
1798
2313
  }
@@ -1810,20 +2325,19 @@ var StreamsCrdt = class StreamsCrdt {
1810
2325
  *
1811
2326
  * Returns `undefined` when the queue is empty.
1812
2327
  */
1813
- drainPendingBatch(state) {
1814
- const first = state.pendingLocal.peekFront();
2328
+ async drainQueuedBatch(pendingLocal) {
2329
+ const first = pendingLocal.peekFront();
1815
2330
  if (first == null) return;
1816
2331
  const maxBytes = StreamsCrdt.MAX_BATCH_BYTES;
1817
- let totalBytes = first.updates.reduce((sum, u) => sum + 4 + u.byteLength, 0);
1818
2332
  let mergedVersion = first.version;
1819
2333
  const allUpdates = Array.from(first.updates);
1820
2334
  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;
2335
+ await this.measureAppendBodyByteLength(allUpdates);
2336
+ while (count < pendingLocal.length) {
2337
+ const next = pendingLocal.at(count);
2338
+ const candidateUpdates = [...allUpdates, ...next.updates];
2339
+ if (await this.measureAppendBodyByteLength(candidateUpdates) > maxBytes) break;
1825
2340
  allUpdates.push(...next.updates);
1826
- totalBytes += nextBytes;
1827
2341
  mergedVersion = this.adapter.mergeVersions(mergedVersion, next.version);
1828
2342
  count += 1;
1829
2343
  }
@@ -1839,12 +2353,18 @@ var StreamsCrdt = class StreamsCrdt {
1839
2353
  count
1840
2354
  };
1841
2355
  }
2356
+ drainPendingBatch(state) {
2357
+ return this.drainQueuedBatch(state.pendingLocal);
2358
+ }
2359
+ drainPendingWriteOnlyBatch() {
2360
+ return this.drainQueuedBatch(this.pendingWriteOnly);
2361
+ }
1842
2362
  async flushPendingLocal(state) {
1843
2363
  if (state.flushingLocal || state.closed) return;
1844
2364
  state.flushingLocal = true;
1845
2365
  try {
1846
2366
  while (!state.closed) {
1847
- const drained = this.drainPendingBatch(state);
2367
+ const drained = await this.drainPendingBatch(state);
1848
2368
  if (drained == null) return;
1849
2369
  let attempts = 0;
1850
2370
  while (!state.closed) try {
@@ -1861,12 +2381,14 @@ var StreamsCrdt = class StreamsCrdt {
1861
2381
  streamUrl: this.streamUrl,
1862
2382
  code: classified.code
1863
2383
  });
2384
+ state.lastError = toTransportError(error);
1864
2385
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error(`local append failed: ${classified.code}`));
1865
2386
  this.setWriteSubStatus(state, "error");
1866
2387
  return;
1867
2388
  }
1868
2389
  const maxAttempts = this.reconnectConfig.maxAttempts;
1869
2390
  if (attempts >= maxAttempts) {
2391
+ state.lastError = toTransportError(error);
1870
2392
  this.rejectSyncWaiters(state, /* @__PURE__ */ new Error("local append retry budget exhausted"));
1871
2393
  this.setWriteSubStatus(state, "error");
1872
2394
  return;
@@ -1922,6 +2444,7 @@ var StreamsCrdt = class StreamsCrdt {
1922
2444
  const status = this.computeOverallStatus(state);
1923
2445
  if (state.status === status) return;
1924
2446
  state.status = status;
2447
+ if (status === "joined") state.lastError = void 0;
1925
2448
  for (const listener of state.statusListeners) listener(status);
1926
2449
  }
1927
2450
  computeOverallStatus(state) {
@@ -1937,6 +2460,17 @@ var StreamsCrdt = class StreamsCrdt {
1937
2460
  this.opQueue = run.then(() => void 0, () => void 0);
1938
2461
  return run;
1939
2462
  }
2463
+ async measureAppendBodyByteLength(updates) {
2464
+ if (this.measureUpdateItemsByteLength != null) return await this.measureUpdateItemsByteLength(updates);
2465
+ return updates.reduce((sum, update) => sum + 4 + update.byteLength, 0);
2466
+ }
2467
+ disableWriteOnlyMode(reason) {
2468
+ if (this.writeOnlyDisabledReason != null) return;
2469
+ this.writeOnlyDisabledReason = reason;
2470
+ this.pendingWriteOnly.clear();
2471
+ this.unsubscribeWriteOnlyLocal?.();
2472
+ this.unsubscribeWriteOnlyLocal = void 0;
2473
+ }
1940
2474
  logDebug(message, detail) {
1941
2475
  if (!this.debug) return;
1942
2476
  if (detail == null) {
@@ -1988,6 +2522,6 @@ function createStreamUrl(input) {
1988
2522
  return `${baseUrl}/ds/${encodeURIComponent(input.bucketId)}/${encodeURIComponent(streamId)}`;
1989
2523
  }
1990
2524
  //#endregion
1991
- export { InMemoryRemoteCursorStore as a, StreamsCrdt as i, isValidBucketId as n, IndexedDbRemoteCursorStore as o, isValidRillId as r, createInitialRemoteCursor as s, createStreamUrl as t };
2525
+ 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
2526
 
1993
- //# sourceMappingURL=stream-id-0dTFsiaO.js.map
2527
+ //# sourceMappingURL=stream-id-D0qcbI5c.js.map