@interop/was-client 0.59.1 → 0.61.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 +66 -45
- package/dist/Collection.d.ts +140 -96
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +648 -194
- package/dist/Collection.js.map +1 -1
- package/dist/Resource.d.ts +1 -1
- package/dist/Resource.d.ts.map +1 -1
- package/dist/Resource.js +0 -3
- package/dist/Resource.js.map +1 -1
- package/dist/Space.d.ts +17 -16
- package/dist/Space.d.ts.map +1 -1
- package/dist/Space.js +41 -24
- package/dist/Space.js.map +1 -1
- package/dist/WasClient.d.ts.map +1 -1
- package/dist/WasClient.js +9 -3
- package/dist/WasClient.js.map +1 -1
- package/dist/codec.d.ts +3 -3
- package/dist/edv/EdvCodec.d.ts.map +1 -1
- package/dist/edv/EdvCodec.js +22 -46
- package/dist/edv/EdvCodec.js.map +1 -1
- package/dist/edv/descriptorStore.d.ts +16 -14
- package/dist/edv/descriptorStore.d.ts.map +1 -1
- package/dist/edv/descriptorStore.js +17 -14
- package/dist/edv/descriptorStore.js.map +1 -1
- package/dist/edv/didKeyRecipient.d.ts +6 -1
- package/dist/edv/didKeyRecipient.d.ts.map +1 -1
- package/dist/edv/didKeyRecipient.js +4 -0
- package/dist/edv/didKeyRecipient.js.map +1 -1
- package/dist/edv/epochKeys.d.ts +43 -3
- package/dist/edv/epochKeys.d.ts.map +1 -1
- package/dist/edv/epochKeys.js +90 -9
- package/dist/edv/epochKeys.js.map +1 -1
- package/dist/edv/epochRoster.d.ts +36 -26
- package/dist/edv/epochRoster.d.ts.map +1 -1
- package/dist/edv/epochRoster.js +61 -10
- package/dist/edv/epochRoster.js.map +1 -1
- package/dist/edv/hmacKey.d.ts +17 -5
- package/dist/edv/hmacKey.d.ts.map +1 -1
- package/dist/edv/hmacKey.js +19 -5
- package/dist/edv/hmacKey.js.map +1 -1
- package/dist/edv/index.d.ts +1 -1
- package/dist/edv/index.js +1 -1
- package/dist/edv/logGovernedDescriptorStore.d.ts +2 -2
- package/dist/edv/logGovernedDescriptorStore.d.ts.map +1 -1
- package/dist/edv/logGovernedDescriptorStore.js +10 -9
- package/dist/edv/logGovernedDescriptorStore.js.map +1 -1
- package/dist/edv/recipients.d.ts +1 -1
- package/dist/edv/recipients.d.ts.map +1 -1
- package/dist/edv/recipients.js +12 -4
- package/dist/edv/recipients.js.map +1 -1
- package/dist/edv/refresh.d.ts +6 -1
- package/dist/edv/refresh.d.ts.map +1 -1
- package/dist/edv/refresh.js +19 -2
- package/dist/edv/refresh.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +3 -3
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/internal/cas.d.ts.map +1 -1
- package/dist/internal/cas.js +7 -4
- package/dist/internal/cas.js.map +1 -1
- package/dist/internal/codec.d.ts +22 -1
- package/dist/internal/codec.d.ts.map +1 -1
- package/dist/internal/codec.js +117 -46
- package/dist/internal/codec.js.map +1 -1
- package/dist/internal/content.d.ts.map +1 -1
- package/dist/internal/content.js +12 -1
- package/dist/internal/content.js.map +1 -1
- package/dist/internal/describe.d.ts +98 -24
- package/dist/internal/describe.d.ts.map +1 -1
- package/dist/internal/describe.js +125 -18
- package/dist/internal/describe.js.map +1 -1
- package/dist/internal/meta.d.ts +32 -17
- package/dist/internal/meta.d.ts.map +1 -1
- package/dist/internal/meta.js +64 -35
- package/dist/internal/meta.js.map +1 -1
- package/dist/internal/paths.d.ts +33 -17
- package/dist/internal/paths.d.ts.map +1 -1
- package/dist/internal/paths.js +92 -40
- package/dist/internal/paths.js.map +1 -1
- package/dist/internal/reserved.d.ts +14 -0
- package/dist/internal/reserved.d.ts.map +1 -1
- package/dist/internal/reserved.js +23 -4
- package/dist/internal/reserved.js.map +1 -1
- package/dist/internal/write.js +30 -1
- package/dist/internal/write.js.map +1 -1
- package/dist/paths.d.ts +8 -5
- package/dist/paths.d.ts.map +1 -1
- package/dist/paths.js +8 -5
- package/dist/paths.js.map +1 -1
- package/dist/sync/port.d.ts.map +1 -1
- package/dist/sync/port.js +14 -1
- package/dist/sync/port.js.map +1 -1
- package/dist/sync/provisioning.d.ts +7 -7
- package/dist/sync/provisioning.d.ts.map +1 -1
- package/dist/sync/provisioning.js +27 -15
- package/dist/sync/provisioning.js.map +1 -1
- package/dist/types.d.ts +16 -9
- package/dist/types.d.ts.map +1 -1
- package/package.json +2 -2
package/dist/Collection.js
CHANGED
|
@@ -5,30 +5,82 @@
|
|
|
5
5
|
* A navigational handle to a Collection within a Space. Exposes its own
|
|
6
6
|
* lifecycle (`describe`/`configure`/`delete`) and contained-resource operations
|
|
7
7
|
* (`add`/`get`/`put`/`list`, plus `resource(id)` for delete-by-id).
|
|
8
|
+
*
|
|
9
|
+
* The Collection's own "about it" document is the Collection Metadata object at
|
|
10
|
+
* its `meta` sub-resource: one object under one validator, carrying the
|
|
11
|
+
* configuration members beside the user-writable `custom`. `describe` and
|
|
12
|
+
* `meta` are its two read projections (the second decodes `custom` through the
|
|
13
|
+
* codec), and `configure` / `replaceDescription` / `setMeta` / `setName` are
|
|
14
|
+
* full-replacement writes of it composed against a fresh read. The bare
|
|
15
|
+
* Collection URL is the container: it lists and adds Resources, and `delete()`
|
|
16
|
+
* removes the Collection.
|
|
8
17
|
*/
|
|
9
|
-
import { collectionPath,
|
|
18
|
+
import { collectionPath, collectionPolicy, collectionLinkset, collectionBackend, collectionQuota, collectionQuery, collectionMeta, collectionLog, resourcePath, toUrl } from './internal/paths.js';
|
|
10
19
|
import { assertNotReserved } from './internal/reserved.js';
|
|
11
|
-
import { ValidationError, WasServerError, httpStatus } from './errors.js';
|
|
20
|
+
import { NotFoundError, ValidationError, WasServerError, httpStatus } from './errors.js';
|
|
12
21
|
import { delegateGrantAt } from './internal/grant.js';
|
|
13
|
-
import { send, readData
|
|
14
|
-
import { collectionCodecHolder } from './internal/codec.js';
|
|
22
|
+
import { send, readData } from './internal/request.js';
|
|
23
|
+
import { codecForDescriptor, collectionCodecHolder, identityCodec } from './internal/codec.js';
|
|
15
24
|
import { collectionBackendFeatures } from './internal/features.js';
|
|
16
25
|
import { collectWalk, signedPageWalk, walkItems, walkPagesOrEmpty } from './internal/pagination.js';
|
|
17
|
-
import { collectionWritableFields,
|
|
26
|
+
import { ANNOTATION_MEMBERS, CONFIGURATION_MEMBERS, asCollectionMetadata, carriedForward, collectionWritableFields, isGovernedDescriptor, readCollectionMetadata, storedEncryption, unreadableDescriptionError } from './internal/describe.js';
|
|
18
27
|
import { readEtag, writeHeaders } from './internal/conditional.js';
|
|
19
28
|
import { compareAndSwap } from './internal/cas.js';
|
|
20
|
-
import { readMeta,
|
|
29
|
+
import { readMeta, patchCustom } from './internal/meta.js';
|
|
21
30
|
import { codecRequestContext, insertResource } from './internal/write.js';
|
|
22
31
|
import { readPolicy, writePolicy, deletePolicy, isPublicPolicy, setPublicPolicy } from './internal/policy.js';
|
|
23
32
|
import { ENCODER, LOG_CONTENT_TYPE, createdResource, dataOrNull, storedResponse } from './internal/content.js';
|
|
24
33
|
import { INDEX_SCHEMA_PROPERTY, attributeKey, normalizeAttribute, readIndexSchema } from './internal/indexSchema.js';
|
|
25
34
|
import { Resource } from './Resource.js';
|
|
35
|
+
/**
|
|
36
|
+
* Merges a caller's configuration members over the Collection's current ones,
|
|
37
|
+
* the mirror of the full-replacement `PUT`: a member the caller leaves out
|
|
38
|
+
* keeps its stored value instead of being cleared. `configure({ name })` on an
|
|
39
|
+
* EDV collection would otherwise wipe its `backend` or trip
|
|
40
|
+
* `encryption-immutable` by clearing the descriptor, and erase a stored
|
|
41
|
+
* `generator`.
|
|
42
|
+
*
|
|
43
|
+
* A log-governed `encryption` descriptor is the exception: it is the server's
|
|
44
|
+
* projection of the history log's head, and a write carrying it is refused
|
|
45
|
+
* (`encryption-history-log-governed`), so it is never merged forward. Omitting
|
|
46
|
+
* the member leaves the derived descriptor in place.
|
|
47
|
+
*
|
|
48
|
+
* @param desc {CollectionWritableFields} what the caller stated
|
|
49
|
+
* @param current {CollectionMetadata | null} the object this write is pinned
|
|
50
|
+
* to, or `null` when there is none
|
|
51
|
+
* @returns {CollectionWritableFields}
|
|
52
|
+
*/
|
|
53
|
+
function mergedConfiguration(desc, current) {
|
|
54
|
+
const declared = desc.encryption ?? current?.encryption;
|
|
55
|
+
return collectionWritableFields({
|
|
56
|
+
name: desc.name ?? current?.name,
|
|
57
|
+
backend: desc.backend ?? current?.backend,
|
|
58
|
+
...(declared !== undefined &&
|
|
59
|
+
!isGovernedDescriptor(declared) && { encryption: declared }),
|
|
60
|
+
// The app-attribution members merge forward on the same terms, so a
|
|
61
|
+
// `configure({ name })` does not erase a stored `generator`. They are
|
|
62
|
+
// deliberately NOT part of `configure`'s unreadable-object guard: unlike
|
|
63
|
+
// `backend` and `encryption`, they are freely re-writable attribution
|
|
64
|
+
// (dropping one is cosmetic, not a data-placement change or an
|
|
65
|
+
// `encryption-immutable` trip), and admitting them there would let a
|
|
66
|
+
// `configure({ generator })` sail past the guard and blindly drop the two
|
|
67
|
+
// members it exists to protect.
|
|
68
|
+
generator: desc.generator ?? current?.generator,
|
|
69
|
+
generatorOrigin: desc.generatorOrigin ?? current?.generatorOrigin
|
|
70
|
+
});
|
|
71
|
+
}
|
|
26
72
|
export class Collection {
|
|
27
73
|
spaceId;
|
|
28
74
|
id;
|
|
29
75
|
#context;
|
|
30
76
|
#capability;
|
|
31
77
|
#codecHolder;
|
|
78
|
+
/**
|
|
79
|
+
* The per-handle encryption override, kept so a write that states its own
|
|
80
|
+
* encryption state -- the guarded create in {@link setMeta} -- can honor it
|
|
81
|
+
* without discovering a descriptor from stored state.
|
|
82
|
+
*/
|
|
83
|
+
#encryptionOverride;
|
|
32
84
|
/**
|
|
33
85
|
* The shared backend-feature probe for this collection (memoized on a
|
|
34
86
|
* definitive answer), consulted by the conditional-codec write path and
|
|
@@ -61,6 +113,7 @@ export class Collection {
|
|
|
61
113
|
this.spaceId = spaceId;
|
|
62
114
|
this.id = collectionId;
|
|
63
115
|
this.#capability = capability;
|
|
116
|
+
this.#encryptionOverride = encryption;
|
|
64
117
|
this.#codecHolder = collectionCodecHolder(context, {
|
|
65
118
|
spaceId,
|
|
66
119
|
collectionId,
|
|
@@ -73,12 +126,15 @@ export class Collection {
|
|
|
73
126
|
capability
|
|
74
127
|
});
|
|
75
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* The Collection container in canonical (trailing-slash) form: the URL whose
|
|
131
|
+
* `GET` lists the Collection's Resources, whose `POST` adds one, and whose
|
|
132
|
+
* `DELETE` removes the Collection. A Collection's own description is not
|
|
133
|
+
* here -- it is the Metadata object at the reserved `meta` segment.
|
|
134
|
+
*/
|
|
76
135
|
get #path() {
|
|
77
136
|
return collectionPath(this.spaceId, this.id);
|
|
78
137
|
}
|
|
79
|
-
get #itemsPath() {
|
|
80
|
-
return collectionItems(this.spaceId, this.id);
|
|
81
|
-
}
|
|
82
138
|
get #policyPath() {
|
|
83
139
|
return collectionPolicy(this.spaceId, this.id);
|
|
84
140
|
}
|
|
@@ -114,185 +170,503 @@ export class Collection {
|
|
|
114
170
|
return this.#codecHolder.get();
|
|
115
171
|
}
|
|
116
172
|
/**
|
|
117
|
-
* Reads the Collection
|
|
118
|
-
*
|
|
119
|
-
*
|
|
173
|
+
* Reads the Collection Metadata object -- the Collection's configuration
|
|
174
|
+
* (`name`, `backend`, `encryption`, `generator`) beside the server-managed
|
|
175
|
+
* timestamps and the user-writable `custom`, as one object under one
|
|
176
|
+
* validator. Returns `null` if the collection is missing or not visible to
|
|
177
|
+
* you (WAS returns 404 for both not-found and unauthorized).
|
|
178
|
+
*
|
|
179
|
+
* This read never resolves the codec, so it is the configuration read: on an
|
|
180
|
+
* encrypted Collection the served `custom` is the opaque envelope, exactly as
|
|
181
|
+
* stored. {@link meta} is the same object with `custom` decoded.
|
|
120
182
|
*
|
|
121
|
-
* @returns {Promise<
|
|
183
|
+
* @returns {Promise<(CollectionMetadata & { etag?: string }) | null>}
|
|
122
184
|
*/
|
|
123
185
|
async describe() {
|
|
124
|
-
|
|
186
|
+
const read = await this.describeWithEtag();
|
|
187
|
+
if (read === null) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
return {
|
|
191
|
+
...read.description,
|
|
192
|
+
...(read.etag !== undefined && { etag: read.etag })
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* {@link describe}, with the object and its `ETag` in separate members. The
|
|
197
|
+
* `ETag` is the `metaVersion` validator to pass to
|
|
198
|
+
* {@link replaceDescription}'s `ifMatch` for a lost-update-safe
|
|
199
|
+
* (compare-and-swap) write. One validator covers the whole object, so it is
|
|
200
|
+
* also what {@link setMeta} pins against. Returns `null` if the collection is
|
|
201
|
+
* missing or not visible to you (404 conflation caveat); `etag` is absent
|
|
202
|
+
* against a backend that does not version the object.
|
|
203
|
+
*
|
|
204
|
+
* @returns {Promise<{ description: CollectionMetadata; etag?: string } | null>}
|
|
205
|
+
*/
|
|
206
|
+
async describeWithEtag() {
|
|
207
|
+
const read = await this.#readStored();
|
|
208
|
+
if (read === null) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
description: asCollectionMetadata(read.metadata),
|
|
213
|
+
...(read.etag !== undefined && { etag: read.etag })
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Reads the stored Collection Metadata object with its validator, in the wire
|
|
218
|
+
* form the server served it -- `custom` undecoded. The one read behind
|
|
219
|
+
* {@link describe} and behind every write's compose step.
|
|
220
|
+
*
|
|
221
|
+
* @returns {Promise<{ metadata: StoredCollectionMetadata; etag?: string } | null>}
|
|
222
|
+
*/
|
|
223
|
+
async #readStored() {
|
|
224
|
+
const read = await readCollectionMetadata(this.#context, {
|
|
125
225
|
spaceId: this.spaceId,
|
|
126
226
|
collectionId: this.id,
|
|
127
227
|
capability: this.#capability
|
|
128
228
|
});
|
|
229
|
+
this.#remember(read);
|
|
230
|
+
return read;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The object this handle read most recently, kept as the first baseline a
|
|
234
|
+
* following write composes against so a read-then-write is one `GET` plus
|
|
235
|
+
* one `PUT` rather than two `GET`s. Only a read carrying the backend's
|
|
236
|
+
* validator is kept: the write is pinned to that validator, so a baseline
|
|
237
|
+
* another client has since overwritten loses the compare-and-swap and is
|
|
238
|
+
* re-read, while a backend serving no validator has nothing to lose the race
|
|
239
|
+
* on and always reads fresh.
|
|
240
|
+
*/
|
|
241
|
+
#recentRead;
|
|
242
|
+
/**
|
|
243
|
+
* Records a read as the next write's baseline (see {@link #recentRead}).
|
|
244
|
+
*
|
|
245
|
+
* @param read {object | null} the stored object and its validator
|
|
246
|
+
* @returns {void}
|
|
247
|
+
*/
|
|
248
|
+
#remember(read) {
|
|
249
|
+
this.#recentRead =
|
|
250
|
+
read !== null && read.etag !== undefined
|
|
251
|
+
? { metadata: read.metadata, etag: read.etag }
|
|
252
|
+
: undefined;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Takes the remembered read, if it is usable as this write's baseline, and
|
|
256
|
+
* drops it either way: it describes one point in time, so it is never handed
|
|
257
|
+
* to a second write. A write pinned to the caller's own `ifMatch` reuses it
|
|
258
|
+
* only when the two name the same version, which makes the reuse exact
|
|
259
|
+
* rather than merely rebasable.
|
|
260
|
+
*
|
|
261
|
+
* @param [ifMatch] {string} the caller's precondition, when it named one
|
|
262
|
+
* @returns {{ metadata: StoredCollectionMetadata; etag: string } | undefined}
|
|
263
|
+
*/
|
|
264
|
+
#takeRecentRead(ifMatch) {
|
|
265
|
+
const recent = this.#recentRead;
|
|
266
|
+
this.#recentRead = undefined;
|
|
267
|
+
if (recent === undefined) {
|
|
268
|
+
return undefined;
|
|
269
|
+
}
|
|
270
|
+
return ifMatch === undefined || ifMatch === recent.etag ? recent : undefined;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Writes the Collection Metadata object: one `PUT` at `meta`, a full
|
|
274
|
+
* replacement under the one `metaVersion` validator. Because it replaces the
|
|
275
|
+
* whole object, `compose` is handed the stored object and returns the write
|
|
276
|
+
* body, carrying forward every member this write is not about -- the
|
|
277
|
+
* configuration members on an annotation write, the `custom` envelope and its
|
|
278
|
+
* `epoch` stamp on a configuration write. `compose` runs against whichever
|
|
279
|
+
* read the write is actually pinned to, so a merge it performs is never
|
|
280
|
+
* applied over a version this write did not observe.
|
|
281
|
+
*
|
|
282
|
+
* The precondition decides how the read-modify-write is guarded:
|
|
283
|
+
*
|
|
284
|
+
* - `ifNoneMatch` is the guarded create ("only if the Collection does not
|
|
285
|
+
* exist"), and reads nothing: `compose` is handed `null`, since there is no
|
|
286
|
+
* stored object to carry forward.
|
|
287
|
+
* - `ifMatch` pins the caller's own validator, so a lost race is the caller's
|
|
288
|
+
* to see as `PreconditionFailedError`.
|
|
289
|
+
* - Neither: the write is pinned to the validator of the read it composed
|
|
290
|
+
* against, and a lost race re-reads and recomposes up to the shared
|
|
291
|
+
* compare-and-swap attempt limit. One validator covers configuration and
|
|
292
|
+
* annotations alike, so a configuration change now legitimately invalidates
|
|
293
|
+
* an in-flight annotation write; rebasing is the normal outcome, not an
|
|
294
|
+
* error. A backend without `conditional-writes` serves no validator, and
|
|
295
|
+
* the write stays the unconditional upsert it has always been.
|
|
296
|
+
*
|
|
297
|
+
* Limitation, on a backend without `conditional-writes` only: the body
|
|
298
|
+
* re-sends the stored `encryption` descriptor, and with no validator to pin
|
|
299
|
+
* the `PUT` to, a descriptor rotation landing between the compose read and
|
|
300
|
+
* the write is re-sent as the older roster -- refused as a spurious
|
|
301
|
+
* `invalid-request-body` ("epochs is append-only") by a server enforcing the
|
|
302
|
+
* descriptor invariants, and rolled back by one that does not. There is no
|
|
303
|
+
* client-side remedy without a validator or a partial-update form, neither of
|
|
304
|
+
* which WAS v0.5 offers (WCL-99).
|
|
305
|
+
*
|
|
306
|
+
* The absent-object rule is the masked-404 fail-closed policy at write level:
|
|
307
|
+
* a `null` read means "missing OR not visible to you", and composing against
|
|
308
|
+
* an empty object would upsert a brand-new configuration-less Collection over
|
|
309
|
+
* a Collection this capability simply cannot read. Only a write that states
|
|
310
|
+
* its own absence -- the `ifNoneMatch` create, or a caller passing
|
|
311
|
+
* `allowAbsent` because creating is what it is for -- may proceed from
|
|
312
|
+
* nothing.
|
|
313
|
+
*
|
|
314
|
+
* @param options {object}
|
|
315
|
+
* @param options.compose {function} the stored object (`null` when there is
|
|
316
|
+
* none) to the write body, minus `id`
|
|
317
|
+
* @param options.operation {string} what the caller is doing, for the
|
|
318
|
+
* compare-and-swap exhaustion error
|
|
319
|
+
* @param [options.current] {object} an object the caller has already read,
|
|
320
|
+
* used as the first attempt's baseline instead of re-reading it: the write
|
|
321
|
+
* composes against it and pins to its validator, and a rebase re-reads. A
|
|
322
|
+
* baseline carrying no validator makes the first attempt unconditional,
|
|
323
|
+
* which is what a backend without `conditional-writes` offers anyway
|
|
324
|
+
* @param [options.allowAbsent] {boolean} compose against nothing (an
|
|
325
|
+
* upsert) instead of throwing `NotFoundError` when the object cannot be
|
|
326
|
+
* read
|
|
327
|
+
* @param [options.ifMatch] {string}
|
|
328
|
+
* @param [options.ifNoneMatch] {boolean}
|
|
329
|
+
* @returns {Promise<{ metadata?: CollectionMetadata; etag?: string }>} the
|
|
330
|
+
* new validator, and the object the server answered a create with
|
|
331
|
+
*/
|
|
332
|
+
async #writeStored({ compose, operation, current, allowAbsent, ifMatch, ifNoneMatch }) {
|
|
333
|
+
const put = async (body, precondition) => {
|
|
334
|
+
const response = await send(this.#context, {
|
|
335
|
+
path: this.#metaPath,
|
|
336
|
+
method: 'PUT',
|
|
337
|
+
capability: this.#capability,
|
|
338
|
+
json: { id: this.id, ...body },
|
|
339
|
+
headers: writeHeaders({ precondition })
|
|
340
|
+
});
|
|
341
|
+
const created = dataOrNull(response);
|
|
342
|
+
// The write superseded whatever this handle last read.
|
|
343
|
+
this.#recentRead = undefined;
|
|
344
|
+
return {
|
|
345
|
+
...(created !== null && { metadata: created }),
|
|
346
|
+
etag: readEtag(response)
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
if (ifNoneMatch === true) {
|
|
350
|
+
return put(await compose(null), { ifNoneMatch: true });
|
|
351
|
+
}
|
|
352
|
+
// The baseline for one attempt: the caller's own read, then this handle's
|
|
353
|
+
// most recent read, then a fresh `GET`. Each is consumed once, so a rebase
|
|
354
|
+
// always re-reads.
|
|
355
|
+
let seed = current ?? undefined;
|
|
356
|
+
const baseline = async () => {
|
|
357
|
+
const reused = seed ?? this.#takeRecentRead(ifMatch);
|
|
358
|
+
seed = undefined;
|
|
359
|
+
if (reused !== undefined) {
|
|
360
|
+
return reused;
|
|
361
|
+
}
|
|
362
|
+
const read = await this.#readStored();
|
|
363
|
+
this.#recentRead = undefined;
|
|
364
|
+
if (read !== null) {
|
|
365
|
+
return read;
|
|
366
|
+
}
|
|
367
|
+
if (allowAbsent !== true) {
|
|
368
|
+
throw new NotFoundError(`Cannot ${operation.toLowerCase()} on collection "${this.id}": it ` +
|
|
369
|
+
'does not exist, or is not visible with this capability (WAS ' +
|
|
370
|
+
'returns 404 for both). Writing anyway would replace it with a ' +
|
|
371
|
+
'configuration-less Collection. Create it with ' +
|
|
372
|
+
'`space.createCollection()`, or pass `ifNoneMatch: true` to ' +
|
|
373
|
+
'create it only if it is absent.');
|
|
374
|
+
}
|
|
375
|
+
return { metadata: null };
|
|
376
|
+
};
|
|
377
|
+
if (ifMatch !== undefined) {
|
|
378
|
+
const attempt = await baseline();
|
|
379
|
+
return put(await compose(attempt.metadata), { ifMatch });
|
|
380
|
+
}
|
|
381
|
+
let written = {};
|
|
382
|
+
await compareAndSwap({
|
|
383
|
+
store: {
|
|
384
|
+
read: async () => {
|
|
385
|
+
const attempt = await baseline();
|
|
386
|
+
return {
|
|
387
|
+
value: attempt.metadata,
|
|
388
|
+
...(attempt.etag !== undefined && { etag: attempt.etag })
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
replace: async (body, { ifMatch: pinned }) => {
|
|
392
|
+
written = await put(body ?? {}, { ifMatch: pinned });
|
|
393
|
+
}
|
|
394
|
+
},
|
|
395
|
+
operation,
|
|
396
|
+
mutate: compose
|
|
397
|
+
});
|
|
398
|
+
return written;
|
|
129
399
|
}
|
|
130
400
|
/**
|
|
131
|
-
* Creates or updates the collection by id (upsert). Merges the given
|
|
132
|
-
* over the current
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
401
|
+
* Creates or updates the collection by id (upsert). Merges the given
|
|
402
|
+
* configuration members over the current ones; the annotations (`custom` and
|
|
403
|
+
* its `epoch` stamp) are carried forward untouched, unless this write
|
|
404
|
+
* changes the encryption scheme, in which case `custom` is re-sealed under
|
|
405
|
+
* the incoming descriptor (see {@link replaceDescription}).
|
|
406
|
+
*
|
|
407
|
+
* The merge runs against the same read the write is pinned to, and rebases
|
|
408
|
+
* with it: a rival configuration change landing in between is re-read and
|
|
409
|
+
* merged over rather than re-applied stale.
|
|
410
|
+
*
|
|
411
|
+
* The merge needs a readable current object to be lost-update-safe, and the
|
|
412
|
+
* read cannot distinguish "absent" from "unreadable" (WAS masks unauthorized
|
|
413
|
+
* reads as 404). When it finds nothing and either `backend` or `encryption`
|
|
414
|
+
* is left out, this fails closed rather than sending a PUT body that would
|
|
415
|
+
* silently drop an existing collection's `backend` (a data-placement change)
|
|
416
|
+
* or trip `encryption-immutable` by clearing its descriptor. Supplying only
|
|
417
|
+
* one of the two does not cover the other: with nothing readable to merge
|
|
418
|
+
* from, the omitted field is dropped either way. Pass `force: true` to
|
|
419
|
+
* proceed anyway -- e.g. when creating a new collection through a handle (or
|
|
420
|
+
* use `space.createCollection()`, which does not merge).
|
|
144
421
|
*
|
|
145
422
|
* @param desc {CollectionWritableFields} the fields to merge; `encryption`
|
|
146
423
|
* declares the client-side encryption descriptor, which is set-once on the
|
|
147
424
|
* server (it may be added to a Collection that lacks one, but
|
|
148
425
|
* changing/clearing an existing descriptor is rejected -- `ConflictError`,
|
|
149
426
|
* `encryption-immutable`)
|
|
150
|
-
* @param [desc.force] {boolean} proceed even when the current
|
|
427
|
+
* @param [desc.force] {boolean} proceed even when the current object
|
|
151
428
|
* is unreadable and `backend`/`encryption` are omitted (see above)
|
|
152
|
-
* @param [desc.current] {
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
* @returns {Promise<CollectionDescription>}
|
|
429
|
+
* @param [desc.current] {CollectionMetadata | null} the current Collection
|
|
430
|
+
* Metadata object, when the caller has already read it -- the merge's first
|
|
431
|
+
* attempt then runs against this instead of a second read. It is used only
|
|
432
|
+
* while it carries the `etag` the write pins against, so a copy another
|
|
433
|
+
* client has since overwritten loses the compare-and-swap and is re-read
|
|
434
|
+
* rather than merged forward. `null` means the caller read it and found
|
|
435
|
+
* the collection absent or unreadable; omitting the member entirely is
|
|
436
|
+
* what asks for the read
|
|
437
|
+
* @returns {Promise<CollectionMetadata>}
|
|
162
438
|
*/
|
|
163
439
|
async configure(desc) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
440
|
+
// What the last compose attempt merged, for the echoed return below (the
|
|
441
|
+
// server answers an update with no body).
|
|
442
|
+
let echoed = {
|
|
443
|
+
fields: {}
|
|
444
|
+
};
|
|
445
|
+
if (desc.current === null) {
|
|
446
|
+
// `current: null` is an answer the caller already read, not a request to
|
|
447
|
+
// read: the guard fires on it without a round trip of this handle's own.
|
|
448
|
+
this.#refuseBlindMerge(desc);
|
|
449
|
+
}
|
|
450
|
+
const { metadata } = await this.#writeStored({
|
|
451
|
+
current: desc.current != null
|
|
452
|
+
? {
|
|
453
|
+
metadata: desc.current,
|
|
454
|
+
...(desc.current.etag !== undefined && {
|
|
455
|
+
etag: desc.current.etag
|
|
456
|
+
})
|
|
457
|
+
}
|
|
458
|
+
: undefined,
|
|
459
|
+
// The merge is the upsert: with nothing stored there is nothing to merge
|
|
460
|
+
// forward, and the guard below decides whether that is safe.
|
|
461
|
+
allowAbsent: true,
|
|
462
|
+
compose: stored => {
|
|
463
|
+
const current = stored === null ? null : asCollectionMetadata(stored);
|
|
464
|
+
// Each protected field is checked on its own terms. Supplying one does
|
|
465
|
+
// not make the other safe to omit: with no readable current object
|
|
466
|
+
// there is nothing to merge the omitted one forward from, so
|
|
467
|
+
// `configure({ backend })` on an EDV collection would send a body with
|
|
468
|
+
// no `encryption` -- clearing the descriptor, or tripping
|
|
469
|
+
// `encryption-immutable` -- which is the harm this guard exists to
|
|
470
|
+
// prevent.
|
|
471
|
+
if (current === null) {
|
|
472
|
+
this.#refuseBlindMerge(desc);
|
|
473
|
+
}
|
|
474
|
+
const fields = mergedConfiguration(desc, current);
|
|
475
|
+
echoed = {
|
|
476
|
+
...(current?.type !== undefined && { type: current.type }),
|
|
477
|
+
fields
|
|
478
|
+
};
|
|
479
|
+
return this.#configurationBody(stored, fields);
|
|
480
|
+
},
|
|
481
|
+
operation: 'Collection configuration'
|
|
482
|
+
});
|
|
483
|
+
this.#resetCodecIfDeclared(desc.encryption);
|
|
484
|
+
return (metadata ?? {
|
|
485
|
+
id: this.id,
|
|
486
|
+
type: echoed.type ?? ['Collection'],
|
|
487
|
+
...echoed.fields
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* The fail-closed guard of {@link configure}: refuses a merge whose current
|
|
492
|
+
* object could not be read, unless the caller stated both protected members
|
|
493
|
+
* itself or passed `force`. Each is checked on its own terms. Supplying one
|
|
494
|
+
* does not make the other safe to omit: with no readable current object
|
|
495
|
+
* there is nothing to merge the omitted one forward from, so
|
|
496
|
+
* `configure({ backend })` on an EDV collection would send a body with no
|
|
497
|
+
* `encryption` -- clearing the descriptor, or tripping
|
|
498
|
+
* `encryption-immutable` -- which is the harm this guard exists to prevent.
|
|
499
|
+
*
|
|
500
|
+
* @param desc {CollectionWritableFields} what the caller stated
|
|
501
|
+
* @param [desc.force] {boolean}
|
|
502
|
+
* @returns {void}
|
|
503
|
+
*/
|
|
504
|
+
#refuseBlindMerge(desc) {
|
|
505
|
+
if ((desc.backend === undefined || desc.encryption === undefined) &&
|
|
168
506
|
!desc.force) {
|
|
169
507
|
throw unreadableDescriptionError({
|
|
170
508
|
operation: `configure collection "${this.id}"`,
|
|
171
509
|
consequence: "merging forward could silently drop an existing collection's " +
|
|
172
510
|
'backend or encryption descriptor',
|
|
173
|
-
advice: 'Supply `backend
|
|
174
|
-
'capability, or pass `force: true` if you are
|
|
175
|
-
'collection.'
|
|
511
|
+
advice: 'Supply BOTH `backend` and `encryption` explicitly, use a ' +
|
|
512
|
+
'read-capable capability, or pass `force: true` if you are ' +
|
|
513
|
+
'creating a new collection.'
|
|
176
514
|
});
|
|
177
515
|
}
|
|
178
|
-
// Merge every current field forward (mirror `Space.configure`): a
|
|
179
|
-
// replace-semantics server drops anything omitted from the PUT body, so
|
|
180
|
-
// `configure({ name })` on an EDV collection would otherwise wipe its
|
|
181
|
-
// `backend` or trip `encryption-immutable` by clearing the descriptor.
|
|
182
|
-
const name = desc.name ?? current?.name;
|
|
183
|
-
const backend = desc.backend ?? current?.backend;
|
|
184
|
-
const encryption = desc.encryption ?? current?.encryption;
|
|
185
|
-
// The app-attribution fields merge forward on the same terms, so a
|
|
186
|
-
// `configure({ name })` does not erase a stored `generator`. They are
|
|
187
|
-
// deliberately NOT part of the unreadable-description guard above: unlike
|
|
188
|
-
// `backend` and `encryption`, they are freely re-writable attribution
|
|
189
|
-
// (dropping one is cosmetic, not a data-placement change or an
|
|
190
|
-
// `encryption-immutable` trip), and admitting them there would let a
|
|
191
|
-
// `configure({ generator })` sail past the guard and blindly drop the two
|
|
192
|
-
// fields it exists to protect.
|
|
193
|
-
const generator = desc.generator ?? current?.generator;
|
|
194
|
-
const generatorOrigin = desc.generatorOrigin ?? current?.generatorOrigin;
|
|
195
|
-
const fields = collectionWritableFields({
|
|
196
|
-
name,
|
|
197
|
-
backend,
|
|
198
|
-
encryption,
|
|
199
|
-
generator,
|
|
200
|
-
generatorOrigin
|
|
201
|
-
});
|
|
202
|
-
await send(this.#context, {
|
|
203
|
-
path: this.#path,
|
|
204
|
-
method: 'PUT',
|
|
205
|
-
capability: this.#capability,
|
|
206
|
-
json: { id: this.id, ...fields }
|
|
207
|
-
});
|
|
208
|
-
// Adding the encryption descriptor flips this collection from plaintext to
|
|
209
|
-
// encrypted server-side. Drop any codec memoized from the prior (plaintext)
|
|
210
|
-
// descriptor so the next read/write re-resolves it -- otherwise a `put`
|
|
211
|
-
// would reuse the cached identity codec and write server-visible plaintext
|
|
212
|
-
// into the now-encrypted collection. Child resource handles share this
|
|
213
|
-
// codec via their thunk, so resetting here propagates to them too.
|
|
214
|
-
if (desc.encryption) {
|
|
215
|
-
this.#codecHolder.reset();
|
|
216
|
-
}
|
|
217
|
-
return {
|
|
218
|
-
id: this.id,
|
|
219
|
-
type: current?.type ?? ['Collection'],
|
|
220
|
-
...fields
|
|
221
|
-
};
|
|
222
516
|
}
|
|
223
517
|
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* the opaque validator to pass to {@link replaceDescription}'s `ifMatch` for a
|
|
227
|
-
* lost-update-safe (compare-and-swap) description write. Returns `null` if the
|
|
228
|
-
* collection is missing or not visible to you (404 conflation caveat); `etag`
|
|
229
|
-
* is absent against a server that does not version the description.
|
|
230
|
-
*
|
|
231
|
-
* @returns {Promise<{ description: CollectionDescription; etag?: string } | null>}
|
|
232
|
-
*/
|
|
233
|
-
async describeWithEtag() {
|
|
234
|
-
const read = await readDataWithEtag(this.#context, {
|
|
235
|
-
path: this.#path,
|
|
236
|
-
capability: this.#capability
|
|
237
|
-
});
|
|
238
|
-
return read === null ? null : { description: read.data, etag: read.etag };
|
|
239
|
-
}
|
|
240
|
-
/**
|
|
241
|
-
* Writes (replaces) the Collection Description, optionally under a
|
|
242
|
-
* precondition: `ifMatch` (the `ETag` from {@link describeWithEtag}) makes
|
|
518
|
+
* Writes (replaces) the Collection's configuration members, optionally under
|
|
519
|
+
* a precondition: `ifMatch` (the `ETag` from {@link describeWithEtag}) makes
|
|
243
520
|
* it a compare-and-swap so a concurrent writer cannot be silently clobbered,
|
|
244
521
|
* and `ifNoneMatch: true` makes it a guarded create that proceeds only while
|
|
245
522
|
* no Collection exists under this id. A failed precondition surfaces as
|
|
246
|
-
* `PreconditionFailedError` (412). Sends the writable
|
|
247
|
-
* body; omit a
|
|
248
|
-
* pass every
|
|
523
|
+
* `PreconditionFailedError` (412). Sends the writable configuration as the
|
|
524
|
+
* full body; omit a member to drop it (replace semantics), so callers doing
|
|
525
|
+
* CAS pass every member forward. Members this client does not model are
|
|
526
|
+
* carried forward from the stored object rather than cleared.
|
|
249
527
|
*
|
|
250
|
-
*
|
|
528
|
+
* The annotations (`custom` and its `epoch` stamp) are carried forward
|
|
529
|
+
* verbatim -- `custom` exactly as served, so an encrypted Collection's
|
|
530
|
+
* envelope is never re-sealed by an ordinary configuration write. The one
|
|
531
|
+
* exception is a write that changes the encryption scheme: the server
|
|
532
|
+
* validates `custom` against the INCOMING descriptor, so a stored plaintext
|
|
533
|
+
* `custom` cannot travel beside a newly declared `encryption` descriptor.
|
|
534
|
+
* Such a write re-seals `custom` under the incoming descriptor's codec, and
|
|
535
|
+
* drops `custom` (and its stamp) when this client cannot build that codec.
|
|
536
|
+
* A Collection with no stored `custom` sends none, which clears an
|
|
537
|
+
* already-empty value on an encrypted Collection just as on a plaintext one.
|
|
538
|
+
* Returns the new `ETag`, and the object the server answers a create with.
|
|
539
|
+
*
|
|
540
|
+
* This is the generic compare-and-swap primitive the key-epoch recipient
|
|
251
541
|
* operations build on (add/remove a reader is a CAS of the `encryption`
|
|
252
542
|
* descriptor); it is not epoch-specific.
|
|
253
543
|
*
|
|
254
544
|
* @param description {CollectionWritableFields}
|
|
255
545
|
* @param options {object}
|
|
256
546
|
* @param [options.ifMatch] {string} the prior `ETag`; the write applies only
|
|
257
|
-
* if the
|
|
547
|
+
* if the object is unchanged
|
|
258
548
|
* @param [options.ifNoneMatch] {boolean} write only if the Collection does
|
|
259
549
|
* not exist yet
|
|
260
|
-
* @returns {Promise<{ description:
|
|
550
|
+
* @returns {Promise<{ description: CollectionMetadata; etag?: string }>}
|
|
261
551
|
*/
|
|
262
552
|
async replaceDescription(description, options = {}) {
|
|
263
553
|
const fields = collectionWritableFields(description);
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}
|
|
274
|
-
})
|
|
554
|
+
const { metadata, etag } = await this.#writeStored({
|
|
555
|
+
compose: stored => this.#configurationBody(stored, fields),
|
|
556
|
+
operation: 'Collection configuration',
|
|
557
|
+
// A caller pinning a validator is writing to an object it has read, so
|
|
558
|
+
// an unreadable one is a lost Collection, not a create. An unconditional
|
|
559
|
+
// call stays the upsert it has always been.
|
|
560
|
+
allowAbsent: options.ifMatch === undefined,
|
|
561
|
+
ifMatch: options.ifMatch,
|
|
562
|
+
ifNoneMatch: options.ifNoneMatch
|
|
275
563
|
});
|
|
276
|
-
|
|
277
|
-
// recipient operations CAS this field) or flip the collection from
|
|
278
|
-
// plaintext to encrypted. Drop any memoized codec -- bound at construction
|
|
279
|
-
// to the prior descriptor's write key/epoch -- so the next read/write
|
|
280
|
-
// re-resolves it under the new descriptor; otherwise a `put` on the same
|
|
281
|
-
// handle would keep encrypting under the stale epoch, whose key a
|
|
282
|
-
// just-removed reader still holds. Child resource handles share this codec
|
|
283
|
-
// via their thunk, so resetting here propagates to them too.
|
|
284
|
-
if (description.encryption !== undefined) {
|
|
285
|
-
this.#codecHolder.reset();
|
|
286
|
-
}
|
|
564
|
+
this.#resetCodecIfDeclared(description.encryption);
|
|
287
565
|
return {
|
|
288
|
-
description: {
|
|
566
|
+
description: metadata ?? {
|
|
289
567
|
id: this.id,
|
|
290
568
|
type: ['Collection'],
|
|
291
569
|
...fields
|
|
292
570
|
},
|
|
293
|
-
etag
|
|
571
|
+
...(etag !== undefined && { etag })
|
|
294
572
|
};
|
|
295
573
|
}
|
|
574
|
+
/**
|
|
575
|
+
* The write body of a configuration write: the caller's configuration
|
|
576
|
+
* members over everything the stored object carries that this write is not
|
|
577
|
+
* about -- the annotations, and any member this client does not model.
|
|
578
|
+
* Shared by {@link configure} and {@link replaceDescription}, whose bodies
|
|
579
|
+
* differ only in how the configuration members were arrived at.
|
|
580
|
+
*
|
|
581
|
+
* @param stored {StoredCollectionMetadata | null}
|
|
582
|
+
* @param fields {CollectionWritableFields} the configuration this write
|
|
583
|
+
* states in full
|
|
584
|
+
* @returns {Promise<StoredCollectionMetadata>}
|
|
585
|
+
*/
|
|
586
|
+
async #configurationBody(stored, fields) {
|
|
587
|
+
const base = stored ?? {};
|
|
588
|
+
const resealed = await this.#resealedCustom(base, fields.encryption);
|
|
589
|
+
const carried = carriedForward(base, {
|
|
590
|
+
replacing: resealed === null
|
|
591
|
+
? CONFIGURATION_MEMBERS
|
|
592
|
+
: [...CONFIGURATION_MEMBERS, ...ANNOTATION_MEMBERS]
|
|
593
|
+
});
|
|
594
|
+
return { ...carried, ...(resealed ?? {}), ...fields };
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The annotations a configuration write that CHANGES the encryption scheme
|
|
598
|
+
* sends: the stored `custom` opened under the descriptor it was sealed with
|
|
599
|
+
* and re-sealed under the incoming one, with the new key epoch stamped
|
|
600
|
+
* beside it. The server validates `custom` against the incoming descriptor,
|
|
601
|
+
* so forwarding a plaintext `custom` beside a newly declared descriptor is
|
|
602
|
+
* refused (422, `encryption-scheme-mismatch`) -- and the spec states the same
|
|
603
|
+
* rule: a change to `encryption` requires the envelope to be re-sealed.
|
|
604
|
+
*
|
|
605
|
+
* Resolves `null` when the write leaves the scheme as it is (the ordinary
|
|
606
|
+
* case, an epoch rotation included), meaning "carry the stored annotations
|
|
607
|
+
* forward untouched". Resolves `{}` -- drop `custom` and its stamp -- when
|
|
608
|
+
* the re-seal is not possible here: this client cannot build a codec for one
|
|
609
|
+
* of the two descriptors, or the stored envelope is one it cannot open. The
|
|
610
|
+
* name and tags inside are lost in that case, which is why the re-seal is
|
|
611
|
+
* attempted first.
|
|
612
|
+
*
|
|
613
|
+
* @param stored {StoredCollectionMetadata}
|
|
614
|
+
* @param [encryption] {CollectionEncryption} the descriptor this write
|
|
615
|
+
* states
|
|
616
|
+
* @returns {Promise<{ custom?: unknown; epoch?: string } | null>}
|
|
617
|
+
*/
|
|
618
|
+
async #resealedCustom(stored, encryption) {
|
|
619
|
+
const declared = storedEncryption(stored);
|
|
620
|
+
if (encryption?.scheme === declared?.scheme) {
|
|
621
|
+
return null;
|
|
622
|
+
}
|
|
623
|
+
if (stored.custom === undefined) {
|
|
624
|
+
return null;
|
|
625
|
+
}
|
|
626
|
+
try {
|
|
627
|
+
const [from, to] = await Promise.all([
|
|
628
|
+
codecForDescriptor(this.#context, {
|
|
629
|
+
spaceId: this.spaceId,
|
|
630
|
+
collectionId: this.id,
|
|
631
|
+
...(declared !== undefined && { descriptor: declared })
|
|
632
|
+
}),
|
|
633
|
+
codecForDescriptor(this.#context, {
|
|
634
|
+
spaceId: this.spaceId,
|
|
635
|
+
collectionId: this.id,
|
|
636
|
+
...(encryption !== undefined && { descriptor: encryption })
|
|
637
|
+
})
|
|
638
|
+
]);
|
|
639
|
+
if (from === null || to === null) {
|
|
640
|
+
return {};
|
|
641
|
+
}
|
|
642
|
+
const slot = { kind: 'collection' };
|
|
643
|
+
const opened = await from.decodeMeta({ custom: stored.custom }, slot);
|
|
644
|
+
const { custom, epoch } = await to.encodeMeta({ custom: opened, slot });
|
|
645
|
+
return { custom, ...(epoch !== undefined && { epoch }) };
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
return {};
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/**
|
|
652
|
+
* Drops any memoized codec when a write declared an `encryption` descriptor.
|
|
653
|
+
* Writing it can rotate the key epoch (the recipient operations CAS this
|
|
654
|
+
* member) or flip the collection from plaintext to encrypted, and a codec
|
|
655
|
+
* memoized from the prior descriptor would keep encrypting under the stale
|
|
656
|
+
* epoch -- whose key a just-removed reader still holds -- or write
|
|
657
|
+
* server-visible plaintext into a now-encrypted collection. Child resource
|
|
658
|
+
* handles share this codec via their thunk, so resetting here propagates to
|
|
659
|
+
* them too.
|
|
660
|
+
*
|
|
661
|
+
* @param [encryption] {CollectionEncryption} the descriptor the write
|
|
662
|
+
* declared, if any
|
|
663
|
+
* @returns {void}
|
|
664
|
+
*/
|
|
665
|
+
#resetCodecIfDeclared(encryption) {
|
|
666
|
+
if (encryption !== undefined) {
|
|
667
|
+
this.#codecHolder.reset();
|
|
668
|
+
}
|
|
669
|
+
}
|
|
296
670
|
/**
|
|
297
671
|
* Deletes the whole collection. Idempotent. To delete a single resource, use
|
|
298
672
|
* `collection.resource(id).delete()`.
|
|
@@ -311,28 +685,30 @@ export class Collection {
|
|
|
311
685
|
return collectionMeta(this.spaceId, this.id);
|
|
312
686
|
}
|
|
313
687
|
/**
|
|
314
|
-
* Reads the Collection
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
688
|
+
* Reads the Collection Metadata object with its user-writable `custom`
|
|
689
|
+
* decoded -- {@link describe} plus the codec. Returns `null` if the
|
|
690
|
+
* collection is missing or not visible to you (404 conflation caveat). A
|
|
691
|
+
* server without Collection metadata support surfaces its 501 as
|
|
692
|
+
* `NotImplementedError`.
|
|
319
693
|
*
|
|
320
694
|
* On an encrypted collection the stored `custom` is an opaque envelope; this
|
|
321
695
|
* decodes it (decrypts, via the codec) so a caller always sees plaintext
|
|
322
696
|
* `{ name, tags }`. A collection with no user metadata reports `custom` as
|
|
323
|
-
* `{}`.
|
|
697
|
+
* `{}`. Resolving the codec can fail closed on a collection this client holds
|
|
698
|
+
* no keys for, which is why {@link describe} -- the configuration read -- goes
|
|
699
|
+
* without it.
|
|
324
700
|
*
|
|
325
701
|
* Against a backend with the `conditional-writes` feature the result also
|
|
326
|
-
* carries the
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
702
|
+
* carries the object's current `etag` (its `metaVersion` validator) -- pass it
|
|
703
|
+
* as `setMeta(meta, { ifMatch })` for a lost-update-safe update. One
|
|
704
|
+
* validator covers the whole object: a configuration write and an annotation
|
|
705
|
+
* write advance the same counter, so a client holds one ETag for the
|
|
706
|
+
* Collection rather than one per surface.
|
|
331
707
|
*
|
|
332
708
|
* @returns {Promise<(CollectionMetadata & { etag?: string }) | null>}
|
|
333
709
|
*/
|
|
334
710
|
async meta() {
|
|
335
|
-
// Resolving the codec on a blinded-index collection reads
|
|
711
|
+
// Resolving the codec on a blinded-index collection reads `meta` itself
|
|
336
712
|
// (for the index schema). When this call is the one that started that
|
|
337
713
|
// resolution, its snapshot is this read's answer -- take it rather than
|
|
338
714
|
// GETting and decrypting the same document twice. Every other caller gets
|
|
@@ -347,28 +723,43 @@ export class Collection {
|
|
|
347
723
|
codec: Promise.resolve(codec),
|
|
348
724
|
subject: `collection "${this.id}"`,
|
|
349
725
|
slot: { kind: 'collection' },
|
|
350
|
-
capability: this.#capability
|
|
726
|
+
capability: this.#capability,
|
|
727
|
+
// `setName` / `setTags` / `declareIndex` write straight back through
|
|
728
|
+
// `setMeta`, so this read is the baseline that write composes against
|
|
729
|
+
// rather than a second `GET` of the same object.
|
|
730
|
+
onStored: read => {
|
|
731
|
+
this.#remember(read);
|
|
732
|
+
}
|
|
351
733
|
});
|
|
352
734
|
}
|
|
353
735
|
/**
|
|
354
|
-
* Replaces the Collection's user-writable
|
|
355
|
-
* replacement
|
|
356
|
-
* `custom` clears them all.
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
736
|
+
* Replaces the Collection's user-writable annotations (`custom`). This is a
|
|
737
|
+
* full replacement of `custom`: any property omitted from it is cleared, and
|
|
738
|
+
* an omitted `custom` clears them all. The Collection's configuration
|
|
739
|
+
* members are carried forward -- the same `PUT` replaces the whole
|
|
740
|
+
* Collection Metadata object, so they are read and re-sent rather than
|
|
741
|
+
* dropped.
|
|
360
742
|
*
|
|
361
743
|
* On an encrypted collection `custom` is encrypted into an opaque envelope by
|
|
362
744
|
* the codec before it is sent, so `name` / `tags` are never stored as
|
|
363
745
|
* server-visible plaintext -- transparently, the same call works on plaintext
|
|
364
|
-
* and encrypted collections alike.
|
|
746
|
+
* and encrypted collections alike. The key epoch the codec stamps the
|
|
747
|
+
* envelope with travels as a top-level `epoch` member of the same body.
|
|
365
748
|
*
|
|
366
|
-
* Conditional
|
|
367
|
-
*
|
|
368
|
-
* update-if-unchanged,
|
|
369
|
-
*
|
|
370
|
-
*
|
|
371
|
-
*
|
|
749
|
+
* Conditional writes (the backend's `conditional-writes` feature): pass
|
|
750
|
+
* `ifMatch` (the `etag` from a prior {@link meta} or {@link describe}) for an
|
|
751
|
+
* update-if-unchanged, and a failed precondition throws
|
|
752
|
+
* `PreconditionFailedError` (412). Without one the write is pinned to the
|
|
753
|
+
* version it composed the configuration from and rebases on a lost race.
|
|
754
|
+
* `ifNoneMatch: true` is the guarded create: one validator covers the whole
|
|
755
|
+
* object, which exists exactly as long as its Collection does, so it means
|
|
756
|
+
* "create the Collection only if it does not exist" -- and creates it with no
|
|
757
|
+
* configuration, the plaintext codec encoding its `custom` (nothing is
|
|
758
|
+
* stored yet to declare encryption, and a per-handle override is the only
|
|
759
|
+
* way to state it). Every other annotation write requires the Collection to
|
|
760
|
+
* be there: one that cannot read the current object throws `NotFoundError`
|
|
761
|
+
* rather than upserting a configuration-less Collection over one this
|
|
762
|
+
* capability cannot see. Returns the new `etag`.
|
|
372
763
|
*
|
|
373
764
|
* @param meta {object}
|
|
374
765
|
* @param [meta.custom] {ResourceMetadataCustomInput} the user-writable
|
|
@@ -376,44 +767,79 @@ export class Collection {
|
|
|
376
767
|
* Collection-level `custom` also carries the persisted `indexSchema`)
|
|
377
768
|
* while `name` / `tags` themselves stay checked at their stored types
|
|
378
769
|
* @param options {object}
|
|
379
|
-
* @param [options.ifMatch] {string} update only if the
|
|
380
|
-
* @param [options.ifNoneMatch] {boolean}
|
|
381
|
-
*
|
|
770
|
+
* @param [options.ifMatch] {string} update only if the `ETag` matches
|
|
771
|
+
* @param [options.ifNoneMatch] {boolean} create only if the Collection does
|
|
772
|
+
* not exist
|
|
773
|
+
* @returns {Promise<{ etag?: string }>} the object's new ETag
|
|
382
774
|
*/
|
|
383
775
|
async setMeta(meta = {}, options = {}) {
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
codec: this.#codec(),
|
|
776
|
+
const codec = await this.#codecForWrite(options.ifNoneMatch === true);
|
|
777
|
+
const { custom, epoch } = await codec.encodeMeta({
|
|
387
778
|
custom: meta.custom ?? {},
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
779
|
+
slot: { kind: 'collection' }
|
|
780
|
+
});
|
|
781
|
+
const { etag } = await this.#writeStored({
|
|
782
|
+
// The configuration members come from the stored object and the
|
|
783
|
+
// annotations from this call. The epoch stamp describes the `custom`
|
|
784
|
+
// envelope itself, so it is re-stated with it; the server clears the
|
|
785
|
+
// stored stamp when the member is omitted, which is exactly right on a
|
|
786
|
+
// plaintext collection, whose codec surfaces no epoch.
|
|
787
|
+
compose: stored => ({
|
|
788
|
+
...carriedForward(stored ?? {}, { replacing: ANNOTATION_MEMBERS }),
|
|
789
|
+
custom,
|
|
790
|
+
...(epoch !== undefined && { epoch })
|
|
791
|
+
}),
|
|
792
|
+
operation: 'Collection metadata update',
|
|
793
|
+
// The create states its own absence; every other annotation write is
|
|
794
|
+
// about a Collection that exists, and an unreadable one is refused
|
|
795
|
+
// rather than upserted over (see `#writeStored`).
|
|
796
|
+
allowAbsent: false,
|
|
392
797
|
ifMatch: options.ifMatch,
|
|
393
|
-
ifNoneMatch: options.ifNoneMatch
|
|
394
|
-
capability: this.#capability
|
|
798
|
+
ifNoneMatch: options.ifNoneMatch
|
|
395
799
|
});
|
|
800
|
+
return { etag };
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* The codec an annotation write encodes `custom` with. The ordinary write
|
|
804
|
+
* uses the handle's resolved codec, discovered from the Collection's stored
|
|
805
|
+
* `encryption` descriptor. The guarded create cannot: there is nothing
|
|
806
|
+
* stored to discover, and descriptor discovery on an absent Collection is
|
|
807
|
+
* exactly the masked-404 an encryption-capable client fails closed on -- it
|
|
808
|
+
* would refuse the create it was asked to make. So the create takes its
|
|
809
|
+
* encryption state from what it is writing: a per-handle override when the
|
|
810
|
+
* caller pinned one, and otherwise the plaintext codec, because the body it
|
|
811
|
+
* sends declares no descriptor.
|
|
812
|
+
*
|
|
813
|
+
* @param creating {boolean} whether this write is the guarded create
|
|
814
|
+
* @returns {Promise<ResourceCodec>}
|
|
815
|
+
*/
|
|
816
|
+
async #codecForWrite(creating) {
|
|
817
|
+
if (creating && this.#encryptionOverride === undefined) {
|
|
818
|
+
return identityCodec;
|
|
819
|
+
}
|
|
820
|
+
return this.#codec();
|
|
396
821
|
}
|
|
397
822
|
/**
|
|
398
|
-
* Sets the Collection's
|
|
399
|
-
* existing `tags`. Convenience over `setMeta()`. The write is pinned to
|
|
400
|
-
* `etag` the `meta()` read returned (when the backend supports
|
|
401
|
-
* `conditional-writes`), so a concurrent
|
|
402
|
-
*
|
|
823
|
+
* Sets the Collection's annotation-level human-readable `name`, preserving
|
|
824
|
+
* any existing `tags`. Convenience over `setMeta()`. The write is pinned to
|
|
825
|
+
* the `etag` the `meta()` read returned (when the backend supports
|
|
826
|
+
* `conditional-writes`) and rebases on a lost race, so a concurrent write --
|
|
827
|
+
* an annotation write or a configuration change, which share the one
|
|
828
|
+
* validator -- is re-read and re-applied rather than silently erased by this
|
|
403
829
|
* full-replacement write.
|
|
404
830
|
*
|
|
405
831
|
* On an encrypted collection this is the collection's client-encrypted name
|
|
406
832
|
* surface: the codec seals it into the `custom` envelope, and by convention
|
|
407
|
-
* the plaintext
|
|
833
|
+
* the plaintext top-level `name` is left unpopulated. On a plaintext
|
|
408
834
|
* collection the two are separate labels -- space-level listings surface the
|
|
409
|
-
*
|
|
410
|
-
*
|
|
835
|
+
* top-level `name` (set via `configure({ name })`), while this one is inside
|
|
836
|
+
* `custom`.
|
|
411
837
|
*
|
|
412
838
|
* @param name {string}
|
|
413
839
|
* @returns {Promise<void>}
|
|
414
840
|
*/
|
|
415
841
|
async setName(name) {
|
|
416
|
-
return patchCustom(this, { name });
|
|
842
|
+
return patchCustom(this, { name }, 'Collection name update');
|
|
417
843
|
}
|
|
418
844
|
/**
|
|
419
845
|
* Sets the Collection's `tags`, preserving any existing `name`. Convenience
|
|
@@ -424,13 +850,13 @@ export class Collection {
|
|
|
424
850
|
* @returns {Promise<void>}
|
|
425
851
|
*/
|
|
426
852
|
async setTags(tags) {
|
|
427
|
-
return patchCustom(this, { tags });
|
|
853
|
+
return patchCustom(this, { tags }, 'Collection tags update');
|
|
428
854
|
}
|
|
429
855
|
get #logPath() {
|
|
430
856
|
return collectionLog(this.spaceId, this.id);
|
|
431
857
|
}
|
|
432
858
|
/**
|
|
433
|
-
* The absolute URL of the Collection's governing history log, the
|
|
859
|
+
* The absolute URL of the Collection's governing history log, the `meta/log`
|
|
434
860
|
* sub-resource {@link getHistoryLog} reads. A log-governed descriptor's
|
|
435
861
|
* `history.resource` names this URL; a verifying reader compares the two
|
|
436
862
|
* before opening the log.
|
|
@@ -524,6 +950,17 @@ export class Collection {
|
|
|
524
950
|
}
|
|
525
951
|
})
|
|
526
952
|
});
|
|
953
|
+
// From the guarded create on, the served `encryption` member IS a
|
|
954
|
+
// projection of this log's head state, so every write here can rotate the
|
|
955
|
+
// key epoch or flip the collection from plaintext to encrypted -- exactly
|
|
956
|
+
// what `replaceDescription` resets for, reached by the other route. Drop
|
|
957
|
+
// the memoized codec so the next read/write re-resolves it against the
|
|
958
|
+
// new head; otherwise a `put` on this handle after a `removeRecipient`
|
|
959
|
+
// would keep encrypting under the rotated-out epoch (whose key the
|
|
960
|
+
// just-removed reader still holds), and a `put` after a guarded create
|
|
961
|
+
// would write server-visible plaintext through the identity codec the
|
|
962
|
+
// pre-governed description resolved.
|
|
963
|
+
this.#codecHolder.reset();
|
|
527
964
|
return { etag: readEtag(response) };
|
|
528
965
|
}
|
|
529
966
|
/**
|
|
@@ -802,7 +1239,7 @@ export class Collection {
|
|
|
802
1239
|
*/
|
|
803
1240
|
async add(data, options = {}) {
|
|
804
1241
|
const codec = await this.#codec();
|
|
805
|
-
const itemsPath = this.#
|
|
1242
|
+
const itemsPath = this.#path;
|
|
806
1243
|
const outcome = await insertResource(this.#context, {
|
|
807
1244
|
itemsPath,
|
|
808
1245
|
pathForId: mintedId => resourcePath(this.spaceId, this.id, mintedId),
|
|
@@ -901,7 +1338,7 @@ export class Collection {
|
|
|
901
1338
|
return signedPageWalk(this.#context, {
|
|
902
1339
|
firstUrl: toUrl({
|
|
903
1340
|
serverUrl: this.#context.serverUrl,
|
|
904
|
-
path: this.#
|
|
1341
|
+
path: this.#path
|
|
905
1342
|
}),
|
|
906
1343
|
capability: this.#capability
|
|
907
1344
|
});
|
|
@@ -963,10 +1400,11 @@ export class Collection {
|
|
|
963
1400
|
* envelopes (an EDV encrypted document under the v1 `edv` scheme) -- this
|
|
964
1401
|
* method does not decrypt them, unlike `get()`.
|
|
965
1402
|
*
|
|
966
|
-
*
|
|
967
|
-
* through as a page: a 2xx response with no JSON body
|
|
968
|
-
* from an end-of-feed page otherwise),
|
|
969
|
-
*
|
|
1403
|
+
* Malformed responses fail the call with a `WasServerError` instead of
|
|
1404
|
+
* passing through as a page: a 2xx response with no JSON body
|
|
1405
|
+
* (indistinguishable from an end-of-feed page otherwise), a body with no
|
|
1406
|
+
* `documents` array or with a non-object entry in it, and a live entry with
|
|
1407
|
+
* no `data` (the server could not read or parse that resource's body).
|
|
970
1408
|
*
|
|
971
1409
|
* @param [options] {object}
|
|
972
1410
|
* @param [options.checkpoint] {ChangesCheckpoint} resume strictly after this
|
|
@@ -996,7 +1434,21 @@ export class Collection {
|
|
|
996
1434
|
`body (content-type ` +
|
|
997
1435
|
`"${response?.headers.get('content-type') ?? 'unknown'}").`);
|
|
998
1436
|
}
|
|
1437
|
+
// A 2xx body that parsed but carries no `documents` array is the same
|
|
1438
|
+
// class of server fault as a bodiless one: reported as a typed
|
|
1439
|
+
// `WasServerError`, not as a `TypeError` from iterating `undefined`.
|
|
1440
|
+
if (!Array.isArray(page.documents)) {
|
|
1441
|
+
throw new WasServerError(`The changes feed of collection "${this.id}" answered with no ` +
|
|
1442
|
+
'`documents` array.');
|
|
1443
|
+
}
|
|
999
1444
|
for (const doc of page.documents) {
|
|
1445
|
+
// `Array.isArray` accepts `[null]`; a non-object entry is the same class
|
|
1446
|
+
// of server fault, reported as a typed `WasServerError` rather than as a
|
|
1447
|
+
// `TypeError` from reading `_deleted` off it.
|
|
1448
|
+
if (doc === null || typeof doc !== 'object') {
|
|
1449
|
+
throw new WasServerError(`The changes feed of collection "${this.id}" served a non-object ` +
|
|
1450
|
+
'entry in its `documents` array.');
|
|
1451
|
+
}
|
|
1000
1452
|
if (!doc._deleted && doc.data === undefined) {
|
|
1001
1453
|
throw new WasServerError(`The changes feed of collection "${this.id}" served resource ` +
|
|
1002
1454
|
`"${doc.id}" with no body: the server could not read it.`);
|
|
@@ -1057,7 +1509,9 @@ export class Collection {
|
|
|
1057
1509
|
latest.set(doc.id, doc);
|
|
1058
1510
|
}
|
|
1059
1511
|
}
|
|
1060
|
-
|
|
1512
|
+
// A terminal page is one with no checkpoint to resume from, whether the
|
|
1513
|
+
// server spelled that as an explicit `null` or by omitting the member.
|
|
1514
|
+
if (!page.checkpoint) {
|
|
1061
1515
|
return [...latest.values()];
|
|
1062
1516
|
}
|
|
1063
1517
|
const position = `${page.checkpoint.updatedAt}\u0000${page.checkpoint.id}`;
|