@interop/was-client 0.98.0 → 0.99.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +59 -41
  2. package/dist/Collection.d.ts +43 -15
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +127 -54
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/Resource.d.ts +26 -11
  7. package/dist/Resource.d.ts.map +1 -1
  8. package/dist/Resource.js +26 -11
  9. package/dist/Resource.js.map +1 -1
  10. package/dist/WasClient.d.ts +20 -4
  11. package/dist/WasClient.d.ts.map +1 -1
  12. package/dist/WasClient.js +27 -6
  13. package/dist/WasClient.js.map +1 -1
  14. package/dist/codec.d.ts +6 -14
  15. package/dist/codec.d.ts.map +1 -1
  16. package/dist/codec.js.map +1 -1
  17. package/dist/edv/EdvCodec.d.ts +2 -1
  18. package/dist/edv/EdvCodec.d.ts.map +1 -1
  19. package/dist/edv/EdvCodec.js +312 -80
  20. package/dist/edv/EdvCodec.js.map +1 -1
  21. package/dist/edv/WasTransport.d.ts +76 -12
  22. package/dist/edv/WasTransport.d.ts.map +1 -1
  23. package/dist/edv/WasTransport.js +76 -11
  24. package/dist/edv/WasTransport.js.map +1 -1
  25. package/dist/edv/docCipher.d.ts.map +1 -1
  26. package/dist/edv/docCipher.js +11 -9
  27. package/dist/edv/docCipher.js.map +1 -1
  28. package/dist/edv/index.d.ts +1 -0
  29. package/dist/edv/index.d.ts.map +1 -1
  30. package/dist/edv/index.js.map +1 -1
  31. package/dist/edv/transportFactory.d.ts.map +1 -1
  32. package/dist/edv/transportFactory.js +3 -2
  33. package/dist/edv/transportFactory.js.map +1 -1
  34. package/dist/errors.d.ts +13 -6
  35. package/dist/errors.d.ts.map +1 -1
  36. package/dist/errors.js +16 -7
  37. package/dist/errors.js.map +1 -1
  38. package/dist/index.d.ts +1 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/internal/codec.d.ts +51 -2
  43. package/dist/internal/codec.d.ts.map +1 -1
  44. package/dist/internal/codec.js +53 -21
  45. package/dist/internal/codec.js.map +1 -1
  46. package/dist/internal/indexSchema.d.ts +30 -0
  47. package/dist/internal/indexSchema.d.ts.map +1 -1
  48. package/dist/internal/indexSchema.js +40 -0
  49. package/dist/internal/indexSchema.js.map +1 -1
  50. package/dist/internal/meta.d.ts +9 -3
  51. package/dist/internal/meta.d.ts.map +1 -1
  52. package/dist/internal/meta.js +13 -7
  53. package/dist/internal/meta.js.map +1 -1
  54. package/dist/internal/paths.d.ts +11 -0
  55. package/dist/internal/paths.d.ts.map +1 -1
  56. package/dist/internal/paths.js +13 -0
  57. package/dist/internal/paths.js.map +1 -1
  58. package/dist/internal/request.d.ts +9 -0
  59. package/dist/internal/request.d.ts.map +1 -1
  60. package/dist/internal/request.js +31 -3
  61. package/dist/internal/request.js.map +1 -1
  62. package/dist/internal/write.d.ts +10 -10
  63. package/dist/internal/write.d.ts.map +1 -1
  64. package/dist/internal/write.js +23 -30
  65. package/dist/internal/write.js.map +1 -1
  66. package/package.json +5 -5
@@ -38,13 +38,15 @@
38
38
  * is encrypted as one document -- stored as a legible UTF-8 string for a
39
39
  * text-family type (else base64) -- with the plaintext content type and the
40
40
  * encoding carried in the document `meta`. A blob over `maxBlobBytes`, and
