@interop/was-client 0.60.0 → 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 +135 -94
- package/dist/Collection.d.ts.map +1 -1
- package/dist/Collection.js +612 -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/codec.d.ts +3 -3
- package/dist/edv/EdvCodec.js +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/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.js +2 -2
- package/dist/edv/recipients.js.map +1 -1
- package/dist/edv/refresh.d.ts +1 -1
- package/dist/edv/refresh.js +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/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/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/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/provisioning.d.ts +5 -5
- package/dist/sync/provisioning.d.ts.map +1 -1
- package/dist/sync/provisioning.js +2 -2
- 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,65 +170,339 @@ 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;
|
|
129
253
|
}
|
|
130
254
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
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).
|
|
146
421
|
*
|
|
147
422
|
* @param desc {CollectionWritableFields} the fields to merge; `encryption`
|
|
148
423
|
* declares the client-side encryption descriptor, which is set-once on the
|
|
149
424
|
* server (it may be added to a Collection that lacks one, but
|
|
150
425
|
* changing/clearing an existing descriptor is rejected -- `ConflictError`,
|
|
151
426
|
* `encryption-immutable`)
|
|
152
|
-
* @param [desc.force] {boolean} proceed even when the current
|
|
427
|
+
* @param [desc.force] {boolean} proceed even when the current object
|
|
153
428
|
* is unreadable and `backend`/`encryption` are omitted (see above)
|
|
154
|
-
* @param [desc.current] {
|
|
155
|
-
*
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* @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>}
|
|
164
438
|
*/
|
|
165
439
|
async configure(desc) {
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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) &&
|
|
176
506
|
!desc.force) {
|
|
177
507
|
throw unreadableDescriptionError({
|
|
178
508
|
operation: `configure collection "${this.id}"`,
|
|
@@ -183,124 +513,160 @@ export class Collection {
|
|
|
183
513
|
'creating a new collection.'
|
|
184
514
|
});
|
|
185
515
|
}
|
|
186
|
-
// Merge every current field forward (mirror `Space.configure`): a
|
|
187
|
-
// replace-semantics server drops anything omitted from the PUT body, so
|
|
188
|
-
// `configure({ name })` on an EDV collection would otherwise wipe its
|
|
189
|
-
// `backend` or trip `encryption-immutable` by clearing the descriptor.
|
|
190
|
-
const name = desc.name ?? current?.name;
|
|
191
|
-
const backend = desc.backend ?? current?.backend;
|
|
192
|
-
const encryption = desc.encryption ?? current?.encryption;
|
|
193
|
-
// The app-attribution fields merge forward on the same terms, so a
|
|
194
|
-
// `configure({ name })` does not erase a stored `generator`. They are
|
|
195
|
-
// deliberately NOT part of the unreadable-description guard above: unlike
|
|
196
|
-
// `backend` and `encryption`, they are freely re-writable attribution
|
|
197
|
-
// (dropping one is cosmetic, not a data-placement change or an
|
|
198
|
-
// `encryption-immutable` trip), and admitting them there would let a
|
|
199
|
-
// `configure({ generator })` sail past the guard and blindly drop the two
|
|
200
|
-
// fields it exists to protect.
|
|
201
|
-
const generator = desc.generator ?? current?.generator;
|
|
202
|
-
const generatorOrigin = desc.generatorOrigin ?? current?.generatorOrigin;
|
|
203
|
-
const fields = collectionWritableFields({
|
|
204
|
-
name,
|
|
205
|
-
backend,
|
|
206
|
-
encryption,
|
|
207
|
-
generator,
|
|
208
|
-
generatorOrigin
|
|
209
|
-
});
|
|
210
|
-
await send(this.#context, {
|
|
211
|
-
path: this.#path,
|
|
212
|
-
method: 'PUT',
|
|
213
|
-
capability: this.#capability,
|
|
214
|
-
json: { id: this.id, ...fields }
|
|
215
|
-
});
|
|
216
|
-
// Adding the encryption descriptor flips this collection from plaintext to
|
|
217
|
-
// encrypted server-side. Drop any codec memoized from the prior (plaintext)
|
|
218
|
-
// descriptor so the next read/write re-resolves it -- otherwise a `put`
|
|
219
|
-
// would reuse the cached identity codec and write server-visible plaintext
|
|
220
|
-
// into the now-encrypted collection. Child resource handles share this
|
|
221
|
-
// codec via their thunk, so resetting here propagates to them too.
|
|
222
|
-
if (desc.encryption) {
|
|
223
|
-
this.#codecHolder.reset();
|
|
224
|
-
}
|
|
225
|
-
return {
|
|
226
|
-
id: this.id,
|
|
227
|
-
type: current?.type ?? ['Collection'],
|
|
228
|
-
...fields
|
|
229
|
-
};
|
|
230
516
|
}
|
|
231
517
|
/**
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
* the opaque validator to pass to {@link replaceDescription}'s `ifMatch` for a
|
|
235
|
-
* lost-update-safe (compare-and-swap) description write. Returns `null` if the
|
|
236
|
-
* collection is missing or not visible to you (404 conflation caveat); `etag`
|
|
237
|
-
* is absent against a server that does not version the description.
|
|
238
|
-
*
|
|
239
|
-
* @returns {Promise<{ description: CollectionDescription; etag?: string } | null>}
|
|
240
|
-
*/
|
|
241
|
-
async describeWithEtag() {
|
|
242
|
-
const read = await readDataWithEtag(this.#context, {
|
|
243
|
-
path: this.#path,
|
|
244
|
-
capability: this.#capability
|
|
245
|
-
});
|
|
246
|
-
return read === null ? null : { description: read.data, etag: read.etag };
|
|
247
|
-
}
|
|
248
|
-
/**
|
|
249
|
-
* Writes (replaces) the Collection Description, optionally under a
|
|
250
|
-
* 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
|
|
251
520
|
* it a compare-and-swap so a concurrent writer cannot be silently clobbered,
|
|
252
521
|
* and `ifNoneMatch: true` makes it a guarded create that proceeds only while
|
|
253
522
|
* no Collection exists under this id. A failed precondition surfaces as
|
|
254
|
-
* `PreconditionFailedError` (412). Sends the writable
|
|
255
|
-
* body; omit a
|
|
256
|
-
* 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.
|
|
527
|
+
*
|
|
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.
|
|
257
539
|
*
|
|
258
|
-
* This is the generic
|
|
540
|
+
* This is the generic compare-and-swap primitive the key-epoch recipient
|
|
259
541
|
* operations build on (add/remove a reader is a CAS of the `encryption`
|
|
260
542
|
* descriptor); it is not epoch-specific.
|
|
261
543
|
*
|
|
262
544
|
* @param description {CollectionWritableFields}
|
|
263
545
|
* @param options {object}
|
|
264
546
|
* @param [options.ifMatch] {string} the prior `ETag`; the write applies only
|
|
265
|
-
* if the
|
|
547
|
+
* if the object is unchanged
|
|
266
548
|
* @param [options.ifNoneMatch] {boolean} write only if the Collection does
|
|
267
549
|
* not exist yet
|
|
268
|
-
* @returns {Promise<{ description:
|
|
550
|
+
* @returns {Promise<{ description: CollectionMetadata; etag?: string }>}
|
|
269
551
|
*/
|
|
270
552
|
async replaceDescription(description, options = {}) {
|
|
271
553
|
const fields = collectionWritableFields(description);
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
})
|
|
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
|
|
283
563
|
});
|
|
284
|
-
|
|
285
|
-
// recipient operations CAS this field) or flip the collection from
|
|
286
|
-
// plaintext to encrypted. Drop any memoized codec -- bound at construction
|
|
287
|
-
// to the prior descriptor's write key/epoch -- so the next read/write
|
|
288
|
-
// re-resolves it under the new descriptor; otherwise a `put` on the same
|
|
289
|
-
// handle would keep encrypting under the stale epoch, whose key a
|
|
290
|
-
// just-removed reader still holds. Child resource handles share this codec
|
|
291
|
-
// via their thunk, so resetting here propagates to them too.
|
|
292
|
-
if (description.encryption !== undefined) {
|
|
293
|
-
this.#codecHolder.reset();
|
|
294
|
-
}
|
|
564
|
+
this.#resetCodecIfDeclared(description.encryption);
|
|
295
565
|
return {
|
|
296
|
-
description: {
|
|
566
|
+
description: metadata ?? {
|
|
297
567
|
id: this.id,
|
|
298
568
|
type: ['Collection'],
|
|
299
569
|
...fields
|
|
300
570
|
},
|
|
301
|
-
etag
|
|
571
|
+
...(etag !== undefined && { etag })
|
|
302
572
|
};
|
|
303
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
|
+
}
|
|
304
670
|
/**
|
|
305
671
|
* Deletes the whole collection. Idempotent. To delete a single resource, use
|
|
306
672
|
* `collection.resource(id).delete()`.
|
|
@@ -319,28 +685,30 @@ export class Collection {
|
|
|
319
685
|
return collectionMeta(this.spaceId, this.id);
|
|
320
686
|
}
|
|
321
687
|
/**
|
|
322
|
-
* Reads the Collection
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
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`.
|
|
327
693
|
*
|
|
328
694
|
* On an encrypted collection the stored `custom` is an opaque envelope; this
|
|
329
695
|
* decodes it (decrypts, via the codec) so a caller always sees plaintext
|
|
330
696
|
* `{ name, tags }`. A collection with no user metadata reports `custom` as
|
|
331
|
-
* `{}`.
|
|
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.
|
|
332
700
|
*
|
|
333
701
|
* Against a backend with the `conditional-writes` feature the result also
|
|
334
|
-
* carries the
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
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.
|
|
339
707
|
*
|
|
340
708
|
* @returns {Promise<(CollectionMetadata & { etag?: string }) | null>}
|
|
341
709
|
*/
|
|
342
710
|
async meta() {
|
|
343
|
-
// Resolving the codec on a blinded-index collection reads
|
|
711
|
+
// Resolving the codec on a blinded-index collection reads `meta` itself
|
|
344
712
|
// (for the index schema). When this call is the one that started that
|
|
345
713
|
// resolution, its snapshot is this read's answer -- take it rather than
|
|
346
714
|
// GETting and decrypting the same document twice. Every other caller gets
|
|
@@ -355,28 +723,43 @@ export class Collection {
|
|
|
355
723
|
codec: Promise.resolve(codec),
|
|
356
724
|
subject: `collection "${this.id}"`,
|
|
357
725
|
slot: { kind: 'collection' },
|
|
358
|
-
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
|
+
}
|
|
359
733
|
});
|
|
360
734
|
}
|
|
361
735
|
/**
|
|
362
|
-
* Replaces the Collection's user-writable
|
|
363
|
-
* replacement
|
|
364
|
-
* `custom` clears them all.
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
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.
|
|
368
742
|
*
|
|
369
743
|
* On an encrypted collection `custom` is encrypted into an opaque envelope by
|
|
370
744
|
* the codec before it is sent, so `name` / `tags` are never stored as
|
|
371
745
|
* server-visible plaintext -- transparently, the same call works on plaintext
|
|
372
|
-
* 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.
|
|
373
748
|
*
|
|
374
|
-
* Conditional
|
|
375
|
-
*
|
|
376
|
-
* update-if-unchanged,
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
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`.
|
|
380
763
|
*
|
|
381
764
|
* @param meta {object}
|
|
382
765
|
* @param [meta.custom] {ResourceMetadataCustomInput} the user-writable
|
|
@@ -384,44 +767,79 @@ export class Collection {
|
|
|
384
767
|
* Collection-level `custom` also carries the persisted `indexSchema`)
|
|
385
768
|
* while `name` / `tags` themselves stay checked at their stored types
|
|
386
769
|
* @param options {object}
|
|
387
|
-
* @param [options.ifMatch] {string} update only if the
|
|
388
|
-
* @param [options.ifNoneMatch] {boolean}
|
|
389
|
-
*
|
|
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
|
|
390
774
|
*/
|
|
391
775
|
async setMeta(meta = {}, options = {}) {
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
codec: this.#codec(),
|
|
776
|
+
const codec = await this.#codecForWrite(options.ifNoneMatch === true);
|
|
777
|
+
const { custom, epoch } = await codec.encodeMeta({
|
|
395
778
|
custom: meta.custom ?? {},
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
|
400
797
|
ifMatch: options.ifMatch,
|
|
401
|
-
ifNoneMatch: options.ifNoneMatch
|
|
402
|
-
capability: this.#capability
|
|
798
|
+
ifNoneMatch: options.ifNoneMatch
|
|
403
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();
|
|
404
821
|
}
|
|
405
822
|
/**
|
|
406
|
-
* Sets the Collection's
|
|
407
|
-
* existing `tags`. Convenience over `setMeta()`. The write is pinned to
|
|
408
|
-
* `etag` the `meta()` read returned (when the backend supports
|
|
409
|
-
* `conditional-writes`), so a concurrent
|
|
410
|
-
*
|
|
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
|
|
411
829
|
* full-replacement write.
|
|
412
830
|
*
|
|
413
831
|
* On an encrypted collection this is the collection's client-encrypted name
|
|
414
832
|
* surface: the codec seals it into the `custom` envelope, and by convention
|
|
415
|
-
* the plaintext
|
|
833
|
+
* the plaintext top-level `name` is left unpopulated. On a plaintext
|
|
416
834
|
* collection the two are separate labels -- space-level listings surface the
|
|
417
|
-
*
|
|
418
|
-
*
|
|
835
|
+
* top-level `name` (set via `configure({ name })`), while this one is inside
|
|
836
|
+
* `custom`.
|
|
419
837
|
*
|
|
420
838
|
* @param name {string}
|
|
421
839
|
* @returns {Promise<void>}
|
|
422
840
|
*/
|
|
423
841
|
async setName(name) {
|
|
424
|
-
return patchCustom(this, { name });
|
|
842
|
+
return patchCustom(this, { name }, 'Collection name update');
|
|
425
843
|
}
|
|
426
844
|
/**
|
|
427
845
|
* Sets the Collection's `tags`, preserving any existing `name`. Convenience
|
|
@@ -432,13 +850,13 @@ export class Collection {
|
|
|
432
850
|
* @returns {Promise<void>}
|
|
433
851
|
*/
|
|
434
852
|
async setTags(tags) {
|
|
435
|
-
return patchCustom(this, { tags });
|
|
853
|
+
return patchCustom(this, { tags }, 'Collection tags update');
|
|
436
854
|
}
|
|
437
855
|
get #logPath() {
|
|
438
856
|
return collectionLog(this.spaceId, this.id);
|
|
439
857
|
}
|
|
440
858
|
/**
|
|
441
|
-
* 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`
|
|
442
860
|
* sub-resource {@link getHistoryLog} reads. A log-governed descriptor's
|
|
443
861
|
* `history.resource` names this URL; a verifying reader compares the two
|
|
444
862
|
* before opening the log.
|
|
@@ -821,7 +1239,7 @@ export class Collection {
|
|
|
821
1239
|
*/
|
|
822
1240
|
async add(data, options = {}) {
|
|
823
1241
|
const codec = await this.#codec();
|
|
824
|
-
const itemsPath = this.#
|
|
1242
|
+
const itemsPath = this.#path;
|
|
825
1243
|
const outcome = await insertResource(this.#context, {
|
|
826
1244
|
itemsPath,
|
|
827
1245
|
pathForId: mintedId => resourcePath(this.spaceId, this.id, mintedId),
|
|
@@ -920,7 +1338,7 @@ export class Collection {
|
|
|
920
1338
|
return signedPageWalk(this.#context, {
|
|
921
1339
|
firstUrl: toUrl({
|
|
922
1340
|
serverUrl: this.#context.serverUrl,
|
|
923
|
-
path: this.#
|
|
1341
|
+
path: this.#path
|
|
924
1342
|
}),
|
|
925
1343
|
capability: this.#capability
|
|
926
1344
|
});
|