@interop/was-client 0.59.1 → 0.61.0

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