@interop/was-client 0.98.0 → 0.99.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.
Files changed (103) hide show
  1. package/README.md +63 -42
  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 +23 -8
  11. package/dist/WasClient.d.ts.map +1 -1
  12. package/dist/WasClient.js +31 -15
  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 +348 -105
  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/cipher.d.ts +6 -3
  26. package/dist/edv/cipher.d.ts.map +1 -1
  27. package/dist/edv/cipher.js +6 -3
  28. package/dist/edv/cipher.js.map +1 -1
  29. package/dist/edv/core.d.ts +1 -1
  30. package/dist/edv/core.d.ts.map +1 -1
  31. package/dist/edv/core.js +1 -1
  32. package/dist/edv/core.js.map +1 -1
  33. package/dist/edv/docCipher.d.ts +4 -2
  34. package/dist/edv/docCipher.d.ts.map +1 -1
  35. package/dist/edv/docCipher.js +17 -12
  36. package/dist/edv/docCipher.js.map +1 -1
  37. package/dist/edv/epochCrypto.d.ts +12 -4
  38. package/dist/edv/epochCrypto.d.ts.map +1 -1
  39. package/dist/edv/epochCrypto.js +37 -10
  40. package/dist/edv/epochCrypto.js.map +1 -1
  41. package/dist/edv/epochKeys.d.ts +6 -2
  42. package/dist/edv/epochKeys.d.ts.map +1 -1
  43. package/dist/edv/epochKeys.js +25 -14
  44. package/dist/edv/epochKeys.js.map +1 -1
  45. package/dist/edv/hmacKey.d.ts +7 -1
  46. package/dist/edv/hmacKey.d.ts.map +1 -1
  47. package/dist/edv/hmacKey.js +9 -3
  48. package/dist/edv/hmacKey.js.map +1 -1
  49. package/dist/edv/index.d.ts +2 -1
  50. package/dist/edv/index.d.ts.map +1 -1
  51. package/dist/edv/index.js +1 -1
  52. package/dist/edv/index.js.map +1 -1
  53. package/dist/edv/recipients.d.ts.map +1 -1
  54. package/dist/edv/recipients.js +2 -0
  55. package/dist/edv/recipients.js.map +1 -1
  56. package/dist/edv/transportFactory.d.ts.map +1 -1
  57. package/dist/edv/transportFactory.js +3 -2
  58. package/dist/edv/transportFactory.js.map +1 -1
  59. package/dist/errors.d.ts +19 -10
  60. package/dist/errors.d.ts.map +1 -1
  61. package/dist/errors.js +22 -11
  62. package/dist/errors.js.map +1 -1
  63. package/dist/index.d.ts +1 -1
  64. package/dist/index.d.ts.map +1 -1
  65. package/dist/index.js +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/internal/codec.d.ts +51 -2
  68. package/dist/internal/codec.d.ts.map +1 -1
  69. package/dist/internal/codec.js +53 -21
  70. package/dist/internal/codec.js.map +1 -1
  71. package/dist/internal/indexSchema.d.ts +30 -0
  72. package/dist/internal/indexSchema.d.ts.map +1 -1
  73. package/dist/internal/indexSchema.js +40 -0
  74. package/dist/internal/indexSchema.js.map +1 -1
  75. package/dist/internal/memo.d.ts +13 -4
  76. package/dist/internal/memo.d.ts.map +1 -1
  77. package/dist/internal/memo.js +18 -7
  78. package/dist/internal/memo.js.map +1 -1
  79. package/dist/internal/meta.d.ts +9 -3
  80. package/dist/internal/meta.d.ts.map +1 -1
  81. package/dist/internal/meta.js +13 -7
  82. package/dist/internal/meta.js.map +1 -1
  83. package/dist/internal/pagination.d.ts +30 -15
  84. package/dist/internal/pagination.d.ts.map +1 -1
  85. package/dist/internal/pagination.js +31 -17
  86. package/dist/internal/pagination.js.map +1 -1
  87. package/dist/internal/paths.d.ts +11 -0
  88. package/dist/internal/paths.d.ts.map +1 -1
  89. package/dist/internal/paths.js +13 -0
  90. package/dist/internal/paths.js.map +1 -1
  91. package/dist/internal/request.d.ts +9 -0
  92. package/dist/internal/request.d.ts.map +1 -1
  93. package/dist/internal/request.js +31 -3
  94. package/dist/internal/request.js.map +1 -1
  95. package/dist/internal/write.d.ts +10 -10
  96. package/dist/internal/write.d.ts.map +1 -1
  97. package/dist/internal/write.js +23 -30
  98. package/dist/internal/write.js.map +1 -1
  99. package/dist/sync/index.d.ts +4 -2
  100. package/dist/sync/index.d.ts.map +1 -1
  101. package/dist/sync/index.js +4 -2
  102. package/dist/sync/index.js.map +1 -1
  103. package/package.json +7 -7
