was-teaching-server 0.10.0 → 0.11.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 (47) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +23 -11
  3. package/dist/backends/filesystem.d.ts +86 -2
  4. package/dist/backends/filesystem.d.ts.map +1 -1
  5. package/dist/backends/filesystem.js +220 -21
  6. package/dist/backends/filesystem.js.map +1 -1
  7. package/dist/backends/postgres.d.ts +74 -2
  8. package/dist/backends/postgres.d.ts.map +1 -1
  9. package/dist/backends/postgres.js +175 -13
  10. package/dist/backends/postgres.js.map +1 -1
  11. package/dist/backends/postgresSchema.d.ts.map +1 -1
  12. package/dist/backends/postgresSchema.js +69 -82
  13. package/dist/backends/postgresSchema.js.map +1 -1
  14. package/dist/errors.d.ts +13 -9
  15. package/dist/errors.d.ts.map +1 -1
  16. package/dist/errors.js +22 -12
  17. package/dist/errors.js.map +1 -1
  18. package/dist/lib/equalityIndex.d.ts +334 -0
  19. package/dist/lib/equalityIndex.d.ts.map +1 -0
  20. package/dist/lib/equalityIndex.js +552 -0
  21. package/dist/lib/equalityIndex.js.map +1 -0
  22. package/dist/plugin.d.ts.map +1 -1
  23. package/dist/plugin.js +5 -2
  24. package/dist/plugin.js.map +1 -1
  25. package/dist/requests/CollectionRequest.d.ts +22 -5
  26. package/dist/requests/CollectionRequest.d.ts.map +1 -1
  27. package/dist/requests/CollectionRequest.js +192 -11
  28. package/dist/requests/CollectionRequest.js.map +1 -1
  29. package/dist/requests/ResourceRequest.d.ts.map +1 -1
  30. package/dist/requests/ResourceRequest.js +20 -0
  31. package/dist/requests/ResourceRequest.js.map +1 -1
  32. package/dist/requests/SpaceRequest.d.ts +1 -0
  33. package/dist/requests/SpaceRequest.d.ts.map +1 -1
  34. package/dist/requests/SpaceRequest.js +21 -1
  35. package/dist/requests/SpaceRequest.js.map +1 -1
  36. package/dist/types.d.ts +84 -1
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +19 -18
  39. package/src/backends/filesystem.ts +286 -18
  40. package/src/backends/postgres.ts +247 -11
  41. package/src/errors.ts +25 -12
  42. package/src/lib/equalityIndex.ts +769 -0
  43. package/src/plugin.ts +5 -2
  44. package/src/requests/CollectionRequest.ts +221 -12
  45. package/src/requests/ResourceRequest.ts +20 -0
  46. package/src/requests/SpaceRequest.ts +25 -1
  47. package/src/types.ts +97 -0
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Plaintext equality query evaluation -- the `equality` profile of the reserved
3
+ * Collection `POST .../query` endpoint (the `equality-query` backend feature),
4
+ * and its anonymous-cacheable sibling, the GET `filter[attr]=value` filter on
5
+ * the List Collection endpoint. Unlike the `blinded-index` profile (in which the
6
+ * client computes HMAC-blinded index entries because the server cannot see
7
+ * plaintext), here the *server* extracts and indexes the declared attributes
8
+ * from a Resource's stored JSON content and/or its `custom` metadata object at
9
+ * query time -- a plain Resource write with no extra ceremony is immediately
10
+ * queryable.
11
+ *
12
+ * A Collection opts in by declaring `indexes` in its Collection Description (see
13
+ * `lib/collectionDescription.ts` and the `CollectionIndexDeclaration` wire
14
+ * type). Each declared entry names an attribute plus the `source` it is
15
+ * extracted from (`content` = a JSON Resource's stored content, `custom` = any
16
+ * Resource's `custom` metadata object -- the route by which blob Resources
17
+ * become queryable) and MAY carry `unique: true`, claiming per-Collection
18
+ * uniqueness for that attribute's `(name, value)` pairs.
19
+ *
20
+ * Both storage backends answer the profile through `runEqualityQuery` and
21
+ * enforce the uniqueness invariant through `assertNoUniqueEqualityConflict` /
22
+ * `findEqualityUniqueViolation`, so their matching, ordering, pagination, and
23
+ * uniqueness semantics cannot drift (the same pattern as `lib/blindedIndex.ts`).
24
+ * Matching is strict JSON equality: values compare by type and value with no
25
+ * coercion (`"1"` does not match `1`; `true` does not match `"true"`); a
26
+ * multi-valued (array) attribute matches when any of its elements equals the
27
+ * queried value. Pagination reuses WAS's opaque cursor convention
28
+ * (`lib/cursor.ts`), keyset-ordered by ascending `resourceId`.
29
+ */
30
+ import type { CollectionIndexDeclaration } from '../types.js'
31
+ import { decodeCursor, encodeCursor } from './cursor.js'
32
+ import { clampPageSize, DEFAULT_PAGE_SIZE } from './pagination.js'
33
+ import {
34
+ InvalidRequestBodyError,
35
+ UniqueAttributeConflictError
36
+ } from '../errors.js'
37
+
38
+ /**
39
+ * An indexable attribute value: a JSON string, number, or boolean. A declared
40
+ * attribute whose value is one of these (or an array of these) is indexed;
41
+ * anything else (`null`, an object, a nested array) contributes no entry.
42
+ */
43
+ export type EqualityValue = string | number | boolean
44
+
45
+ /**
46
+ * A declared `indexes` entry after normalization: the bare-string shorthand
47
+ * expanded and the optional `source` / `unique` defaulted, so downstream code
48
+ * reads a uniform shape. `source` defaults to `'content'`, `unique` to `false`.
49
+ */
50
+ export interface NormalizedIndexDeclaration {
51
+ name: string
52
+ source: 'content' | 'custom'
53
+ unique: boolean
54
+ }
55
+
56
+ /**
57
+ * A validated `equality` query: exactly one of `equals` / `has`. `equals` is a
58
+ * disjunction (OR across its elements) of conjunctions (AND within one
59
+ * element's `{name: value}` pairs); `has` names attributes that must be present
60
+ * with an indexable value, regardless of value. All names are declared index
61
+ * names; all values are plain JSON string / number / boolean.
62
+ */
63
+ export interface EqualityQuery {
64
+ equals?: Array<Record<string, EqualityValue>>
65
+ has?: string[]
66
+ }
67
+
68
+ /**
69
+ * A page of matching Resources, in ascending `resourceId` order. Each document
70
+ * carries the Resource `id`; `data` (the stored JSON content) is present for a
71
+ * JSON Resource and absent for a blob; `custom` (the Resource's custom metadata
72
+ * object) is present when the Resource has one. `cursor` is present if and only
73
+ * if a further page may follow (`hasMore`); echo it back in the next query body
74
+ * to resume.
75
+ */
76
+ export interface EqualityQueryPage {
77
+ documents: Array<{ id: string; data?: unknown; custom?: unknown }>
78
+ hasMore: boolean
79
+ cursor?: string
80
+ }
81
+
82
+ /**
83
+ * A candidate Resource the backends hand to the evaluator: its id plus the two
84
+ * extraction sources. `content` is the stored (parsed) JSON content, present
85
+ * only for a live JSON Resource (a blob carries none); `custom` is the
86
+ * Resource's custom metadata object, present when it has one (the route by
87
+ * which blobs become queryable).
88
+ */
89
+ export interface EqualityCandidate {
90
+ resourceId: string
91
+ content?: unknown
92
+ custom?: unknown
93
+ }
94
+
95
+ /**
96
+ * True for a plain (non-null, non-array) object -- the only shape a `content`
97
+ * or `custom` source can be extracted from.
98
+ * @param value {unknown}
99
+ * @returns {boolean}
100
+ */
101
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
102
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
103
+ }
104
+
105
+ /**
106
+ * True when a JSON value is directly indexable -- a string, number, or boolean
107
+ * (an array of these is handled element-by-element by the caller).
108
+ * @param value {unknown}
109
+ * @returns {boolean}
110
+ */
111
+ function isIndexableValue(value: unknown): value is EqualityValue {
112
+ return (
113
+ typeof value === 'string' ||
114
+ typeof value === 'number' ||
115
+ typeof value === 'boolean'
116
+ )
117
+ }
118
+
119
+ /**
120
+ * Normalizes a Collection Description's declared `indexes` into the uniform
121
+ * {@link NormalizedIndexDeclaration} shape: a bare string entry becomes
122
+ * `{ name, source: 'content', unique: false }`, and an object entry has its
123
+ * optional `source` / `unique` defaulted. An absent declaration normalizes to
124
+ * an empty array. Assumes the declaration has already passed
125
+ * {@link assertSupportedIndexes} (or was read back from storage), so it does no
126
+ * shape validation of its own.
127
+ *
128
+ * @param options {object}
129
+ * @param [options.indexes] {Array<string | CollectionIndexDeclaration>} the
130
+ * Collection Description's declared `indexes`
131
+ * @returns {NormalizedIndexDeclaration[]}
132
+ */
133
+ export function normalizeIndexes({
134
+ indexes
135
+ }: {
136
+ indexes?: Array<string | CollectionIndexDeclaration>
137
+ }): NormalizedIndexDeclaration[] {
138
+ if (indexes === undefined) {
139
+ return []
140
+ }
141
+ return indexes.map(entry => {
142
+ if (typeof entry === 'string') {
143
+ return { name: entry, source: 'content', unique: false }
144
+ }
145
+ return {
146
+ name: entry.name,
147
+ source: entry.source ?? 'content',
148
+ unique: entry.unique === true
149
+ }
150
+ })
151
+ }
152
+
153
+ /**
154
+ * Validates a client-supplied Collection `indexes` declaration (shape only) and
155
+ * returns the value to persist verbatim, or `undefined` when absent (an update
156
+ * then leaves the stored declaration untouched, like `name` / `backend` /
157
+ * `encryption`). Mirrors the validation style of `assertSupportedEncryption` /
158
+ * `assertValidEncryptionEpochs` in `lib/encryption.ts`, rejecting a malformed
159
+ * declaration with `invalid-request-body` (400) and a precise `pointer`. Rules:
160
+ * - `indexes` MUST be an array (an EMPTY array is allowed -- it clears the
161
+ * declaration).
162
+ * - each entry is either a non-empty string (the `content`-sourced shorthand)
163
+ * or an object with a non-empty string `name`, an optional `source` of
164
+ * exactly `'content'` or `'custom'`, and an optional boolean `unique`.
165
+ * - declared names MUST be unique across the array regardless of source
166
+ * (queries refer to attributes by name alone).
167
+ *
168
+ * @param options {object}
169
+ * @param [options.indexes] {unknown} the request body's `indexes` value
170
+ * @param [options.requestName] {string} request name for the 400 error title
171
+ * @returns {Array<string | CollectionIndexDeclaration> | undefined} the
172
+ * declaration to store, or undefined when absent
173
+ */
174
+ export function assertSupportedIndexes({
175
+ indexes,
176
+ requestName
177
+ }: {
178
+ indexes?: unknown
179
+ requestName?: string
180
+ }): Array<string | CollectionIndexDeclaration> | undefined {
181
+ if (indexes === undefined) {
182
+ return undefined
183
+ }
184
+ if (!Array.isArray(indexes)) {
185
+ throw new InvalidRequestBodyError({
186
+ requestName,
187
+ detail: 'Collection "indexes" must be an array.',
188
+ pointer: '#/indexes'
189
+ })
190
+ }
191
+ const names = new Set<string>()
192
+ indexes.forEach((entry, entryIndex) => {
193
+ const pointer = `#/indexes/${entryIndex}`
194
+ let name: string
195
+ if (typeof entry === 'string') {
196
+ if (entry.length === 0) {
197
+ throw new InvalidRequestBodyError({
198
+ requestName,
199
+ detail:
200
+ 'Each "indexes" string entry must be a non-empty attribute name.',
201
+ pointer
202
+ })
203
+ }
204
+ name = entry
205
+ } else if (isPlainObject(entry)) {
206
+ const { name: entryName, source, unique } = entry
207
+ if (typeof entryName !== 'string' || entryName.length === 0) {
208
+ throw new InvalidRequestBodyError({
209
+ requestName,
210
+ detail: 'Each "indexes" entry must have a non-empty string "name".',
211
+ pointer: `${pointer}/name`
212
+ })
213
+ }
214
+ if (source !== undefined && source !== 'content' && source !== 'custom') {
215
+ throw new InvalidRequestBodyError({
216
+ requestName,
217
+ detail:
218
+ 'An "indexes" entry "source" must be exactly "content" or "custom".',
219
+ pointer: `${pointer}/source`
220
+ })
221
+ }
222
+ if (unique !== undefined && typeof unique !== 'boolean') {
223
+ throw new InvalidRequestBodyError({
224
+ requestName,
225
+ detail: 'An "indexes" entry "unique" must be a boolean.',
226
+ pointer: `${pointer}/unique`
227
+ })
228
+ }
229
+ name = entryName
230
+ } else {
231
+ throw new InvalidRequestBodyError({
232
+ requestName,
233
+ detail:
234
+ 'Each "indexes" entry must be a non-empty string or an object with a "name".',
235
+ pointer
236
+ })
237
+ }
238
+ if (names.has(name)) {
239
+ throw new InvalidRequestBodyError({
240
+ requestName,
241
+ detail: `Duplicate "indexes" attribute name "${name}".`,
242
+ pointer: `${pointer}/name`
243
+ })
244
+ }
245
+ names.add(name)
246
+ })
247
+ // Persist verbatim (bare strings and objects alike survive the round-trip);
248
+ // normalization for extraction/matching happens at query/write time.
249
+ return indexes as Array<string | CollectionIndexDeclaration>
250
+ }
251
+
252
+ /**
253
+ * Validates and normalizes the `equality` profile's query body fields
254
+ * (everything besides `profile`), throwing `invalid-request-body` (400) on a
255
+ * malformed query. Exactly one of `equals` / `has` is required. Every attribute
256
+ * name appearing in `equals` or `has` MUST be among the declared index names
257
+ * (`indexes`, already normalized) -- a query naming an undeclared attribute is
258
+ * rejected fail-closed (a typo or missing declaration surfaces loudly rather
259
+ * than as a silently-empty page). `limit` is coerced leniently as in
260
+ * `parseBlindedIndexQueryBody` (a non-numeric or `< 1` value falls back to the
261
+ * default; the backend clamps an oversized one), and the opaque `cursor` is
262
+ * validated by the backend's cursor decode (`invalid-cursor` 400).
263
+ *
264
+ * @param options {object}
265
+ * @param options.body {object} the parsed query POST body
266
+ * @param options.indexes {NormalizedIndexDeclaration[]} the Collection's
267
+ * normalized declared indexes (the allowed attribute names)
268
+ * @param [options.requestName] {string}
269
+ * @returns {{ query: EqualityQuery, count: boolean, limit?: number, cursor?: string }}
270
+ */
271
+ export function parseEqualityQueryBody({
272
+ body,
273
+ indexes,
274
+ requestName
275
+ }: {
276
+ body: {
277
+ equals?: unknown
278
+ has?: unknown
279
+ count?: unknown
280
+ limit?: unknown
281
+ cursor?: unknown
282
+ }
283
+ indexes: NormalizedIndexDeclaration[]
284
+ requestName?: string
285
+ }): {
286
+ query: EqualityQuery
287
+ count: boolean
288
+ limit?: number
289
+ cursor?: string
290
+ } {
291
+ const { equals, has, count, limit, cursor } = body
292
+ const declared = new Set(indexes.map(entry => entry.name))
293
+
294
+ // Exactly one of `equals` / `has`: supplying neither, or both, is a 400.
295
+ if ((equals === undefined) === (has === undefined)) {
296
+ throw new InvalidRequestBodyError({
297
+ requestName,
298
+ detail: 'An equality query requires exactly one of "equals" or "has".'
299
+ })
300
+ }
301
+
302
+ const query: EqualityQuery = {}
303
+ if (equals !== undefined) {
304
+ if (!Array.isArray(equals) || equals.length === 0) {
305
+ throw new InvalidRequestBodyError({
306
+ requestName,
307
+ detail: '"equals" must be a non-empty array of objects.',
308
+ pointer: '#/equals'
309
+ })
310
+ }
311
+ equals.forEach((element, elementIndex) => {
312
+ const pointer = `#/equals/${elementIndex}`
313
+ if (!isPlainObject(element)) {
314
+ throw new InvalidRequestBodyError({
315
+ requestName,
316
+ detail:
317
+ '"equals" elements must be objects mapping declared attribute names to string, number, or boolean values.',
318
+ pointer
319
+ })
320
+ }
321
+ for (const [name, value] of Object.entries(element)) {
322
+ if (!isIndexableValue(value)) {
323
+ throw new InvalidRequestBodyError({
324
+ requestName,
325
+ detail:
326
+ 'An "equals" attribute value must be a string, number, or boolean.',
327
+ pointer: `${pointer}/${name}`
328
+ })
329
+ }
330
+ if (!declared.has(name)) {
331
+ throw new InvalidRequestBodyError({
332
+ requestName,
333
+ detail: `Attribute "${name}" is not declared in the Collection's indexes.`,
334
+ pointer: `${pointer}/${name}`
335
+ })
336
+ }
337
+ }
338
+ })
339
+ query.equals = equals as Array<Record<string, EqualityValue>>
340
+ }
341
+ if (has !== undefined) {
342
+ if (
343
+ !Array.isArray(has) ||
344
+ has.length === 0 ||
345
+ has.some(name => typeof name !== 'string')
346
+ ) {
347
+ throw new InvalidRequestBodyError({
348
+ requestName,
349
+ detail: '"has" must be a non-empty array of declared attribute names.',
350
+ pointer: '#/has'
351
+ })
352
+ }
353
+ for (const name of has as string[]) {
354
+ if (!declared.has(name)) {
355
+ throw new InvalidRequestBodyError({
356
+ requestName,
357
+ detail: `Attribute "${name}" is not declared in the Collection's indexes.`,
358
+ pointer: '#/has'
359
+ })
360
+ }
361
+ }
362
+ query.has = has as string[]
363
+ }
364
+
365
+ if (count !== undefined && typeof count !== 'boolean') {
366
+ throw new InvalidRequestBodyError({
367
+ requestName,
368
+ detail: '"count" must be a boolean.',
369
+ pointer: '#/count'
370
+ })
371
+ }
372
+
373
+ if (cursor !== undefined && typeof cursor !== 'string') {
374
+ throw new InvalidRequestBodyError({
375
+ requestName,
376
+ detail: '"cursor" must be a string.',
377
+ pointer: '#/cursor'
378
+ })
379
+ }
380
+
381
+ // Lenient `limit` coercion, same as the `changes` / `blinded-index` profiles:
382
+ // a non-numeric or `< 1` value is ignored so the backend applies its own
383
+ // default/clamp.
384
+ const parsedLimit = Number(limit)
385
+ const resolvedLimit =
386
+ Number.isFinite(parsedLimit) && parsedLimit >= 1 ? parsedLimit : undefined
387
+
388
+ return {
389
+ query,
390
+ count: count === true,
391
+ ...(resolvedLimit !== undefined && { limit: resolvedLimit }),
392
+ ...(cursor !== undefined && { cursor })
393
+ }
394
+ }
395
+
396
+ /**
397
+ * Extracts a Resource's indexable attribute values per its Collection's
398
+ * declared indexes, keyed by declared name. For each declared entry the
399
+ * top-level member is read from its `source` -- `content` from the stored JSON
400
+ * content, `custom` from the custom metadata object -- each only when that
401
+ * source is a plain object. A member is indexable when it is a string, number,
402
+ * or boolean, or an array of those (an array indexes the Resource under EACH
403
+ * element; a `null`, object, or nested-array element contributes nothing);
404
+ * anything else, an absent member, or a non-object source contributes no entry
405
+ * for that attribute. Strict typing, no coercion: `1`, `"1"`, and `true` stay
406
+ * distinct.
407
+ *
408
+ * @param options {object}
409
+ * @param options.indexes {NormalizedIndexDeclaration[]} the normalized indexes
410
+ * @param [options.content] {unknown} the stored JSON content (a JSON Resource)
411
+ * @param [options.custom] {unknown} the custom metadata object
412
+ * @returns {Map<string, EqualityValue[]>} declared name to its indexable values
413
+ */
414
+ export function extractEqualityAttributes({
415
+ indexes,
416
+ content,
417
+ custom
418
+ }: {
419
+ indexes: NormalizedIndexDeclaration[]
420
+ content?: unknown
421
+ custom?: unknown
422
+ }): Map<string, EqualityValue[]> {
423
+ const extracted = new Map<string, EqualityValue[]>()
424
+ for (const declaration of indexes) {
425
+ const source = declaration.source === 'custom' ? custom : content
426
+ if (!isPlainObject(source)) {
427
+ continue
428
+ }
429
+ const member = source[declaration.name]
430
+ const values: EqualityValue[] = []
431
+ if (Array.isArray(member)) {
432
+ for (const element of member) {
433
+ if (isIndexableValue(element)) {
434
+ values.push(element)
435
+ }
436
+ }
437
+ } else if (isIndexableValue(member)) {
438
+ values.push(member)
439
+ }
440
+ if (values.length > 0) {
441
+ // A name may be declared once (unique across the array), so no merge is
442
+ // needed; the last declaration for a name wins if that ever changes.
443
+ extracted.set(declaration.name, values)
444
+ }
445
+ }
446
+ return extracted
447
+ }
448
+
449
+ /**
450
+ * True when a candidate Resource matches an equality query. `equals` matches
451
+ * when SOME element has ALL its `{name: value}` pairs present among the
452
+ * candidate's extracted attributes (an empty element matches nothing); `has`
453
+ * matches when EVERY named attribute is present with an indexable value. Strict
454
+ * value equality (`===`), so `1`, `"1"`, and `true` never cross-match; a
455
+ * multi-valued attribute matches when any of its values equals the queried one.
456
+ *
457
+ * @param options {object}
458
+ * @param options.candidate {EqualityCandidate}
459
+ * @param options.query {EqualityQuery}
460
+ * @param options.indexes {NormalizedIndexDeclaration[]}
461
+ * @returns {boolean}
462
+ */
463
+ export function matchesEqualityQuery({
464
+ candidate,
465
+ query,
466
+ indexes
467
+ }: {
468
+ candidate: EqualityCandidate
469
+ query: EqualityQuery
470
+ indexes: NormalizedIndexDeclaration[]
471
+ }): boolean {
472
+ const attributes = extractEqualityAttributes({
473
+ indexes,
474
+ content: candidate.content,
475
+ custom: candidate.custom
476
+ })
477
+ if (query.equals !== undefined) {
478
+ return query.equals.some(element => {
479
+ const pairs = Object.entries(element)
480
+ return (
481
+ pairs.length > 0 &&
482
+ pairs.every(([name, value]) =>
483
+ (attributes.get(name) ?? []).some(held => held === value)
484
+ )
485
+ )
486
+ })
487
+ }
488
+ return (query.has ?? []).every(name => attributes.has(name))
489
+ }
490
+
491
+ /**
492
+ * Evaluates an equality query over a Collection's candidate Resources and
493
+ * shapes the result: a bare `{count}` for a count query, else a page of
494
+ * matching documents in ascending `resourceId` (code-unit) order with the
495
+ * standard opaque-cursor pagination (`limit` clamped to `[1, MAX_PAGE_SIZE]`,
496
+ * default `DEFAULT_PAGE_SIZE`; `cursor` resumes strictly after its anchor id,
497
+ * so paging stays correct if the anchor was deleted between pages). A malformed
498
+ * `cursor` rejects with `invalid-cursor` (400).
499
+ *
500
+ * Backends call this with every live Resource of the Collection (JSON Resources
501
+ * carrying `content`, blobs carrying only `custom`) -- an O(n) full scan,
502
+ * deliberate for these teaching backends (a materialized backend would answer
503
+ * from a JSONB expression index or attribute side-table).
504
+ *
505
+ * @param options {object}
506
+ * @param options.candidates {EqualityCandidate[]}
507
+ * @param options.query {EqualityQuery}
508
+ * @param options.indexes {NormalizedIndexDeclaration[]}
509
+ * @param [options.count] {boolean} return only the match count
510
+ * @param [options.limit] {number} requested page size
511
+ * @param [options.cursor] {string} opaque cursor from a prior page
512
+ * @returns {{ count: number } | EqualityQueryPage}
513
+ */
514
+ export function runEqualityQuery({
515
+ candidates,
516
+ query,
517
+ indexes,
518
+ count,
519
+ limit,
520
+ cursor
521
+ }: {
522
+ candidates: EqualityCandidate[]
523
+ query: EqualityQuery
524
+ indexes: NormalizedIndexDeclaration[]
525
+ count?: boolean
526
+ limit?: number
527
+ cursor?: string
528
+ }): { count: number } | EqualityQueryPage {
529
+ const matches = candidates
530
+ .filter(candidate => matchesEqualityQuery({ candidate, query, indexes }))
531
+ // Ascending `resourceId` in code-unit order -- the SAME ordering the cursor
532
+ // seek (`resourceId > after`) uses, so the keyset is stable.
533
+ .sort((left, right) =>
534
+ left.resourceId < right.resourceId
535
+ ? -1
536
+ : left.resourceId > right.resourceId
537
+ ? 1
538
+ : 0
539
+ )
540
+
541
+ if (count === true) {
542
+ return { count: matches.length }
543
+ }
544
+
545
+ // Seek strictly past the cursor's anchor id.
546
+ let startIndex = 0
547
+ if (cursor !== undefined) {
548
+ const { after } = decodeCursor(cursor)
549
+ const found = matches.findIndex(({ resourceId }) => resourceId > after)
550
+ startIndex = found === -1 ? matches.length : found
551
+ }
552
+
553
+ const pageSize =
554
+ limit === undefined ? DEFAULT_PAGE_SIZE : clampPageSize(limit)
555
+
556
+ // Take `pageSize + 1` to detect a further page without a second pass.
557
+ const window = matches.slice(startIndex, startIndex + pageSize + 1)
558
+ const hasMore = window.length > pageSize
559
+ const page = hasMore ? window.slice(0, pageSize) : window
560
+
561
+ return {
562
+ documents: page.map(candidate => ({
563
+ id: candidate.resourceId,
564
+ // `data` present iff the Resource has JSON content (absent for a blob);
565
+ // `custom` present when the Resource carries a custom metadata object.
566
+ ...(candidate.content !== undefined && { data: candidate.content }),
567
+ ...(isPlainObject(candidate.custom) && { custom: candidate.custom })
568
+ })),
569
+ hasMore,
570
+ ...(hasMore && {
571
+ cursor: encodeCursor(page[page.length - 1]!.resourceId)
572
+ })
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Collects a Resource's `unique: true` attribute claims as `(name, value)`
578
+ * pairs -- the claims the per-Collection uniqueness invariant protects. Only
579
+ * declared entries carrying `unique: true` are considered; each indexable value
580
+ * (an array value claims EACH element) becomes one claim. A Resource whose
581
+ * unique attribute is absent or non-indexable makes no claim.
582
+ *
583
+ * @param options {object}
584
+ * @param options.indexes {NormalizedIndexDeclaration[]}
585
+ * @param [options.content] {unknown} the stored JSON content
586
+ * @param [options.custom] {unknown} the custom metadata object
587
+ * @returns {Array<{ name: string, value: EqualityValue }>}
588
+ */
589
+ export function collectUniqueEqualityTerms({
590
+ indexes,
591
+ content,
592
+ custom
593
+ }: {
594
+ indexes: NormalizedIndexDeclaration[]
595
+ content?: unknown
596
+ custom?: unknown
597
+ }): Array<{ name: string; value: EqualityValue }> {
598
+ const uniqueDeclarations = indexes.filter(declaration => declaration.unique)
599
+ if (uniqueDeclarations.length === 0) {
600
+ return []
601
+ }
602
+ const extracted = extractEqualityAttributes({
603
+ indexes: uniqueDeclarations,
604
+ content,
605
+ custom
606
+ })
607
+ const terms: Array<{ name: string; value: EqualityValue }> = []
608
+ for (const [name, values] of extracted) {
609
+ for (const value of values) {
610
+ terms.push({ name, value })
611
+ }
612
+ }
613
+ return terms
614
+ }
615
+
616
+ /**
617
+ * Enforces the per-Collection unique-attribute invariant on a write: a
618
+ * `unique: true` declared attribute's `(name, value)` claim may be held by at
619
+ * most one live Resource in the Collection. Because the declaration is
620
+ * Collection-level, a conflict does NOT require the other side to opt in -- ANY
621
+ * other live Resource whose extracted value for that attribute equals the claim
622
+ * conflicts. `candidates` MUST already exclude the Resource being written, so a
623
+ * Resource re-asserting its own existing value never self-conflicts. Throws
624
+ * `UniqueAttributeConflictError` (409) on a conflict; a Resource with no unique
625
+ * claim never throws.
626
+ *
627
+ * The caller is responsible for making the check-and-write atomic (the
628
+ * filesystem backend serializes claim-carrying writes per Collection; the
629
+ * Postgres backend takes a per-Collection advisory lock in the write
630
+ * transaction), the same pattern as `assertNoUniqueBlindedConflict`.
631
+ *
632
+ * @param options {object}
633
+ * @param options.indexes {NormalizedIndexDeclaration[]}
634
+ * @param [options.content] {unknown} the incoming Resource's content
635
+ * @param [options.custom] {unknown} the incoming Resource's custom metadata
636
+ * @param options.candidates {EqualityCandidate[]} the Collection's OTHER live
637
+ * Resources (the one being written already excluded)
638
+ * @returns {void}
639
+ */
640
+ export function assertNoUniqueEqualityConflict({
641
+ indexes,
642
+ content,
643
+ custom,
644
+ candidates
645
+ }: {
646
+ indexes: NormalizedIndexDeclaration[]
647
+ content?: unknown
648
+ custom?: unknown
649
+ candidates: EqualityCandidate[]
650
+ }): void {
651
+ const incoming = collectUniqueEqualityTerms({ indexes, content, custom })
652
+ if (incoming.length === 0) {
653
+ return
654
+ }
655
+ // Key the claims unambiguously (a name or a string value may itself contain
656
+ // any delimiter, and the value's JSON type must stay distinct -- `1` vs `"1"`
657
+ // -- so a joined string could collide).
658
+ const claimed = new Set(
659
+ incoming.map(term => JSON.stringify([term.name, term.value]))
660
+ )
661
+ for (const candidate of candidates) {
662
+ const held = collectUniqueEqualityTerms({
663
+ indexes,
664
+ content: candidate.content,
665
+ custom: candidate.custom
666
+ })
667
+ for (const term of held) {
668
+ if (claimed.has(JSON.stringify([term.name, term.value]))) {
669
+ throw new UniqueAttributeConflictError({ variant: 'equality' })
670
+ }
671
+ }
672
+ }
673
+ }
674
+
675
+ /**
676
+ * Scans a Collection's candidates for an existing violation of a unique claim:
677
+ * two DIFFERENT Resources holding the same `(name, value)` under the
678
+ * `unique`-declared entries. Returns the first such `{ name, value }`, or
679
+ * `undefined` when none exists. Used when a Collection update ADDS a unique
680
+ * claim over already-stored Resources (the declare-time conflict scan); the
681
+ * request layer rejects a found violation with `id-conflict` (409).
682
+ *
683
+ * @param options {object}
684
+ * @param options.indexes {NormalizedIndexDeclaration[]}
685
+ * @param options.candidates {EqualityCandidate[]} the Collection's live
686
+ * Resources
687
+ * @returns {{ name: string, value: EqualityValue } | undefined}
688
+ */
689
+ export function findEqualityUniqueViolation({
690
+ indexes,
691
+ candidates
692
+ }: {
693
+ indexes: NormalizedIndexDeclaration[]
694
+ candidates: EqualityCandidate[]
695
+ }): { name: string; value: EqualityValue } | undefined {
696
+ const uniqueDeclarations = indexes.filter(declaration => declaration.unique)
697
+ if (uniqueDeclarations.length === 0) {
698
+ return undefined
699
+ }
700
+ // Map each seen claim key to the resourceId holding it; a second, different
701
+ // holder is the violation.
702
+ const holders = new Map<string, string>()
703
+ for (const candidate of candidates) {
704
+ const held = collectUniqueEqualityTerms({
705
+ indexes: uniqueDeclarations,
706
+ content: candidate.content,
707
+ custom: candidate.custom
708
+ })
709
+ for (const term of held) {
710
+ const key = JSON.stringify([term.name, term.value])
711
+ const existing = holders.get(key)
712
+ if (existing !== undefined && existing !== candidate.resourceId) {
713
+ return { name: term.name, value: term.value }
714
+ }
715
+ holders.set(key, candidate.resourceId)
716
+ }
717
+ }
718
+ return undefined
719
+ }
720
+
721
+ /**
722
+ * Collects the GET List Collection `filter[<name>]=<value>` equality filters
723
+ * from a Fastify parsed querystring object -- the anonymous-cacheable entry
724
+ * point over the same equality machinery as the POST profile. Fastify's default
725
+ * querystring parser leaves bracket keys literal and percent-decodes them, so a
726
+ * `filter[parentId]=first-post` query arrives as the key `filter[parentId]`.
727
+ * Returns a `{ <name>: <value> }` map, or `undefined` when no `filter[...]` key
728
+ * is present (the ordinary listing path). A repeated same attribute (the parser
729
+ * yields an array value) is rejected with `invalid-request-body` (400) -- v1
730
+ * admits only a single value per attribute. Attribute names are NOT validated
731
+ * against the Collection's declarations here; the handler does that once it has
732
+ * fetched the description.
733
+ *
734
+ * @param options {object}
735
+ * @param options.query {Record<string, string | string[] | undefined>} the
736
+ * Fastify-parsed querystring object
737
+ * @param [options.requestName] {string}
738
+ * @returns {Record<string, string> | undefined}
739
+ */
740
+ export function parseListFilter({
741
+ query,
742
+ requestName
743
+ }: {
744
+ query: Record<string, string | string[] | undefined>
745
+ requestName?: string
746
+ }): Record<string, string> | undefined {
747
+ const filters: Record<string, string> = {}
748
+ let found = false
749
+ for (const [key, value] of Object.entries(query)) {
750
+ const match = /^filter\[(.+)\]$/.exec(key)
751
+ if (!match) {
752
+ continue
753
+ }
754
+ const name = match[1]!
755
+ found = true
756
+ if (Array.isArray(value)) {
757
+ throw new InvalidRequestBodyError({
758
+ requestName,
759
+ detail: `Filter attribute "${name}" may not be repeated.`,
760
+ pointer: `#/filter/${name}`
761
+ })
762
+ }
763
+ if (value === undefined) {
764
+ continue
765
+ }
766
+ filters[name] = value
767
+ }
768
+ return found ? filters : undefined
769
+ }