@interop/was-client 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +124 -48
  2. package/dist/Collection.d.ts +135 -94
  3. package/dist/Collection.d.ts.map +1 -1
  4. package/dist/Collection.js +612 -194
  5. package/dist/Collection.js.map +1 -1
  6. package/dist/Resource.d.ts +1 -1
  7. package/dist/Resource.d.ts.map +1 -1
  8. package/dist/Resource.js +0 -3
  9. package/dist/Resource.js.map +1 -1
  10. package/dist/Space.d.ts +17 -16
  11. package/dist/Space.d.ts.map +1 -1
  12. package/dist/Space.js +41 -24
  13. package/dist/Space.js.map +1 -1
  14. package/dist/WasClient.d.ts +51 -6
  15. package/dist/WasClient.d.ts.map +1 -1
  16. package/dist/WasClient.js +84 -14
  17. package/dist/WasClient.js.map +1 -1
  18. package/dist/codec.d.ts +3 -3
  19. package/dist/edv/EdvCodec.js +1 -1
  20. package/dist/edv/descriptorStore.d.ts +16 -14
  21. package/dist/edv/descriptorStore.d.ts.map +1 -1
  22. package/dist/edv/descriptorStore.js +17 -14
  23. package/dist/edv/descriptorStore.js.map +1 -1
  24. package/dist/edv/index.d.ts +1 -1
  25. package/dist/edv/index.js +1 -1
  26. package/dist/edv/logGovernedDescriptorStore.d.ts +2 -2
  27. package/dist/edv/logGovernedDescriptorStore.d.ts.map +1 -1
  28. package/dist/edv/logGovernedDescriptorStore.js +10 -9
  29. package/dist/edv/logGovernedDescriptorStore.js.map +1 -1
  30. package/dist/edv/recipients.d.ts +1 -1
  31. package/dist/edv/recipients.js +2 -2
  32. package/dist/edv/recipients.js.map +1 -1
  33. package/dist/edv/refresh.d.ts +1 -1
  34. package/dist/edv/refresh.js +1 -1
  35. package/dist/errors.d.ts +14 -1
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/errors.js +16 -3
  38. package/dist/errors.js.map +1 -1
  39. package/dist/index.d.ts +2 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +1 -1
  42. package/dist/index.js.map +1 -1
  43. package/dist/internal/codec.d.ts +22 -1
  44. package/dist/internal/codec.d.ts.map +1 -1
  45. package/dist/internal/codec.js +117 -46
  46. package/dist/internal/codec.js.map +1 -1
  47. package/dist/internal/describe.d.ts +98 -24
  48. package/dist/internal/describe.d.ts.map +1 -1
  49. package/dist/internal/describe.js +125 -18
  50. package/dist/internal/describe.js.map +1 -1
  51. package/dist/internal/meta.d.ts +32 -17
  52. package/dist/internal/meta.d.ts.map +1 -1
  53. package/dist/internal/meta.js +64 -35
  54. package/dist/internal/meta.js.map +1 -1
  55. package/dist/internal/paths.d.ts +33 -21
  56. package/dist/internal/paths.d.ts.map +1 -1
  57. package/dist/internal/paths.js +93 -46
  58. package/dist/internal/paths.js.map +1 -1
  59. package/dist/internal/request.d.ts +22 -6
  60. package/dist/internal/request.d.ts.map +1 -1
  61. package/dist/internal/request.js +31 -13
  62. package/dist/internal/request.js.map +1 -1
  63. package/dist/internal/reserved.d.ts +14 -0
  64. package/dist/internal/reserved.d.ts.map +1 -1
  65. package/dist/internal/reserved.js +23 -4
  66. package/dist/internal/reserved.js.map +1 -1
  67. package/dist/internal/service.d.ts +61 -0
  68. package/dist/internal/service.d.ts.map +1 -0
  69. package/dist/internal/service.js +259 -0
  70. package/dist/internal/service.js.map +1 -0
  71. package/dist/paths.d.ts +8 -5
  72. package/dist/paths.d.ts.map +1 -1
  73. package/dist/paths.js +8 -5
  74. package/dist/paths.js.map +1 -1
  75. package/dist/sync/provisioning.d.ts +5 -5
  76. package/dist/sync/provisioning.d.ts.map +1 -1
  77. package/dist/sync/provisioning.js +2 -2
  78. package/dist/sync/provisioning.js.map +1 -1
  79. package/dist/types.d.ts +43 -10
  80. package/dist/types.d.ts.map +1 -1
  81. package/package.json +2 -2
@@ -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, collectionItems, collectionPolicy, collectionLinkset, collectionBackend, collectionQuota, collectionQuery, collectionMeta, collectionLog, resourcePath, toUrl } from './internal/paths.js';
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, readDataWithEtag } from './internal/request.js';
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, describeCollection, unreadableDescriptionError } from './internal/describe.js';
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, writeMeta, patchCustom } from './internal/meta.js';
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 Description. Returns `null` if the collection is
118
- * missing or not visible to you (WAS returns 404 for both not-found and
119
- * unauthorized).
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<CollectionDescription | null>}
183
+ * @returns {Promise<(CollectionMetadata & { etag?: string }) | null>}
122
184
  */