41
- * any `ReadableStream` (whose size is unknown), is auto-routed by `add()` to
42
- * the chunked-stream path instead: `encode` returns a multi-request plan the
43
- * write path executes, storing one document plus its chunk resources over a
44
- * `WasTransport` supplied by the injected `CodecTransportFactory`. Reads
45
- * reassemble transparently, so `get()` returns the same `Blob` either way,
46
- * and `decodeStream` hands a chunked document's decrypt stream back
47
- * unbuffered.
41
+ * any `ReadableStream` (whose size is unknown), is auto-routed by `add()` and
42
+ * `put()` alike to the chunked-stream path instead: `encode` returns a
43
+ * multi-request plan the write path executes, storing one document plus its
44
+ * chunk resources over a `WasTransport` supplied by the injected
45
+ * `CodecTransportFactory`. A `put()` over a chunked document is a plan too,
46
+ * whatever its payload: the prior document's chunks that the new one does
47
+ * not cover are reaped after the write. Reads reassemble transparently, so
48
+ * `get()` returns the same `Blob` either way, and `decodeStream` hands a
49
+ * chunked document's decrypt stream back unbuffered.
48
50
  * - **Enforced sequence (conditional writes).** The codec sets
49
51
  * `conditionalWrites`, so the write path pre-reads the current envelope and
50
52
  * hands it to `encode`: an update advances `sequence` from its prior value and
@@ -102,6 +104,13 @@ export const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
102
104
  * decrypted document self-describing alongside `'utf-8'` and `'base64'`.
103
105
  */
104
106
  const CHUNKED_ENCODING = 'chunked';
