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.
- package/CHANGELOG.md +68 -0
- package/README.md +23 -11
- package/dist/backends/filesystem.d.ts +86 -2
- package/dist/backends/filesystem.d.ts.map +1 -1
- package/dist/backends/filesystem.js +220 -21
- package/dist/backends/filesystem.js.map +1 -1
- package/dist/backends/postgres.d.ts +74 -2
- package/dist/backends/postgres.d.ts.map +1 -1
- package/dist/backends/postgres.js +175 -13
- package/dist/backends/postgres.js.map +1 -1
- package/dist/backends/postgresSchema.d.ts.map +1 -1
- package/dist/backends/postgresSchema.js +69 -82
- package/dist/backends/postgresSchema.js.map +1 -1
- package/dist/errors.d.ts +13 -9
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +22 -12
- package/dist/errors.js.map +1 -1
- package/dist/lib/equalityIndex.d.ts +334 -0
- package/dist/lib/equalityIndex.d.ts.map +1 -0
- package/dist/lib/equalityIndex.js +552 -0
- package/dist/lib/equalityIndex.js.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/plugin.js +5 -2
- package/dist/plugin.js.map +1 -1
- package/dist/requests/CollectionRequest.d.ts +22 -5
- package/dist/requests/CollectionRequest.d.ts.map +1 -1
- package/dist/requests/CollectionRequest.js +192 -11
- package/dist/requests/CollectionRequest.js.map +1 -1
- package/dist/requests/ResourceRequest.d.ts.map +1 -1
- package/dist/requests/ResourceRequest.js +20 -0
- package/dist/requests/ResourceRequest.js.map +1 -1
- package/dist/requests/SpaceRequest.d.ts +1 -0
- package/dist/requests/SpaceRequest.d.ts.map +1 -1
- package/dist/requests/SpaceRequest.js +21 -1
- package/dist/requests/SpaceRequest.js.map +1 -1
- package/dist/types.d.ts +84 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +19 -18
- package/src/backends/filesystem.ts +286 -18
- package/src/backends/postgres.ts +247 -11
- package/src/errors.ts +25 -12
- package/src/lib/equalityIndex.ts +769 -0
- package/src/plugin.ts +5 -2
- package/src/requests/CollectionRequest.ts +221 -12
- package/src/requests/ResourceRequest.ts +20 -0
- package/src/requests/SpaceRequest.ts +25 -1
- 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
|
+
}
|