123
185
  async describe() {
124
- return describeCollection(this.#context, {
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
- * Creates or updates the collection by id (upsert). Merges the given fields
132
- * over the current description.
133
- *
134
- * The merge needs a readable current description to be lost-update-safe, and
135
- * `describe()` cannot distinguish "absent" from "unreadable" (WAS masks
136
- * unauthorized reads as 404). When it returns `null` and either `backend`
137
- * or `encryption` is left out, this fails closed rather than sending a PUT
138
- * body that would silently drop an existing collection's `backend` (a
139
- * data-placement change) or trip `encryption-immutable` by clearing its
140
- * descriptor on a replace-semantics server. Supplying only one of the two
141
- * does not cover the other: with nothing readable to merge from, the
142
- * omitted field is dropped either way. Pass `force: true` to proceed
143
- * anyway
144
- * -- e.g. when creating a new collection through a handle (or use
145
- * `space.createCollection()`, which does not merge).
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 description
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] {CollectionDescription | null} the current
155
- * description, when the caller has already read it -- the merge and the
156
- * fail-closed check then run against this instead of a second `describe()`
157
- * round trip. `null` means the caller read it and found the collection
158
- * absent or unreadable, which is a supplied answer; omitting the member
159
- * entirely is what asks for the read. Supplying a description this
160
- * handle's own writes have since superseded would merge stale fields
161
- * forward -- dropping a `backend` or tripping `encryption-immutable` --
162
- * so pass only a read the caller itself made and has not written over
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
- const current = desc.current !== undefined ? desc.current : await this.describe();
167
- // Each protected field is checked on its own terms. Supplying one does
168
- // not make the other safe to omit: with no readable current description
169
- // there is nothing to merge the omitted one forward from, so
170
- // `configure({ backend })` on an EDV collection would send a body with no
171
- // `encryption` -- clearing the descriptor, or tripping
172
- // `encryption-immutable` on a replace-semantics server -- which is the
173
- // harm this guard exists to prevent.
174
- if (current === null &&
175
- (desc.backend === undefined || desc.encryption === undefined) &&
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
- * Reads the Collection Description together with its `ETag` validator (the
233
- * server's `conditional-writes` / description-version support). The `ETag` is
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 fields as the full
255
- * body; omit a field to drop it (replace semantics), so callers doing CAS
256
- * pass every field forward. Returns the new `ETag` and the fields written.
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 description-CAS primitive the key-epoch recipient
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 description is unchanged
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: CollectionDescription; etag?: string }>}
550
+ * @returns {Promise<{ description: CollectionMetadata; etag?: string }>}
269
551
  */
270
552
  async replaceDescription(description, options = {}) {
271
553
  const fields = collectionWritableFields(description);
272
- const response = await send(this.#context, {
273
- path: this.#path,
274
- method: 'PUT',
275
- capability: this.#capability,
276
- json: { id: this.id, ...fields },
277
- headers: writeHeaders({
278
- precondition: {
279
- ifMatch: options.ifMatch,
280
- ifNoneMatch: options.ifNoneMatch
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
- // Writing the `encryption` descriptor can rotate the key epoch (the
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: readEtag(response)
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's metadata object (server-managed timestamps,
323
- * `createdBy` and the encrypted-`custom` key `epoch`, plus the user-writable
324
- * `custom` object). Returns `null` if the collection is missing or not
325
- * visible to you (404 conflation caveat). A server without Collection
326
- * metadata support surfaces its 501 as `NotImplementedError`.
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 metadata's current `etag` (the `/meta` `metaVersion`
335
- * validator) -- pass it as `setMeta(meta, { ifMatch })` for a
336
- * lost-update-safe metadata update. That validator is independent of the
337
- * Collection Description's ETag ({@link describeWithEtag}) and of every
338
- * Resource's versions: writing one never bumps the other.
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 `/meta` itself
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 metadata (`custom`). This is a full
363
- * replacement: any property omitted from `custom` is cleared, and an omitted
364
- * `custom` clears them all. Does not create the collection -- a `PUT` to the
365
- * metadata of a nonexistent collection throws `NotFoundError`. Servers
366
- * without Collection metadata support surface their 501 as
367
- * `NotImplementedError`.
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 metadata writes (the backend's `conditional-writes` feature):
375
- * pass `ifMatch` (the `etag` from a prior `meta()`) for an
376
- * update-if-unchanged, or `ifNoneMatch: true` for a
377
- * write-only-if-no-metadata. A failed precondition throws
378
- * `PreconditionFailedError` (412). The `/meta` ETag (`metaVersion`) is
379
- * independent of the Collection Description's ETag. Returns the new `etag`.
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 `/meta` ETag matches
388
- * @param [options.ifNoneMatch] {boolean} write only if no metadata is set
389
- * @returns {Promise<{ etag?: string }>} the metadata's new ETag
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
- return writeMeta(this.#context, {
393
- metaPath: this.#metaPath,
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
- // The Collection metadata stamp describes the `custom` envelope itself,
397
- // so it travels as a top-level member of this PUT body.
398
- sendEpoch: true,
399
- slot: { kind: 'collection' },
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 metadata-level human-readable `name`, preserving any
407
- * existing `tags`. Convenience over `setMeta()`. The write is pinned to the
408
- * `etag` the `meta()` read returned (when the backend supports
409
- * `conditional-writes`), so a concurrent metadata write surfaces as
410
- * `PreconditionFailedError` instead of being silently erased by this
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 Description `name` is left unpopulated. On a 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
- * Description's `name` (set via `configure({ name })`), while this one is
418
- * metadata-level.
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 `/meta/log`
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.#itemsPath;
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.#itemsPath
1341
+ path: this.#path
924
1342
  }),
925
1343
  capability: this.#capability
926
1344
  });