107
+ /**
108
+ * How many stale chunks a reap deletes at once. Each delete is one signed
109
+ * request that is independent of the others (pinned and 404-tolerant, in no
110
+ * required order), so a shrunk document's leftovers go a few at a time rather
111
+ * than one awaited round trip per chunk.
112
+ */
113
+ const REAP_CONCURRENCY = 8;
105
114
  /**
106
115
  * A shared strict UTF-8 decoder used to test whether a non-JSON payload is
107
116
  * valid UTF-8 (so it can be stored legibly as text rather than base64).
@@ -452,6 +461,41 @@ export class EdvCodec {
452
461
  ? undefined
453
462
  : (await this.#edv.generateId()));
454
463
  const parts = await this.#toDocument(data, contentType, docId);
464
+ // When the write path pre-read a current envelope, this is an update:
465
+ // `sequence` advances from the prior value (`encrypt({ update: true })`
466
+ // increments it), the write is pinned to the server's current ETag with
467
+ // `If-Match`, and the write learns whether the prior document's bytes
468
+ // live in chunk resources, so they can be reaped once this write lands.
469
+ // Only the sealed `meta.encoding` says so for certain, and reading it
470
+ // means opening the envelope (decrypting it, with its binding verified
471
+ // against this id, as a read would): a key unwrap per update. The
472
+ // cleartext `stream` member a chunked write leaves on the envelope is the
473
+ // cue to pay for that; an envelope without one is taken as inline and is
474
+ // not opened. The cue is server-controlled, but nothing rests on it: a
475
+ // server that strips it can only leave the prior chunks stranded (stored
476
+ // and counted against quota, unreachable, removed with the resource), and
477
+ // one that adds it costs this write a decrypt that finds an inline
478
+ // encoding. With no prior envelope this is a fresh insert (`sequence: 0`)
479
+ // guarded by `If-None-Match: *` (create-if-absent), so a concurrent first
480
+ // writer cannot be clobbered.
481
+ let prior = null;
482
+ if (current) {
483
+ const read = await readJsonData(current);
484
+ this.#assertEnvelope(read, 'update');
485
+ const chunked = read.stream !== undefined &&
486
+ (await this.#openEnvelope({ doc: read, expectedId: id })).meta
487
+ ?.encoding === CHUNKED_ENCODING;
488
+ // The caller's own compare-and-swap baseline wins when they named one:
489
+ // the write path has already checked it against the pre-read, so the
490
+ // two agree by the time this runs.
491
+ const etag = precondition?.ifMatch ?? readEtag(current);
492
+ prior = {
493
+ sequence: read.sequence,
494
+ ...(read.indexed !== undefined && { indexed: read.indexed }),
495
+ ...(etag !== undefined && { etag }),
496
+ chunked
497
+ };
498
+ }
455
499
  if (parts.kind === 'chunked') {
456
500
  if (docId === undefined) {
457
501
  // A stream is chunked whatever its size, so only a sized payload can
@@ -473,21 +517,11 @@ export class EdvCodec {
473
517
  return this.#chunkedWrite({
474
518
  id: docId,
475
519
  stream: parts.stream,
476
- meta: parts.meta
520
+ meta: parts.meta,
521
+ prior
477
522
  });
478
523
  }
479
524
  const { content, meta } = parts;
480
- // When the write path pre-read a current envelope, advance `sequence` from
481
- // its prior value (`encrypt({ update: true })` increments it) and pin the
482
- // write to the server's current ETag with `If-Match`. With no prior envelope
483
- // this is a fresh insert (`sequence: 0`) guarded by `If-None-Match: *`
484
- // (create-if-absent), so a concurrent first writer cannot be clobbered.
485
- let priorDoc = null;
486
- if (current) {
487
- const read = await readJsonData(current);
488
- this.#assertEnvelope(read, 'update');
489
- priorDoc = read;
490
- }
491
525
  const { documentCipher } = this.#edv;
492
526
  const encrypted = await documentCipher.encrypt({
493
527
  doc: {
@@ -497,14 +531,14 @@ export class EdvCodec {
497
531
  // the plaintext content type and the inline-encoding discriminator,
498
532
  // taken fresh from this write (the new type/encoding wins on update).
499
533
  meta,
500
- ...(priorDoc && { sequence: priorDoc.sequence }),
534
+ ...(prior && { sequence: prior.sequence }),
501
535
  // Carry the prior envelope's blinded `indexed` entries into the
502
536
  // update. With no blinding key edv-client stores `doc.indexed`
503
537
  // verbatim; omitting it would replace the entries with `[]` and
504
538
  // silently drop the record from `find()`. With a blinding key the
505
539
  // entries are recomputed from the schema and this copy is the prior
506
540
  // state that recomputation updates.
507
- ...(priorDoc?.indexed !== undefined && { indexed: priorDoc.indexed })
541
+ ...(prior?.indexed !== undefined && { indexed: prior.indexed })
508
542
  },
509
543
  // Bind an AEAD-authenticated `was` parameter into the JWE protected
510
544
  // header: the scheme version, the resource id when known at encrypt time
@@ -521,7 +555,7 @@ export class EdvCodec {
521
555
  ...(docId !== undefined && { resource: docId }),
522
556
  hmac: this.#writeBlindingKey()
523
557
  }),
524
- update: priorDoc !== null
558
+ update: prior !== null
525
559
  });
526
560
  if (docId === undefined) {
527
561
  // Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
@@ -530,6 +564,12 @@ export class EdvCodec {
530
564
  docId = await documentCipher.deriveId({ jwe: encrypted.jwe });
531
565
  encrypted.id = docId;
532
566
  }
567
+ if (prior?.chunked) {
568
+ // The envelope replaces a document whose bytes live in chunk resources,
569
+ // which the inline document leaves behind: a plan writes it and reaps
570
+ // them, since one request cannot do both.
571
+ return this.#replacingWrite({ id: docId, encrypted, meta, prior });
572
+ }
533
573
  // Serialized on first read of `body` and kept for any later read, so the
534
574
  // wire bytes are built once per write and only when someone asks for them.
535
575
  let bytes;
@@ -564,9 +604,7 @@ export class EdvCodec {
564
604
  // (create-only-if-absent), and the server enforces it, so a masked-404
565
605
  // pre-read cannot silently overwrite an existing document.
566
606
  ...(precondition ??
567
- (priorDoc
568
- ? { ifMatch: readEtag(current ?? null) }
569
- : { ifNoneMatch: true })),
607
+ (prior ? { ifMatch: prior.etag } : { ifNoneMatch: true })),
570
608
  // Stamp the key epoch this write encrypted under (the `currentEpoch`), so
571
609
  // the server records it and a reader can pick the epoch key.
572
610
  epoch: this.#writeEpoch
@@ -579,9 +617,11 @@ export class EdvCodec {
579
617
  * @param context {CodecRequestContext}
580
618
  * @param [documentHeaders] {Record<string, string>} extra headers for
581
619
  * document writes (the `Key-Epoch` stamp the codec seam applies)
620
+ * @param [baseline] {StoredDocument} the stored document the transport's
621
+ * first update replaces, which it pins that update to
582
622
  * @returns {WasTransport}
583
623
  */