@@ -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
@@ -75,6 +77,7 @@ import { ConflictError, EncryptionError, IntegrityError, KeyUnwrapError, NotFoun
75
77
  import { blobBytes, bytesToBlob, bytesToStream, streamToBlob } from '../internal/blob.js';
76
78
  import { readEtag, writeHeaders } from '../internal/conditional.js';
77
79
  import { isEncryptedEnvelope } from '../sync/envelope.js';
80
+ import { isIntegrityError, isKeyUnwrapError } from '../sync/predicates.js';
78
81
  import { epochWriteStandIn, resolveEpochKeys } from './epochKeys.js';
79
82
  import { didKeyResolver } from './epochCrypto.js';
80
83
  import { currentEpochOf, hasKeyEpochs } from './epochRoster.js';
@@ -102,6 +105,13 @@ export const DEFAULT_MAX_BLOB_BYTES = 512 * 1024;
102
105
  * decrypted document self-describing alongside `'utf-8'` and `'base64'`.
103
106
  */
104
107
  const CHUNKED_ENCODING = 'chunked';
108
+ /**
109
+ * How many stale chunks a reap deletes at once. Each delete is one signed
110
+ * request that is independent of the others (pinned and 404-tolerant, in no
111
+ * required order), so a shrunk document's leftovers go a few at a time rather
112
+ * than one awaited round trip per chunk.
113
+ */
114
+ const REAP_CONCURRENCY = 8;
105
115
  /**
106
116
  * A shared strict UTF-8 decoder used to test whether a non-JSON payload is
107
117
  * valid UTF-8 (so it can be stored legibly as text rather than base64).
@@ -149,35 +159,43 @@ function wasParam({ version, resource, collection, epoch }) {
149
159
  * was-client need not import the class at all.
150
160
  *
151
161
  * Also true for a {@link KeyUnwrapError} raised by a candidate itself -- a lazy
152
- * epoch key whose recipient entry turned out corrupt when its first decrypt
162
+ * epoch key whose recipient entry turned out malformed when its first decrypt
153
163
  * forced the unwrap (see `lazyEpochKey`): that says nothing about the stored
154
164
  * envelope, so the loop must move on to the next candidate rather than report
155
- * tampering.
165
+ * tampering. Matched by name as well (the shared `isKeyUnwrapError`), since
166
+ * the read keys may be injected from another resolved copy of this package.
167
+ * Not true for the {@link IntegrityError} a lazy key raises when its
168
+ * kid-matched entry fails the key-wrap integrity check: that is a tamper
169
+ * signal in its own right and the loop surfaces it.
156
170
  *
157
171
  * @param err {unknown}
158
172
  * @returns {boolean}
159
173
  */
160
174
  function isKeyMiss(err) {
161
- return (err instanceof KeyUnwrapError ||
175
+ return (isKeyUnwrapError(err) ||
162
176
  (err instanceof Error && err.name === 'KeyMissError'));
163
177
  }
164
178
  /**
165
- * Whether a decrypt error means the key DID select a recipient and unwrap the
166
- * content key, but the content's AEAD tag did not verify: the stored
167
- * ciphertext is corrupt or has been tampered with. `@interop/minimal-cipher`
168
- * raises its `IntegrityError` for that (the runtime's raw error as `cause`),
169
- * matched by `err.name` for the same reason {@link isKeyMiss} matches by name.
170
- * Any other error is neither a key miss nor an integrity failure (a malformed
171
- * envelope, a bug) and is not this library's to relabel.
172
- *
173
- * This library's own {@link IntegrityError} carries the same name, so a
174
- * caller that may see both excludes `WasError` first.
179
+ * Whether a decrypt error is the cipher's integrity failure: the candidate
180
+ * key DID select a recipient and unwrap the content key, but the content's
181
+ * AEAD tag did not verify, so the stored ciphertext is corrupt or has been
182
+ * tampered with. `@interop/minimal-cipher` raises its `IntegrityError` for
183
+ * that, matched by name (the shared `isIntegrityError`) for the same reason
184
+ * {@link isKeyMiss} matches by name. This library's own {@link IntegrityError}
185
+ * carries the same name and can reach the same catch from a candidate key (a
186
+ * lazy epoch key whose kid-matched entry failed its key-wrap check), possibly
187
+ * from another resolved copy, so `instanceof` cannot tell the two apart. The
188
+ * cipher documents that its `IntegrityError` always carries the backend's raw
189
+ * error as `cause`; a key-wrap failure carries none, and is already typed and
190
+ * described, so it passes through untouched. Any other error is neither a key
191
+ * miss nor an integrity failure (a malformed envelope, a bug) and is not this
192
+ * library's to relabel.
175
193
  *
176
194
  * @param err {unknown}
177
195
  * @returns {boolean}
178
196
  */
179
- function isIntegrityFailure(err) {
180
- return err instanceof Error && err.name === 'IntegrityError';
197
+ function isCipherIntegrityFailure(err) {
198
+ return (err instanceof Error && isIntegrityError(err) && err.cause !== undefined);
181
199
  }
182
200
  /**
183
201
  * Extracts the JWE recipient key ids (`kid`) a stored envelope names (an epoch
@@ -452,6 +470,41 @@ export class EdvCodec {
452
470
  ? undefined
453
471
  : (await this.#edv.generateId()));
454
472
  const parts = await this.#toDocument(data, contentType, docId);
473
+ // When the write path pre-read a current envelope, this is an update:
474
+ // `sequence` advances from the prior value (`encrypt({ update: true })`
475
+ // increments it), the write is pinned to the server's current ETag with
476
+ // `If-Match`, and the write learns whether the prior document's bytes
477
+ // live in chunk resources, so they can be reaped once this write lands.
478
+ // Only the sealed `meta.encoding` says so for certain, and reading it
479
+ // means opening the envelope (decrypting it, with its binding verified
480
+ // against this id, as a read would): a key unwrap per update. The
481
+ // cleartext `stream` member a chunked write leaves on the envelope is the
482
+ // cue to pay for that; an envelope without one is taken as inline and is
483
+ // not opened. The cue is server-controlled, but nothing rests on it: a
484
+ // server that strips it can only leave the prior chunks stranded (stored
485
+ // and counted against quota, unreachable, removed with the resource), and
486
+ // one that adds it costs this write a decrypt that finds an inline
487
+ // encoding. With no prior envelope this is a fresh insert (`sequence: 0`)
488
+ // guarded by `If-None-Match: *` (create-if-absent), so a concurrent first
489
+ // writer cannot be clobbered.
490
+ let prior = null;
491
+ if (current) {
492
+ const read = await readJsonData(current);
493
+ this.#assertEnvelope(read, 'update');
494
+ const chunked = read.stream !== undefined &&
495
+ (await this.#openEnvelope({ doc: read, expectedId: id })).meta
496
+ ?.encoding === CHUNKED_ENCODING;
497
+ // The caller's own compare-and-swap baseline wins when they named one:
498
+ // the write path has already checked it against the pre-read, so the
499
+ // two agree by the time this runs.
500
+ const etag = precondition?.ifMatch ?? readEtag(current);
501
+ prior = {
502
+ sequence: read.sequence,
503
+ ...(read.indexed !== undefined && { indexed: read.indexed }),
504
+ ...(etag !== undefined && { etag }),
505
+ chunked
506
+ };
507
+ }
455
508
  if (parts.kind === 'chunked') {
456
509
  if (docId === undefined) {
457
510
  // A stream is chunked whatever its size, so only a sized payload can
@@ -473,21 +526,11 @@ export class EdvCodec {
473
526
  return this.#chunkedWrite({
474
527
  id: docId,
475
528
  stream: parts.stream,
476
- meta: parts.meta
529
+ meta: parts.meta,
530
+ prior
477
531
  });
478
532
  }
479
533
  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
534
  const { documentCipher } = this.#edv;
492
535
  const encrypted = await documentCipher.encrypt({
493
536
  doc: {
@@ -497,14 +540,14 @@ export class EdvCodec {
497
540
  // the plaintext content type and the inline-encoding discriminator,
498
541
  // taken fresh from this write (the new type/encoding wins on update).
499
542
  meta,
500
- ...(priorDoc && { sequence: priorDoc.sequence }),
543
+ ...(prior && { sequence: prior.sequence }),
501
544
  // Carry the prior envelope's blinded `indexed` entries into the
502
545
  // update. With no blinding key edv-client stores `doc.indexed`
503
546
  // verbatim; omitting it would replace the entries with `[]` and
504
547
  // silently drop the record from `find()`. With a blinding key the
505
548
  // entries are recomputed from the schema and this copy is the prior
506
549
  // state that recomputation updates.
507
- ...(priorDoc?.indexed !== undefined && { indexed: priorDoc.indexed })
550
+ ...(prior?.indexed !== undefined && { indexed: prior.indexed })
508
551
  },
509
552
  // Bind an AEAD-authenticated `was` parameter into the JWE protected
510
553
  // header: the scheme version, the resource id when known at encrypt time
@@ -521,7 +564,7 @@ export class EdvCodec {
521
564
  ...(docId !== undefined && { resource: docId }),
522
565
  hmac: this.#writeBlindingKey()
523
566
  }),
524
- update: priorDoc !== null
567
+ update: prior !== null
525
568
  });
526
569
  if (docId === undefined) {
527
570
  // Encrypt-then-stamp: the id lives in the cleartext envelope, outside the
@@ -530,6 +573,12 @@ export class EdvCodec {
530
573
  docId = await documentCipher.deriveId({ jwe: encrypted.jwe });
531
574
  encrypted.id = docId;
532
575
  }
576
+ if (prior?.chunked) {
577
+ // The envelope replaces a document whose bytes live in chunk resources,
578
+ // which the inline document leaves behind: a plan writes it and reaps
579
+ // them, since one request cannot do both.
580
+ return this.#replacingWrite({ id: docId, encrypted, meta, prior });
581
+ }
533
582
  // Serialized on first read of `body` and kept for any later read, so the
534
583
  // wire bytes are built once per write and only when someone asks for them.
535
584
  let bytes;
@@ -564,9 +613,7 @@ export class EdvCodec {
564
613
  // (create-only-if-absent), and the server enforces it, so a masked-404
565
614
  // pre-read cannot silently overwrite an existing document.
566
615
  ...(precondition ??
567
- (priorDoc
568
- ? { ifMatch: readEtag(current ?? null) }
569
- : { ifNoneMatch: true })),
616
+ (prior ? { ifMatch: prior.etag } : { ifNoneMatch: true })),
570
617
  // Stamp the key epoch this write encrypted under (the `currentEpoch`), so
571
618
  // the server records it and a reader can pick the epoch key.
572
619
  epoch: this.#writeEpoch
@@ -579,9 +626,11 @@ export class EdvCodec {
579
626
  * @param context {CodecRequestContext}
580
627
  * @param [documentHeaders] {Record<string, string>} extra headers for
581
628
  * document writes (the `Key-Epoch` stamp the codec seam applies)
629
+ * @param [baseline] {StoredDocument} the stored document the transport's
630
+ * first update replaces, which it pins that update to
582
631
  * @returns {WasTransport}
583
632
  */
584
- #transportFor(context, documentHeaders) {
633
+ #transportFor(context, documentHeaders, baseline) {
585
634
  if (this.#transportFactory === undefined) {
586
635
  throw new NotSupportedError(`Collection "${this.#collectionId}" has no server behind it: this ` +
587
636
  'codec was built for a local replica and holds no route to address ' +
@@ -591,14 +640,19 @@ export class EdvCodec {
591
640
  'its `spaceId`, or by passing a `chunkSource` to the DocCipher ' +
592
641
  '`decrypt` in place of a request context.');
593
642
  }
594
- return this.#transportFactory({ context, documentHeaders });
643
+ return this.#transportFactory({
644
+ context,
645
+ documentHeaders,
646
+ ...(baseline !== undefined && { baseline })
647
+ });
595
648
  }
596
649
  /**
597
650
  * The plan for a binary payload over the single-document threshold, or for
598
651
  * 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.
652
+ * by `EdvClientCore.insert({ doc, stream, transport })` (or, over a stored
653
+ * document, `update`) on a transport built from the write context. The
654
+ * document id is minted before the plan is returned, so the caller can
655
+ * report it without waiting for the write.
602
656
  *
603
657
  * The `was` binding, the recipients and the write epoch are exactly the
604
658
  * single-document path's, and `additionalProtectedParams` carries the binding
@@ -609,69 +663,240 @@ export class EdvCodec {
609
663
  * payload, so that discriminator is what the read side routes on: a server
610
664
  * cannot mint it, and cannot suppress it to hide the chunks either.
611
665
  *
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.
666
+ * With no prior document the write is a create-if-absent: the first
667
+ * document write carries `If-None-Match: *`, so the plan serves `add()` and
668
+ * a write by id (`put()`) at an id that holds no document alike. A 412 there
669
+ * surfaces as the `PreconditionFailedError` the request path raised. Over a
670
+ * prior document the first write is pinned to the pre-read's `ETag`, so a
671
+ * 412 is the ordinary lost-update signal, and the new chunks overwrite the
672
+ * prior ones index by index. Once the write is stored, a prior document
673
+ * that was itself chunked has every chunk beyond the new count reaped
674
+ * ({@link #reapChunks}): the new document's sealed count already makes them
675
+ * unreachable, and the reaping frees them.
616
676
  *
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.
677
+ * The write is two-phase (the EDV core writes the document, then streams
678
+ * the chunks, then writes the document again), so a failure partway leaves
679
+ * a pending stub: a document whose sealed stream state is still
680
+ * `{ pending: true }`, undecryptable and listed. A create compensates: if
681
+ * the document was written and the write then failed, the plan best-effort
682
+ * deletes the stub before rethrowing. A replace cannot restore the document
683
+ * it overwrote, so the stub is left in place for the next write at the id
684
+ * to replace, or for a `delete()`. A stub that survives is recognized with
685
+ * {@link isPendingStub}. The EDV core cancels the caller's source stream on
686
+ * such a failure, so the source behind it is released.
626
687
  *
627
688
  * @param options {object}
628
689
  * @param options.id {string} the document id (minted, or the caller's)
629
690
  * @param options.stream {ReadableStream<Uint8Array>} the payload, as the
630
691
  * stream the EDV core re-chunks (never buffered whole by this codec)
631
692
  * @param options.meta {Record<string, unknown>} the document meta to seal
693
+ * @param options.prior {PriorDocument | null} the stored document this
694
+ * write replaces, or `null` for a create
632
695
  * @returns {ChunkedWrite}
633
696
  */
634
- #chunkedWrite({ id, stream, meta }) {
697
+ #chunkedWrite({ id, stream, meta, prior }) {
698
+ return this.#writePlan({
699
+ id,
700
+ meta,
701
+ prior,
702
+ write: async (transport) => {
703
+ const write = {
704
+ stream,
705
+ ...(this.#chunkSize !== undefined && {
706
+ chunkSize: this.#chunkSize
707
+ }),
708
+ ...this.#sealParams({
709
+ resource: id,
710
+ hmac: this.#writeBlindingKey()
711
+ }),
712
+ transport
713
+ };
714
+ const stored = prior === null
715
+ ? await this.#edv.insert({
716
+ doc: { id, content: {}, meta },
717
+ ...write
718
+ })
719
+ : await this.#edv.update({
720
+ doc: {
721
+ id,
722
+ content: {},
723
+ meta,
724
+ sequence: prior.sequence,
725
+ ...(prior.indexed !== undefined && {
726
+ indexed: prior.indexed
727
+ })
728
+ },
729
+ ...write
730
+ });
731
+ // The EDV core reports the chunk count it sealed into the final
732
+ // document write; every stored chunk at or past it is the prior
733
+ // document's.
734
+ return stored.stream.chunks;
735
+ }
736
+ });
737
+ }
738
+ /**
739
+ * The plan for an inline document that replaces a chunked one: the sealed
740
+ * envelope is written as the single-document path would write it, pinned to
741
+ * the pre-read's `ETag` and stamped with the write epoch, and the prior
742
+ * document's chunks are then reaped ({@link #reapChunks}). One request
743
+ * cannot do both, which is why the write is a plan rather than an
744
+ * `EncodedWrite`. The envelope's sealed `meta.encoding` is inline, so a
745
+ * reader of the new document never looks for chunks, and the reaping only
746
+ * frees what is already unreachable.
747
+ *
748
+ * @param options {object}
749
+ * @param options.id {string} the document id
750
+ * @param options.encrypted {IEncryptedDocument} the sealed envelope
751
+ * @param options.meta {Record<string, unknown>} the document meta sealed
752
+ * in it, for the plaintext content type
753
+ * @param options.prior {PriorDocument} the chunked document it replaces
754
+ * @returns {ChunkedWrite}
755
+ */
756
+ #replacingWrite({ id, encrypted, meta, prior }) {
757
+ return this.#writePlan({
758
+ id,
759
+ meta,
760
+ prior,
761
+ write: async (transport) => {
762
+ await transport.update({ encrypted });
763
+ return 0;
764
+ }
765
+ });
766
+ }
767
+ /**
768
+ * The lifecycle shared by every plan ({@link #chunkedWrite},
769
+ * {@link #replacingWrite}): a transport is built from the write context,
770
+ * stamped with the write epoch and, over a prior document, given the
771
+ * pre-read as its baseline; `write` runs on it and reports the chunk count
772
+ * the stored document covers; a failure is compensated and rethrown by
773
+ * {@link #chunkedWriteFailed}; a prior document that was chunked has the
774
+ * chunks past that count reaped ({@link #reapChunks}); and the result
775
+ * carries the validator the server acked the last document write with. The
776
+ * EDV core owns a chunked write and swallows the responses, which is why the
777
+ * transport is what reports the document write it made.
778
+ *
779
+ * @param options {object}
780
+ * @param options.id {string} the document id
781
+ * @param options.meta {Record<string, unknown>} the document meta sealed
782
+ * in the write, for the plaintext content type
783
+ * @param options.prior {PriorDocument | null} the stored document the
784
+ * write replaces, or `null` for a create
785
+ * @param options.write {(transport: WasTransport) => Promise<number>} the
786
+ * write itself, resolving the chunk count the stored document covers
787
+ * @returns {ChunkedWrite}
788
+ */
789
+ #writePlan({ id, meta, prior, write }) {
635
790
  return {
636
791
  chunked: true,
637
792
  id,
638
793
  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
794
  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 }));
