@interop/was-client 0.97.1 → 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.
- package/README.md +59 -41
- package/dist/Collection.d.ts +43 -15
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +127 -54
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +26 -11
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +26 -11
- package/dist/Resource.js.map +1 -1
- package/dist/WasClient.d.ts +20 -4
- package/dist/WasClient.d.ts.map +1 -1
- package/dist/WasClient.js +27 -6
- package/dist/WasClient.js.map +1 -1
- package/dist/codec.d.ts +6 -14
- package/dist/codec.d.ts.map +1 -1
- package/dist/codec.js.map +1 -1
- package/dist/edv/EdvCodec.d.ts +2 -1
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +312 -80
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/WasTransport.d.ts +76 -12
- package/dist/edv/WasTransport.d.ts.map +1 -1
- package/dist/edv/WasTransport.js +76 -11
- package/dist/edv/WasTransport.js.map +1 -1
- package/dist/edv/docCipher.d.ts.map +1 -1
- package/dist/edv/docCipher.js +11 -9
- package/dist/edv/docCipher.js.map +1 -1
- package/dist/edv/index.d.ts +1 -0
- package/dist/edv/index.d.ts.map +1 -1
- package/dist/edv/index.js.map +1 -1
- package/dist/edv/transportFactory.d.ts.map +1 -1
- package/dist/edv/transportFactory.js +3 -2
- package/dist/edv/transportFactory.js.map +1 -1
- package/dist/errors.d.ts +13 -6
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +16 -7
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/codec.d.ts +51 -2
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +53 -21
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/indexSchema.d.ts +30 -0
- package/dist/internal/indexSchema.d.ts.map +1 -1
- package/dist/internal/indexSchema.js +40 -0
- package/dist/internal/indexSchema.js.map +1 -1
- package/dist/internal/meta.d.ts +9 -3
- package/dist/internal/meta.d.ts.map +1 -1
- package/dist/internal/meta.js +13 -7
- package/dist/internal/meta.js.map +1 -1
- package/dist/internal/paths.d.ts +11 -0
- package/dist/internal/paths.d.ts.map +1 -1
- package/dist/internal/paths.js +13 -0
- package/dist/internal/paths.js.map +1 -1
- package/dist/internal/request.d.ts +9 -0
- package/dist/internal/request.d.ts.map +1 -1
- package/dist/internal/request.js +31 -3
- package/dist/internal/request.js.map +1 -1
- package/dist/internal/write.d.ts +10 -10
- package/dist/internal/write.d.ts.map +1 -1
- package/dist/internal/write.js +23 -30
- package/dist/internal/write.js.map +1 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -5
package/dist/edv/EdvCodec.js
CHANGED
|
@@ -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()`
|
|
42
|
-
* the chunked-stream path instead: `encode` returns a
|
|
43
|
-
* write path executes, storing one document plus its
|
|
44
|
-
* `WasTransport` supplied by the injected
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
...(
|
|
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
|
-
...(
|
|
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:
|
|
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
|
-
(
|
|
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({
|
|
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
|
|
600
|
-
* built from the write context. The
|
|
601
|
-
* is returned, so the caller can
|
|
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
|
-
*
|
|
613
|
-
* `If-None-Match: *`, so the plan
|
|
614
|
-
* id that holds no document
|
|
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 (
|
|
618
|
-
*
|
|
619
|
-
* document whose sealed stream state is still
|
|
620
|
-
* undecryptable and listed.
|
|
621
|
-
* was written and the write then failed,
|
|
622
|
-
* before rethrowing. A
|
|
623
|
-
*
|
|
624
|
-
*
|
|
625
|
-
*
|
|
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
|
-
|
|
649
|
-
|
|
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
|
|
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({
|
|
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
|
-
|
|
725
|
-
await transport.deleteDocument({ id, ifMatch: written.etag });
|
|
726
|
-
}
|
|
727
|
-
catch (deleteErr) {
|
|
947
|
+
if (replace) {
|
|
728
948
|
removed = false;
|
|
729
949
|
outcome =
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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) {
|