584
- #transportFor(context, documentHeaders) {
624
+ #transportFor(context, documentHeaders, baseline) {
585
625
  if (this.#transportFactory === undefined) {
586
626
  throw new NotSupportedError(`Collection "${this.#collectionId}" has no server behind it: this ` +
587
627
  'codec was built for a local replica and holds no route to address ' +
@@ -591,14 +631,19 @@ export class EdvCodec {
591
631
  'its `spaceId`, or by passing a `chunkSource` to the DocCipher ' +
592
632
  '`decrypt` in place of a request context.');
593
633
  }
594
- return this.#transportFactory({ context, documentHeaders });
634
+ return this.#transportFactory({
635
+ context,
636
+ documentHeaders,
637
+ ...(baseline !== undefined && { baseline })
638
+ });
595
639
  }
596
640
  /**
597
641
  * The plan for a binary payload over the single-document threshold, or for
598
642
  * a stream of any size: one EDV document plus its chunk resources, written
599
- * by `EdvClientCore.insert({ doc, stream, transport })` over a transport
600
- * built from the write context. The document id is minted before the plan
601
- * is returned, so the caller can report it without waiting for the write.
643
+ * by `EdvClientCore.insert({ doc, stream, transport })` (or, over a stored
644
+ * document, `update`) on a transport built from the write context. The
645
+ * document id is minted before the plan is returned, so the caller can
646
+ * report it without waiting for the write.
602
647
  *
603
648
  * The `was` binding, the recipients and the write epoch are exactly the
604
649
  * single-document path's, and `additionalProtectedParams` carries the binding
@@ -609,69 +654,240 @@ export class EdvCodec {
609
654
  * payload, so that discriminator is what the read side routes on: a server
610
655
  * cannot mint it, and cannot suppress it to hide the chunks either.
611
656
  *
612
- * The write is a create-if-absent: the first document write carries
613
- * `If-None-Match: *`, so the plan also serves a write by id (`put()`) at an
614
- * id that holds no document yet. A 412 there surfaces as the
615
- * `PreconditionFailedError` the request path raised.
657
+ * With no prior document the write is a create-if-absent: the first
658
+ * document write carries `If-None-Match: *`, so the plan serves `add()` and
659
+ * a write by id (`put()`) at an id that holds no document alike. A 412 there
660
+ * surfaces as the `PreconditionFailedError` the request path raised. Over a
661
+ * prior document the first write is pinned to the pre-read's `ETag`, so a
662
+ * 412 is the ordinary lost-update signal, and the new chunks overwrite the
663
+ * prior ones index by index. Once the write is stored, a prior document
664
+ * that was itself chunked has every chunk beyond the new count reaped
665
+ * ({@link #reapChunks}): the new document's sealed count already makes them
666
+ * unreachable, and the reaping frees them.
616
667
  *
617
- * The write is two-phase (`EdvClientCore.insert` writes the document, then
618
- * streams the chunks), so a failure partway leaves a pending stub: a
619
- * document whose sealed stream state is still `{ pending: true }`,
620
- * undecryptable and listed. The plan therefore compensates: if the document
621
- * was written and the write then failed, it best-effort deletes the stub
622
- * before rethrowing. A stub that survives (the process died, or the cleanup
623
- * failed) is recognized with {@link isPendingStub}. The EDV core cancels the
624
- * caller's source stream on such a failure, so the source behind it is
625
- * released.
668
+ * The write is two-phase (the EDV core writes the document, then streams
669
+ * the chunks, then writes the document again), so a failure partway leaves
670
+ * a pending stub: a document whose sealed stream state is still
671
+ * `{ pending: true }`, undecryptable and listed. A create compensates: if
672
+ * the document was written and the write then failed, the plan best-effort
673
+ * deletes the stub before rethrowing. A replace cannot restore the document
674
+ * it overwrote, so the stub is left in place for the next write at the id
675
+ * to replace, or for a `delete()`. A stub that survives is recognized with
676
+ * {@link isPendingStub}. The EDV core cancels the caller's source stream on
677
+ * such a failure, so the source behind it is released.
626
678
  *
627
679
  * @param options {object}
628
680
  * @param options.id {string} the document id (minted, or the caller's)
629
681
  * @param options.stream {ReadableStream<Uint8Array>} the payload, as the
630
682
  * stream the EDV core re-chunks (never buffered whole by this codec)
631
683
  * @param options.meta {Record<string, unknown>} the document meta to seal
684
+ * @param options.prior {PriorDocument | null} the stored document this
685
+ * write replaces, or `null` for a create
632
686
  * @returns {ChunkedWrite}
633
687
  */