795
+ const transport = this.#transportFor(context, writeHeaders({ epoch: this.#writeEpoch }), prior === null ? undefined : { id, etag: prior.etag });
796
+ let keep;
653
797
  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
- });
798
+ keep = await write(transport);
666
799
  }
667
800
  catch (err) {
668
- throw await this.#chunkedWriteFailed({ err, id, transport });
801
+ throw await this.#chunkedWriteFailed({
802
+ err,
803
+ id,
804
+ transport,
805
+ replace: prior !== null
806
+ });
807
+ }
808
+ if (prior?.chunked) {
809
+ await this.#reapChunks({ transport, id, keep });
669
810
  }
670
811
  const etag = transport.lastDocumentWrite?.etag;
671
812
  return { id, ...(etag !== undefined && { etag }) };
672
813
  }
673
814
  };
674
815
  }
816
+ /**
817
+ * Reaps the chunks a replaced document left behind: lists the chunks stored
818
+ * under the document and deletes every one at or past `keep`, the chunk
819
+ * count the new document covers (`0` for an inline document). The listing,
820
+ * not the prior envelope's sealed count, says what is stored, so a pending
821
+ * stub's chunks (whose count was never sealed) and a longer stale set are
822
+ * reaped the same way. Each delete is pinned to the chunk `etag` the listing
823
+ * reported, where it reported one, so a chunk a later writer has since
824
+ * stored at that index is left to it; such a 412, and a chunk already gone
825
+ * (404), are not failures. The deletes run {@link REAP_CONCURRENCY} at a
826
+ * time. A listing that finds no resource (404) means another client deleted
827
+ * it since the write landed, and its chunks with it: nothing is left to
828
+ * reap.
829
+ *
830
+ * Runs only after the new document is stored, and the new document's sealed
831
+ * state already makes these chunks unreachable, so a failure here loses no
832
+ * data: it is reported as {@link EncryptionError} naming what is still
833
+ * stored. A later write at the id reaps again only if the stored document
834
+ * is still chunked (a pending stub, say); under an inline document the
835
+ * stale chunks stay until the resource is deleted, which takes them.
836
+ *
837
+ * @param options {object}
838
+ * @param options.transport {WasTransport} the transport the write ran on
839
+ * @param options.id {string} the document id
840
+ * @param options.keep {number} the chunk count the stored document covers
841
+ * @returns {Promise<void>}
842
+ */
843
+ async #reapChunks({ transport, id, keep }) {
844
+ let stale;
845
+ try {
846
+ stale = (await transport.listChunks({ id })).filter(chunk => chunk.index >= keep);
847
+ }
848
+ catch (err) {
849
+ if (err instanceof NotFoundError) {
850
+ return;
851
+ }
852
+ throw this.#reapFailed(id, err);
853
+ }
854
+ const reapOne = async (chunk) => {
855
+ try {
856
+ await transport.deleteChunk({
857
+ id,
858
+ chunkIndex: chunk.index,
859
+ ...(chunk.etag !== undefined && { ifMatch: chunk.etag })
860
+ });
861
+ }
862
+ catch (err) {
863
+ if (err instanceof NotFoundError ||
864
+ err instanceof PreconditionFailedError) {
865
+ return;
866
+ }
867
+ throw err;
868
+ }
869
+ };
870
+ // A fixed number of workers each take the next stale chunk until none is
871
+ // left, so at most `REAP_CONCURRENCY` deletes are in flight.
872
+ let next = 0;
873
+ const worker = async () => {
874
+ while (next < stale.length) {
875
+ await reapOne(stale[next++]);
876
+ }
877
+ };
878
+ try {
879
+ await Promise.all(Array.from({ length: Math.min(REAP_CONCURRENCY, stale.length) }, worker));
880
+ }
881
+ catch (err) {
882
+ throw this.#reapFailed(id, err);
883
+ }
884
+ }
885
+ /**
886
+ * The error a failed {@link #reapChunks} throws: the write landed, and what
887
+ * it did not reap is still stored.
888
+ *
889
+ * @param id {string} the document id
890
+ * @param cause {unknown} the failure
891
+ * @returns {EncryptionError}
892
+ */
893
+ #reapFailed(id, cause) {
894
+ return new EncryptionError(`The encrypted write of resource "${id}" is stored, but the chunk ` +
895
+ 'resources of the document it replaced could not all be deleted. ' +
896
+ 'They are unreachable through a read, which follows the chunk ' +
897
+ 'count sealed in the stored document, but stay stored and counted ' +
898
+ 'against quota; deleting the resource removes them.', { cause });
899
+ }
675
900
  /**
676
901
  * Compensates a failed chunked write and builds the error to rethrow. The
677
902
  * pending stub is deleted only when the transport reports it actually wrote
@@ -683,6 +908,11 @@ export class EdvCodec {
683
908
  * so its outcome only shapes the message. Deleting the document deletes the
684
909
  * chunks already stored under it.
685
910
  *
911
+ * A replace (`replace`) deletes nothing: the stub stands where the document
912
+ * it overwrote stood, and deleting it would turn a torn update into a
913
+ * missing resource. The message says the stub is there to be written over
914
+ * or deleted.
915
+ *
686
916
  * Three failures keep their own type, so a caller can dispatch on them as it
687
917
  * does for a single-request write. A 412 on the first document write (the
688
918
  * id is taken) or on the final document update (another writer replaced the
@@ -697,10 +927,12 @@ export class EdvCodec {
697
927
  * @param options.err {unknown} the failure from the chunked write
698
928
  * @param options.id {string} the document id the write targeted
699
929
  * @param options.transport {WasTransport} the transport the write ran on
930
+ * @param options.replace {boolean} whether the write replaced a stored
931
+ * document (no stub delete) rather than created one
700
932
  * @returns {Promise<Error>} the error to throw, carrying `err` as its cause
701
933
  * when it wraps it
702
934
  */
