@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.
- package/README.md +305 -1
- package/dist/flock.d.ts +2 -2
- package/dist/flock.js +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/loro.d.ts +2 -2
- package/dist/loro.js +2 -2
- package/dist/{stream-id-Dr8ZBpUQ.d.ts → stream-id-CS7SzuLW.d.ts} +148 -2
- package/dist/{stream-id-0dTFsiaO.js → stream-id-D0qcbI5c.js} +697 -163
- package/dist/stream-id-D0qcbI5c.js.map +1 -0
- package/package.json +5 -3
- package/dist/stream-id-0dTFsiaO.js.map +0 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
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
|
-
|
|
828
|
-
|
|
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
|
-
|
|
842
|
-
|
|
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:
|
|
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.
|
|
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)
|
|
1303
|
-
|
|
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
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
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:
|
|
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.
|
|
1340
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1814
|
-
const first =
|
|
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
|
-
|
|
1822
|
-
|
|
1823
|
-
const
|
|
1824
|
-
|
|
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 {
|
|
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-
|
|
2527
|
+
//# sourceMappingURL=stream-id-D0qcbI5c.js.map
|