634
- #chunkedWrite({ id, stream, meta }) {
688
+ #chunkedWrite({ id, stream, meta, prior }) {
689
+ return this.#writePlan({
690
+ id,
691
+ meta,
692
+ prior,
693
+ write: async (transport) => {
694
+ const write = {
695
+ stream,
696
+ ...(this.#chunkSize !== undefined && {
697
+ chunkSize: this.#chunkSize
698
+ }),
699
+ ...this.#sealParams({
700
+ resource: id,
701
+ hmac: this.#writeBlindingKey()
702
+ }),
703
+ transport
704
+ };
705
+ const stored = prior === null
706
+ ? await this.#edv.insert({
707
+ doc: { id, content: {}, meta },
708
+ ...write
709
+ })
710
+ : await this.#edv.update({
711
+ doc: {
712
+ id,
713
+ content: {},
714
+ meta,
715
+ sequence: prior.sequence,
716
+ ...(prior.indexed !== undefined && {
717
+ indexed: prior.indexed
718
+ })
719
+ },
720
+ ...write
721
+ });
722
+ // The EDV core reports the chunk count it sealed into the final
723
+ // document write; every stored chunk at or past it is the prior
724
+ // document's.
725
+ return stored.stream.chunks;
726
+ }
727
+ });
728
+ }
729
+ /**
730
+ * The plan for an inline document that replaces a chunked one: the sealed
731
+ * envelope is written as the single-document path would write it, pinned to
732
+ * the pre-read's `ETag` and stamped with the write epoch, and the prior
733
+ * document's chunks are then reaped ({@link #reapChunks}). One request
734
+ * cannot do both, which is why the write is a plan rather than an
735
+ * `EncodedWrite`. The envelope's sealed `meta.encoding` is inline, so a
736
+ * reader of the new document never looks for chunks, and the reaping only
737
+ * frees what is already unreachable.
738
+ *
739
+ * @param options {object}
740
+ * @param options.id {string} the document id
741
+ * @param options.encrypted {IEncryptedDocument} the sealed envelope
742
+ * @param options.meta {Record<string, unknown>} the document meta sealed
743
+ * in it, for the plaintext content type
744
+ * @param options.prior {PriorDocument} the chunked document it replaces
745
+ * @returns {ChunkedWrite}
746
+ */
747
+ #replacingWrite({ id, encrypted, meta, prior }) {
748
+ return this.#writePlan({
749
+ id,
750
+ meta,
751
+ prior,
752
+ write: async (transport) => {
753
+ await transport.update({ encrypted });
754
+ return 0;
755
+ }
756
+ });
757
+ }
758
+ /**
759
+ * The lifecycle shared by every plan ({@link #chunkedWrite},
760
+ * {@link #replacingWrite}): a transport is built from the write context,
761
+ * stamped with the write epoch and, over a prior document, given the
762
+ * pre-read as its baseline; `write` runs on it and reports the chunk count
763
+ * the stored document covers; a failure is compensated and rethrown by
764
+ * {@link #chunkedWriteFailed}; a prior document that was chunked has the
765
+ * chunks past that count reaped ({@link #reapChunks}); and the result
766
+ * carries the validator the server acked the last document write with. The
767
+ * EDV core owns a chunked write and swallows the responses, which is why the
768
+ * transport is what reports the document write it made.
769
+ *
770
+ * @param options {object}
771
+ * @param options.id {string} the document id
772
+ * @param options.meta {Record<string, unknown>} the document meta sealed
773
+ * in the write, for the plaintext content type
774
+ * @param options.prior {PriorDocument | null} the stored document the
775
+ * write replaces, or `null` for a create
776
+ * @param options.write {(transport: WasTransport) => Promise<number>} the
777
+ * write itself, resolving the chunk count the stored document covers
778
+ * @returns {ChunkedWrite}
779
+ */
780
+ #writePlan({ id, meta, prior, write }) {
635
781
  return {
636
782
  chunked: true,
637
783
  id,
638
784
  resourceContentType: meta.contentType,
639
- // What the scheme-agnostic write path appends when it refuses this plan
640
- // for a write by id over an existing document: only this codec knows
641
- // why the payload needs several requests, and which low-level API
642
- // writes one directly.
643
- guidance: 'This payload is too large for a single encrypted document, or is a ' +
644
- 'stream, so it is stored as a document plus chunk resources. Drive ' +
645
- 'the write yourself with `EdvClientCore.update({ doc, stream, ' +
646
- 'transport })` over a `WasTransport`.',
647
785
  execute: async (context) => {
648
- // The EDV core owns the write and swallows the responses, so the
649
- // transport reports the document write it made: whether one landed at
650
- // all (the cleanup decision below) and the validator the server acked
651
- // it with.
652
- const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }));
786
+ const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }), prior === null ? undefined : { id, etag: prior.etag });
787
+ let keep;
653
788
  try {
654
- await this.#edv.insert({
655
- doc: { id, content: {}, meta },
656
- stream,
657
- ...(this.#chunkSize !== undefined && {
658
- chunkSize: this.#chunkSize
659
- }),
660
- ...this.#sealParams({
661
- resource: id,
662
- hmac: this.#writeBlindingKey()
663
- }),
664
- transport
665
- });
789
+ keep = await write(transport);
666
790
  }