703
- async #chunkedWriteFailed({ err, id, transport }) {
935
+ async #chunkedWriteFailed({ err, id, transport, replace }) {
704
936
  // The transport rethrows a 412 or 409 as the named error the EDV core
705
937
  // dispatches on, with the typed HTTP error as its cause.
706
938
  const httpError = err instanceof Error &&
@@ -721,17 +953,26 @@ export class EdvCodec {
721
953
  }
722
954
  let outcome = 'The incomplete document and any chunks stored under it were deleted.';
723
955
  let removed = true;
724
- try {
725
- await transport.deleteDocument({ id, ifMatch: written.etag });
726
- }
727
- catch (deleteErr) {
956
+ if (replace) {
728
957
  removed = false;
729
958
  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.';
959
+ 'The document it replaced is gone, and the resource now holds this ' +
960
+ "write's pending stub, which no read can open: write the resource " +
961
+ 'again, or delete it.';
962
+ }
963
+ else {
964
+ try {
965
+ await transport.deleteDocument({ id, ifMatch: written.etag });
966
+ }
967
+ catch (deleteErr) {
968
+ removed = false;
969
+ outcome =
970
+ deleteErr instanceof PreconditionFailedError
971
+ ? 'Another writer has since replaced the document, so it was ' +
972
+ 'left in place.'
973
+ : 'The incomplete document could NOT be deleted and is still ' +
974
+ 'stored; delete it before writing again.';
975
+ }
735
976
  }
736
977
  if (httpError instanceof QuotaExceededError ||
737
978
  httpError instanceof ConflictError) {
@@ -1085,8 +1326,9 @@ export class EdvCodec {
1085
1326
  * all raises {@link UnknownEpochError} -- the signal that the cached
1086
1327
  * Collection Metadata object may be stale (an epoch rotation emits no
1087
1328
  * change-feed entry) and the codec must be rebuilt from a re-read
1088
- * descriptor. A candidate whose entry then fails to unwrap also surfaces
1089
- * {@link KeyUnwrapError}.
1329
+ * descriptor. A candidate whose entry is malformed also surfaces
1330
+ * {@link KeyUnwrapError}; one whose entry names this reader but fails its
1331
+ * key-wrap integrity check surfaces the {@link IntegrityError} it raised.
1090
1332
  *
1091
1333
  * Also returns the `id` of the key that actually decrypted the envelope (its
1092
1334
  * JWE recipient `kid`), so {@link _verifyBinding} can check a `was.epoch`
@@ -1139,8 +1381,9 @@ export class EdvCodec {
1139
1381
  // unwrap its content-encryption key; try the next candidate.
1140
1382
  continue;
1141
1383
  }
1142
- if (!isIntegrityFailure(err)) {
1143
- // Neither a key miss nor an AEAD failure: rethrow as is.
1384
+ if (!isCipherIntegrityFailure(err)) {
1385
+ // Neither a key miss nor an AEAD failure (this library's own tamper
1386
+ // signal from a lazy epoch key included): rethrow as is.
1144
1387
  throw err;
1145
1388
  }
1146
1389
  // Surface the AEAD failure immediately rather than masking it as a
@@ -1610,11 +1853,11 @@ function sealedContentType(meta) {
1610
1853
  }
1611
1854
  /**
1612
1855
  * The chunked decrypt stream of resource `id`, with the cipher's integrity
1613
- * failure (see {@link isIntegrityFailure}) or key miss (see {@link isKeyMiss})
1614
- * replaced by an {@link EncryptionError} that carries the original as `cause`.
1615
- * Every other error passes through as it is, this library's own typed errors
1616
- * included (a chunk source or lazy epoch key may raise one that the two
1617
- * predicates would match by name). Cancellation is forwarded.
1856
+ * failure (see {@link isCipherIntegrityFailure}) or key miss (see
1857
+ * {@link isKeyMiss}) replaced by an {@link EncryptionError} that carries the
1858
+ * original as `cause`. Every other error passes through as it is, this
1859
+ * library's own typed errors included (a chunk source or lazy epoch key may
1860
+ * raise one that `isKeyMiss` would match by name). Cancellation is forwarded.
1618
1861
  *
1619
1862
  * @param options {object}
1620
1863
  * @param options.id {string} the resource id, for the error message
@@ -1634,7 +1877,7 @@ function chunkDecryptErrors({ id, stream }) {
1634
1877
  // Already one of this library's errors: not the cipher's.
1635
1878
  throw err;
1636
1879
  }
1637
- if (!isIntegrityFailure(err) && !isKeyMiss(err)) {
1880
+ if (!isCipherIntegrityFailure(err) && !isKeyMiss(err)) {
1638
1881
  // Neither tampering nor a key the reader lacks: rethrow as is.
1639
1882
  throw err;
1640
1883
  }