667
791
  catch (err) {
668
- throw await this.#chunkedWriteFailed({ err, id, transport });
792
+ throw await this.#chunkedWriteFailed({
793
+ err,
794
+ id,
795
+ transport,
796
+ replace: prior !== null
797
+ });
798
+ }
799
+ if (prior?.chunked) {
800
+ await this.#reapChunks({ transport, id, keep });
669
801
  }
670
802
  const etag = transport.lastDocumentWrite?.etag;
671
803
  return { id, ...(etag !== undefined && { etag }) };
672
804
  }
673
805
  };
674
806
  }
807
+ /**
808
+ * Reaps the chunks a replaced document left behind: lists the chunks stored
809
+ * under the document and deletes every one at or past `keep`, the chunk
810
+ * count the new document covers (`0` for an inline document). The listing,
811
+ * not the prior envelope's sealed count, says what is stored, so a pending
812
+ * stub's chunks (whose count was never sealed) and a longer stale set are
813
+ * reaped the same way. Each delete is pinned to the chunk `etag` the listing
814
+ * reported, where it reported one, so a chunk a later writer has since
815
+ * stored at that index is left to it; such a 412, and a chunk already gone
816
+ * (404), are not failures. The deletes run {@link REAP_CONCURRENCY} at a
817
+ * time. A listing that finds no resource (404) means another client deleted
818
+ * it since the write landed, and its chunks with it: nothing is left to
819
+ * reap.
820
+ *
821
+ * Runs only after the new document is stored, and the new document's sealed
822
+ * state already makes these chunks unreachable, so a failure here loses no
823
+ * data: it is reported as {@link EncryptionError} naming what is still
824
+ * stored. A later write at the id reaps again only if the stored document
825
+ * is still chunked (a pending stub, say); under an inline document the
826
+ * stale chunks stay until the resource is deleted, which takes them.
827
+ *
828
+ * @param options {object}
829
+ * @param options.transport {WasTransport} the transport the write ran on
830
+ * @param options.id {string} the document id
831
+ * @param options.keep {number} the chunk count the stored document covers
832
+ * @returns {Promise<void>}
833
+ */
834
+ async #reapChunks({ transport, id, keep }) {
835
+ let stale;
836
+ try {
837
+ stale = (await transport.listChunks({ id })).filter(chunk => chunk.index >= keep);
838
+ }
839
+ catch (err) {
840
+ if (err instanceof NotFoundError) {
841
+ return;
842
+ }
843
+ throw this.#reapFailed(id, err);
844
+ }
845
+ const reapOne = async (chunk) => {
846
+ try {
847
+ await transport.deleteChunk({
848
+ id,
849
+ chunkIndex: chunk.index,
850
+ ...(chunk.etag !== undefined && { ifMatch: chunk.etag })
851
+ });
852
+ }
853
+ catch (err) {
854
+ if (err instanceof NotFoundError ||
855
+ err instanceof PreconditionFailedError) {
856
+ return;
857
+ }
858
+ throw err;
859
+ }
860
+ };
861
+ // A fixed number of workers each take the next stale chunk until none is
862
+ // left, so at most `REAP_CONCURRENCY` deletes are in flight.
863
+ let next = 0;
864
+ const worker = async () => {
865
+ while (next < stale.length) {
866
+ await reapOne(stale[next++]);
867
+ }
868
+ };
869
+ try {
870
+ await Promise.all(Array.from({ length: Math.min(REAP_CONCURRENCY, stale.length) }, worker));
871
+ }
872
+ catch (err) {
873
+ throw this.#reapFailed(id, err);
874
+ }
875
+ }
876
+ /**
877
+ * The error a failed {@link #reapChunks} throws: the write landed, and what
878
+ * it did not reap is still stored.
879
+ *
880
+ * @param id {string} the document id
881
+ * @param cause {unknown} the failure
882
+ * @returns {EncryptionError}
883
+ */
884
+ #reapFailed(id, cause) {
885
+ return new EncryptionError(`The encrypted write of resource "${id}" is stored, but the chunk ` +
886
+ 'resources of the document it replaced could not all be deleted. ' +
887
+ 'They are unreachable through a read, which follows the chunk ' +
888
+ 'count sealed in the stored document, but stay stored and counted ' +
889
+ 'against quota; deleting the resource removes them.', { cause });
890
+ }
675
891
  /**
676
892
  * Compensates a failed chunked write and builds the error to rethrow. The
677
893
  * pending stub is deleted only when the transport reports it actually wrote
@@ -683,6 +899,11 @@ export class EdvCodec {
683
899
  * so its outcome only shapes the message. Deleting the document deletes the
684
900
  * chunks already stored under it.
685
901
  *
902
+ * A replace (`replace`) deletes nothing: the stub stands where the document
903
+ * it overwrote stood, and deleting it would turn a torn update into a
904
+ * missing resource. The message says the stub is there to be written over
905
+ * or deleted.
906
+ *
686
907
  * Three failures keep their own type, so a caller can dispatch on them as it
687
908
  * does for a single-request write. A 412 on the first document write (the
688
909
  * id is taken) or on the final document update (another writer replaced the
@@ -697,10 +918,12 @@ export class EdvCodec {
697
918
  * @param options.err {unknown} the failure from the chunked write
698
919
  * @param options.id {string} the document id the write targeted
699
920
  * @param options.transport {WasTransport} the transport the write ran on
921
+ * @param options.replace {boolean} whether the write replaced a stored
922
+ * document (no stub delete) rather than created one
700
923
  * @returns {Promise<Error>} the error to throw, carrying `err` as its cause
701
924
  * when it wraps it
702
925
  */
703
- async #chunkedWriteFailed({ err, id, transport }) {
926
+ async #chunkedWriteFailed({ err, id, transport, replace }) {
704
927
  // The transport rethrows a 412 or 409 as the named error the EDV core
705
928
  // dispatches on, with the typed HTTP error as its cause.
706
929
  const httpError = err instanceof Error &&
@@ -721,17 +944,26 @@ export class EdvCodec {
721
944
  }
722
945
  let outcome = 'The incomplete document and any chunks stored under it were deleted.';
723
946
  let removed = true;
724
- try {
725
- await transport.deleteDocument({ id, ifMatch: written.etag });
726
- }
727
- catch (deleteErr) {
947
+ if (replace) {
728
948
  removed = false;
729
949
  outcome =
730
- deleteErr instanceof PreconditionFailedError
731
- ? 'Another writer has since replaced the document, so it was left ' +
732
- 'in place.'
733
- : 'The incomplete document could NOT be deleted and is still ' +
734
- 'stored; delete it before writing again.';
950
+ 'The document it replaced is gone, and the resource now holds this ' +
951
+ "write's pending stub, which no read can open: write the resource " +
952
+ 'again, or delete it.';
953
+ }
954
+ else {
955
+ try {
956
+ await transport.deleteDocument({ id, ifMatch: written.etag });
957
+ }
958
+ catch (deleteErr) {
959
+ removed = false;
960
+ outcome =
961
+ deleteErr instanceof PreconditionFailedError
962
+ ? 'Another writer has since replaced the document, so it was ' +
963
+ 'left in place.'
964
+ : 'The incomplete document could NOT be deleted and is still ' +
965
+ 'stored; delete it before writing again.';
966
+ }
735
967
  }
736
968
  if (httpError instanceof QuotaExceededError ||
737
969
  httpError instanceof ConflictError) {