@happyvertical/smrt-content 0.43.4 → 0.43.6
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/AGENTS.md +9 -86
- package/agents/content-list.md +869 -0
- package/dist/content-query.d.ts +310 -0
- package/dist/content-query.d.ts.map +1 -0
- package/dist/contents.d.ts +22 -0
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +22 -2
- package/dist/smrt-knowledge.json +38 -5
- package/dist/svelte/components/ContentList.svelte +941 -30
- package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +98 -1
- package/dist/svelte/content-list-controller.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.js +290 -19
- package/dist/svelte/content-list-query.d.ts +498 -0
- package/dist/svelte/content-list-query.d.ts.map +1 -0
- package/dist/svelte/content-list-query.js +1294 -0
- package/dist/svelte/content-list-saved-views.d.ts +172 -0
- package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
- package/dist/svelte/content-list-saved-views.js +298 -0
- package/dist/svelte/content-list-url-state.d.ts +211 -0
- package/dist/svelte/content-list-url-state.d.ts.map +1 -0
- package/dist/svelte/content-list-url-state.js +856 -0
- package/dist/svelte/i18n.contribution.d.ts +24 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +26 -0
- package/dist/svelte/index.d.ts +5 -1
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +8 -1
- package/package.json +16 -15
|
@@ -0,0 +1,1294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-backed ContentList queries (#2452).
|
|
3
|
+
*
|
|
4
|
+
* This module is the seam between the ContentList adapter's *view state* and
|
|
5
|
+
* the canonical bounded data-query protocol (#2444) that
|
|
6
|
+
* `POST /api/v1/contents/query` speaks. It owns three things:
|
|
7
|
+
*
|
|
8
|
+
* 1. {@link contentListViewStateToDataQueryRequest} — the translator. It turns
|
|
9
|
+
* search, declarative filters, sorting, and paging into a
|
|
10
|
+
* `DataQueryRequest`, dropping (never throwing on) anything the protocol
|
|
11
|
+
* cannot express and reporting what it dropped.
|
|
12
|
+
* 2. {@link createContentListQueryTransport} — a `fetch` transport for that
|
|
13
|
+
* route, which unwraps the generated `{ action, result }` envelope and turns
|
|
14
|
+
* the `{ error: { … } }` envelope into a thrown, coded error.
|
|
15
|
+
* 3. {@link ContentListQueryBinding} — the structural seam a caller binds a
|
|
16
|
+
* remote query through.
|
|
17
|
+
*
|
|
18
|
+
* ## Three id namespaces
|
|
19
|
+
*
|
|
20
|
+
* A content list value crosses three vocabularies, and they do not agree:
|
|
21
|
+
*
|
|
22
|
+
* | Namespace | Example | Owner |
|
|
23
|
+
* |---|---|---|
|
|
24
|
+
* | adapter column id | `updated` | `content-list-controller.ts` |
|
|
25
|
+
* | `ContentData` client field | `updatedAt` | `mock-smrt-client.ts` |
|
|
26
|
+
* | server data-query field id | `updated_at` | the registered `Content` model |
|
|
27
|
+
*
|
|
28
|
+
* {@link CONTENT_LIST_QUERY_FIELDS} is the single explicit column → server
|
|
29
|
+
* field map, and `content-list-query.test.ts` asserts it against the field ids
|
|
30
|
+
* and declared operators of the real `buildContentQuerySchema()`. A model
|
|
31
|
+
* rename therefore breaks a test rather than production.
|
|
32
|
+
*
|
|
33
|
+
* A column with no server field is not queryable server-side and is dropped
|
|
34
|
+
* from the request: `site` is derived in the browser from `url`/`source`, so
|
|
35
|
+
* there is nothing for the server to filter or sort on.
|
|
36
|
+
*
|
|
37
|
+
* ## Deliberate structural mirror
|
|
38
|
+
*
|
|
39
|
+
* The request/result types here mirror `@happyvertical/smrt-types` (and
|
|
40
|
+
* `@happyvertical/smrt-web`'s browser copy) structurally rather than by import.
|
|
41
|
+
* `@happyvertical/smrt-content` must not pull the browser data runtime into its
|
|
42
|
+
* Svelte barrel: that runtime is code-split precisely so it never loads on
|
|
43
|
+
* public content pages, and a barrel-level import would defeat that. The mirror
|
|
44
|
+
* is exact, so a `RemoteQueryBinding` from
|
|
45
|
+
* `@happyvertical/smrt-svelte/web` satisfies {@link ContentListQueryBinding}
|
|
46
|
+
* without a cast.
|
|
47
|
+
*
|
|
48
|
+
* ## Documented limits
|
|
49
|
+
*
|
|
50
|
+
* - **Offset paging only.** The content query schema declares
|
|
51
|
+
* `supports.cursorPagination: false`.
|
|
52
|
+
* - **`body` is not queryable.** It is a document, not list data.
|
|
53
|
+
* - **`metadata` path filtering is unavailable.** JSON columns have no portable
|
|
54
|
+
* predicate, so the schema declares no filter operators for them.
|
|
55
|
+
* - **No ETag/version slot.** The canonical envelope carries a
|
|
56
|
+
* `queryFingerprint` plus `freshness.asOf` instead.
|
|
57
|
+
* - **Filters compare exactly.** The server compares the stored value. The
|
|
58
|
+
* adapter folds case only for the token columns (`type`, `status`, `state`),
|
|
59
|
+
* because free text has to reach the server as the operator typed it.
|
|
60
|
+
* - **NULL semantics are aligned end to end.** `ne`/`notIn` union `IS NULL`
|
|
61
|
+
* server-side, as `in` already did — unless the caller listed `null`, which
|
|
62
|
+
* inverts the meaning, and a NEGATED ordered comparison unions so that a
|
|
63
|
+
* predicate and its negation stay complements. The ordered comparisons,
|
|
64
|
+
* `isNull`/`isNotNull`, and the `type`/`title` display fallbacks were aligned
|
|
65
|
+
* on the local side instead, by consulting the original `ContentData` rather
|
|
66
|
+
* than the flattened display text. See `agents/content-list.md`.
|
|
67
|
+
*/
|
|
68
|
+
import { CONTENT_LIST_MAX_PAGE_SIZE, } from './content-list-url-state.js';
|
|
69
|
+
/**
|
|
70
|
+
* The single explicit adapter-column → server-field map.
|
|
71
|
+
*
|
|
72
|
+
* `null` means the column has no server field at all and is therefore neither
|
|
73
|
+
* filterable nor sortable server-side. Only `site` is in that state: it is
|
|
74
|
+
* derived from `url`/`source` in the browser, so the server has nothing to
|
|
75
|
+
* order or compare.
|
|
76
|
+
*/
|
|
77
|
+
export const CONTENT_LIST_QUERY_FIELDS = Object.freeze({
|
|
78
|
+
type: { field: 'type', type: 'string' },
|
|
79
|
+
title: { field: 'title', type: 'string' },
|
|
80
|
+
author: { field: 'author', type: 'string' },
|
|
81
|
+
status: { field: 'status', type: 'string' },
|
|
82
|
+
state: { field: 'state', type: 'string' },
|
|
83
|
+
publish: { field: 'publish_date', type: 'datetime' },
|
|
84
|
+
// The hazard: `updated` (column) → `updatedAt` (ContentData) → `updated_at`.
|
|
85
|
+
updated: { field: 'updated_at', type: 'datetime' },
|
|
86
|
+
site: null,
|
|
87
|
+
description: { field: 'description', type: 'string' },
|
|
88
|
+
});
|
|
89
|
+
/** Row identity, matching `CONTENT_QUERY_IDENTITY_FIELD`. */
|
|
90
|
+
export const CONTENT_LIST_QUERY_IDENTITY_FIELD = 'id';
|
|
91
|
+
/**
|
|
92
|
+
* The schema's `defaultSort`, mirrored so the translator can send it rather
|
|
93
|
+
* than let the normalizer inject it. Cross-asserted against the real schema in
|
|
94
|
+
* `content-list-query.test.ts`.
|
|
95
|
+
*/
|
|
96
|
+
export const CONTENT_LIST_QUERY_DEFAULT_SORT = Object.freeze([
|
|
97
|
+
Object.freeze({ field: 'updated_at', direction: 'desc' }),
|
|
98
|
+
Object.freeze({
|
|
99
|
+
field: CONTENT_LIST_QUERY_IDENTITY_FIELD,
|
|
100
|
+
direction: 'asc',
|
|
101
|
+
}),
|
|
102
|
+
]);
|
|
103
|
+
/**
|
|
104
|
+
* The server fields the list actually renders. Kept deliberately narrow: the
|
|
105
|
+
* envelope has a byte budget, and a projection is the cheapest place to keep a
|
|
106
|
+
* page inside it.
|
|
107
|
+
*/
|
|
108
|
+
export const CONTENT_LIST_QUERY_PROJECTION = Object.freeze([
|
|
109
|
+
CONTENT_LIST_QUERY_IDENTITY_FIELD,
|
|
110
|
+
'type',
|
|
111
|
+
'title',
|
|
112
|
+
'description',
|
|
113
|
+
'author',
|
|
114
|
+
'status',
|
|
115
|
+
'state',
|
|
116
|
+
'publish_date',
|
|
117
|
+
'updated_at',
|
|
118
|
+
'created_at',
|
|
119
|
+
'url',
|
|
120
|
+
'source',
|
|
121
|
+
'fileKey',
|
|
122
|
+
'thumbnailAssetId',
|
|
123
|
+
]);
|
|
124
|
+
/**
|
|
125
|
+
* Every server field the content query schema declares as projectable.
|
|
126
|
+
*
|
|
127
|
+
* A host may override `query.request.projection` with any of these. Naming
|
|
128
|
+
* anything else — a typo, a field that was removed, `body`, `tenantId` — is
|
|
129
|
+
* refused by the normalizer with `DATA_QUERY_PROJECTION_NOT_ALLOWED`, which
|
|
130
|
+
* fails the whole list rather than degrading it, so the translator drops the
|
|
131
|
+
* entry instead. Cross-asserted against `buildContentQuerySchema()`.
|
|
132
|
+
*/
|
|
133
|
+
export const CONTENT_LIST_QUERY_PROJECTABLE_FIELDS = Object.freeze([
|
|
134
|
+
'author',
|
|
135
|
+
'bodyFormat',
|
|
136
|
+
'category',
|
|
137
|
+
'context',
|
|
138
|
+
'created_at',
|
|
139
|
+
'description',
|
|
140
|
+
'fileKey',
|
|
141
|
+
'id',
|
|
142
|
+
'language',
|
|
143
|
+
'metadata',
|
|
144
|
+
'name',
|
|
145
|
+
'original_url',
|
|
146
|
+
'publish_date',
|
|
147
|
+
'slug',
|
|
148
|
+
'source',
|
|
149
|
+
'state',
|
|
150
|
+
'status',
|
|
151
|
+
'tags',
|
|
152
|
+
'thumbnailAssetId',
|
|
153
|
+
'title',
|
|
154
|
+
'type',
|
|
155
|
+
'updated_at',
|
|
156
|
+
'url',
|
|
157
|
+
'variant',
|
|
158
|
+
]);
|
|
159
|
+
/** `stringValue()`'s default ceiling, applied to every field id in a request. */
|
|
160
|
+
export const CONTENT_LIST_QUERY_MAX_FIELD_ID_LENGTH = 256;
|
|
161
|
+
/**
|
|
162
|
+
* Fields free-text search reaches, mirroring the adapter's searchable columns
|
|
163
|
+
* (`title`, `author`, and the hidden search-only `description`).
|
|
164
|
+
*/
|
|
165
|
+
export const CONTENT_LIST_QUERY_SEARCH_FIELDS = Object.freeze(['title', 'description', 'author']);
|
|
166
|
+
/**
|
|
167
|
+
* Page size used when the view state carries none. Matches the server's
|
|
168
|
+
* `CONTENT_QUERY_DEFAULT_PAGE_LIMIT`, so a client default and a server default
|
|
169
|
+
* never disagree about how many rows a page holds.
|
|
170
|
+
*/
|
|
171
|
+
export const CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE = 50;
|
|
172
|
+
/** Mirrors `MAX_DATA_QUERY_OFFSET`; a larger offset is refused outright. */
|
|
173
|
+
export const CONTENT_LIST_QUERY_MAX_OFFSET = 1_000_000;
|
|
174
|
+
/**
|
|
175
|
+
* The request normalizer's input caps, mirrored from
|
|
176
|
+
* `@happyvertical/smrt-core`. Exceeding any of them fails the *entire* request
|
|
177
|
+
* with a 400 — which turns the translator's "drop, never fail" contract into an
|
|
178
|
+
* error panel — so each one is enforced client-side and reported as a drop.
|
|
179
|
+
*
|
|
180
|
+
* - `MAX_DATA_QUERY_IN_VALUES` — entries in one `in`/`notIn` list.
|
|
181
|
+
* - `MAX_DATA_QUERY_FILTERS` — total filter nodes, counting every `all`/`any`
|
|
182
|
+
* container as a node exactly the way `normalizeFilter` does.
|
|
183
|
+
* - the scalar cap — characters in any one filter value, wildcards included.
|
|
184
|
+
*/
|
|
185
|
+
export const CONTENT_LIST_QUERY_MAX_IN_VALUES = 100;
|
|
186
|
+
export const CONTENT_LIST_QUERY_MAX_FILTER_NODES = 50;
|
|
187
|
+
export const CONTENT_LIST_QUERY_MAX_VALUE_LENGTH = 4_096;
|
|
188
|
+
/** `MAX_DATA_QUERY_REQUEST_BYTES` — the whole serialized request. */
|
|
189
|
+
export const CONTENT_LIST_QUERY_MAX_REQUEST_BYTES = 100_000;
|
|
190
|
+
/** `MAX_DATA_QUERY_FILTERS` also caps the projection array. */
|
|
191
|
+
export const CONTENT_LIST_QUERY_MAX_PROJECTION_FIELDS = 50;
|
|
192
|
+
/** `stringValue(object.requestId, …, 128)` — non-empty, at most 128 chars. */
|
|
193
|
+
export const CONTENT_LIST_QUERY_MAX_REQUEST_ID_LENGTH = 128;
|
|
194
|
+
/**
|
|
195
|
+
* `MAX_CONTENT_QUERY_OR_BRANCHES` — the executor's ceiling on the disjunctive
|
|
196
|
+
* normal form it lowers a filter into. Null-safe `ne`/`notIn` cost two branches
|
|
197
|
+
* each and an `all` multiplies, so this one is reachable from a crafted link.
|
|
198
|
+
*/
|
|
199
|
+
export const CONTENT_LIST_QUERY_MAX_OR_BRANCHES = 128;
|
|
200
|
+
/**
|
|
201
|
+
* The request normalizer's *validity* rules, as distinct from its numeric caps.
|
|
202
|
+
*
|
|
203
|
+
* A datetime value is checked against this exact shape (`normalizedInstant`),
|
|
204
|
+
* so `Date#toISOString()` is not automatically acceptable: a year outside the
|
|
205
|
+
* four-digit range serializes as `+275760-09-13T00:00:00.000Z` or
|
|
206
|
+
* `-000001-01-01T00:00:00.000Z`, both of which the server refuses.
|
|
207
|
+
*/
|
|
208
|
+
const RFC_3339_INSTANT = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):([0-5]\d):([0-5]\d)(?:\.\d{1,9})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
209
|
+
/**
|
|
210
|
+
* Operators the server declares per field type, mirroring `filterOperatorsFor`
|
|
211
|
+
* in `content-query.ts`. Sending an operator outside this set fails the *whole*
|
|
212
|
+
* request with a 400, so the translator drops it here instead.
|
|
213
|
+
*/
|
|
214
|
+
const SERVER_OPERATORS = {
|
|
215
|
+
string: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', 'like'],
|
|
216
|
+
number: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn'],
|
|
217
|
+
datetime: ['eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn'],
|
|
218
|
+
boolean: ['eq', 'ne', 'in', 'notIn'],
|
|
219
|
+
json: [],
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* How a `DataTable` operator is expressed over the wire.
|
|
223
|
+
*
|
|
224
|
+
* `pattern` marks the three operators that become a `like` with wildcards the
|
|
225
|
+
* client adds itself — the server does not add them.
|
|
226
|
+
*
|
|
227
|
+
* `nullValue` marks the two null predicates. `eq`/`ne` are null-aware end to
|
|
228
|
+
* end: the protocol's scalar type admits `null`, the request normalizer rejects
|
|
229
|
+
* a null value only for `gt`/`gte`/`lt`/`lte`/`like`, and the collection query
|
|
230
|
+
* builder special-cases it — `{ field: null }` becomes `IS NULL` and
|
|
231
|
+
* `{ 'field !=': null }` becomes `IS NOT NULL`, not a comparison against NULL.
|
|
232
|
+
*
|
|
233
|
+
* `notContains` is the only DataTable operator with no sound server expression:
|
|
234
|
+
* it would be `not(like)`, and the executor refuses to negate a `like`
|
|
235
|
+
* (`DATA_QUERY_UNSUPPORTED`), which would fail the whole query.
|
|
236
|
+
*/
|
|
237
|
+
const OPERATOR_MAP = {
|
|
238
|
+
equals: { operator: 'eq' },
|
|
239
|
+
notEquals: { operator: 'ne' },
|
|
240
|
+
contains: { operator: 'like', pattern: 'contains' },
|
|
241
|
+
startsWith: { operator: 'like', pattern: 'prefix' },
|
|
242
|
+
endsWith: { operator: 'like', pattern: 'suffix' },
|
|
243
|
+
in: { operator: 'in' },
|
|
244
|
+
notIn: { operator: 'notIn' },
|
|
245
|
+
gt: { operator: 'gt' },
|
|
246
|
+
gte: { operator: 'gte' },
|
|
247
|
+
lt: { operator: 'lt' },
|
|
248
|
+
lte: { operator: 'lte' },
|
|
249
|
+
isNull: { operator: 'eq', nullValue: true },
|
|
250
|
+
isNotNull: { operator: 'ne', nullValue: true },
|
|
251
|
+
};
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
// Translator
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
function defaultRequestId() {
|
|
256
|
+
try {
|
|
257
|
+
const cryptoRef = globalThis
|
|
258
|
+
.crypto;
|
|
259
|
+
if (typeof cryptoRef?.randomUUID === 'function') {
|
|
260
|
+
return `content-list-${cryptoRef.randomUUID()}`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// Fall through to the non-cryptographic id below.
|
|
265
|
+
}
|
|
266
|
+
return `content-list-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Escapes the SQL `LIKE` metacharacters in a user-supplied value.
|
|
270
|
+
*
|
|
271
|
+
* Search and the three pattern operators build their own wildcards, so a `%` or
|
|
272
|
+
* `_` the operator typed must be matched literally. The escape character is a
|
|
273
|
+
* backslash, which PostgreSQL and DuckDB honour by default for `LIKE`.
|
|
274
|
+
*
|
|
275
|
+
* KNOWN GAP: SQLite has no default `LIKE` escape character and the collection
|
|
276
|
+
* query builder emits no `ESCAPE` clause, so on SQLite an escaped `%` matches
|
|
277
|
+
* the two literal characters rather than a literal `%`. That fails closed (an
|
|
278
|
+
* empty result) instead of open (every row), which is the safer of the two; a
|
|
279
|
+
* portable fix needs an `ESCAPE` clause at the collection/SQL boundary.
|
|
280
|
+
*/
|
|
281
|
+
const LIKE_METACHARACTER = /[\\%_]/;
|
|
282
|
+
export function escapeContentListQueryLikeValue(value) {
|
|
283
|
+
return value.replace(/[\\%_]/g, (character) => `\\${character}`);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Trims a value so that escaping it and adding wildcards still fits the
|
|
287
|
+
* protocol's scalar cap.
|
|
288
|
+
*
|
|
289
|
+
* Escaping can double a string's length, so a 4096-character search would
|
|
290
|
+
* otherwise become an 8194-character `like` value and 400 the whole request.
|
|
291
|
+
*
|
|
292
|
+
* The unit of measure is the one the server uses. `dataQueryScalar` in
|
|
293
|
+
* `@happyvertical/smrt-core` tests `value.length`, which counts UTF-16 code
|
|
294
|
+
* units — so an astral character (an emoji, most CJK extension blocks) costs
|
|
295
|
+
* TWO. Iteration is still by code point so a surrogate pair is never cut in
|
|
296
|
+
* half, but the *cost* of each code point is its `.length`, not one. Charging
|
|
297
|
+
* one per code point made a search of 4093 ASCII characters plus one emoji
|
|
298
|
+
* measure 4094 here and 4097 at the server: a hard 400 and a whole-list error
|
|
299
|
+
* panel, which is exactly what this bound exists to prevent.
|
|
300
|
+
*/
|
|
301
|
+
function boundLikeSource(text, budget, keep = 'leading') {
|
|
302
|
+
// A shortened pattern is only acceptable because it matches a SUPERSET of
|
|
303
|
+
// what was asked for, and which end survives is what decides that. A prefix
|
|
304
|
+
// pattern (`abc%`) and a contains pattern (`%abc%`) keep their leading
|
|
305
|
+
// characters: anything starting with (or containing) `abcdef` also starts
|
|
306
|
+
// with (or contains) `abc`. A SUFFIX pattern (`%abc`) is the mirror image —
|
|
307
|
+
// keeping the leading characters names a different ending entirely, so the
|
|
308
|
+
// row the operator asked for stops matching while unrelated rows start.
|
|
309
|
+
// Keeping the trailing characters restores the superset property.
|
|
310
|
+
const source = keep === 'trailing' ? [...text].reverse() : [...text];
|
|
311
|
+
const kept = [];
|
|
312
|
+
let used = 0;
|
|
313
|
+
let truncated = false;
|
|
314
|
+
for (const character of source) {
|
|
315
|
+
// `character.length` is 1 for a BMP code point and 2 for a surrogate pair;
|
|
316
|
+
// an escaped metacharacter (always BMP) adds its backslash.
|
|
317
|
+
const cost = character.length + (LIKE_METACHARACTER.test(character) ? 1 : 0);
|
|
318
|
+
if (used + cost > budget) {
|
|
319
|
+
truncated = true;
|
|
320
|
+
break;
|
|
321
|
+
}
|
|
322
|
+
kept.push(character);
|
|
323
|
+
used += cost;
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
text: (keep === 'trailing' ? kept.reverse() : kept).join(''),
|
|
327
|
+
truncated,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
function likeValue(value, pattern) {
|
|
331
|
+
// Two wildcard characters is the worst case (`contains`).
|
|
332
|
+
const bounded = boundLikeSource(value, CONTENT_LIST_QUERY_MAX_VALUE_LENGTH - 2, pattern === 'suffix' ? 'trailing' : 'leading');
|
|
333
|
+
const escaped = escapeContentListQueryLikeValue(bounded.text);
|
|
334
|
+
const wrapped = pattern === 'prefix'
|
|
335
|
+
? `${escaped}%`
|
|
336
|
+
: pattern === 'suffix'
|
|
337
|
+
? `%${escaped}`
|
|
338
|
+
: `%${escaped}%`;
|
|
339
|
+
return { value: wrapped, truncated: bounded.truncated };
|
|
340
|
+
}
|
|
341
|
+
/** Cuts a plain scalar string to the protocol cap without splitting a pair. */
|
|
342
|
+
function boundScalarText(text) {
|
|
343
|
+
if (text.length <= CONTENT_LIST_QUERY_MAX_VALUE_LENGTH) {
|
|
344
|
+
return { text, truncated: false };
|
|
345
|
+
}
|
|
346
|
+
const cut = text.slice(0, CONTENT_LIST_QUERY_MAX_VALUE_LENGTH);
|
|
347
|
+
const last = cut.charCodeAt(cut.length - 1);
|
|
348
|
+
return {
|
|
349
|
+
text: last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut,
|
|
350
|
+
truncated: true,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `YYYY-MM-DD`, optionally followed by a time or offset — the shapes a link or
|
|
355
|
+
* a saved view can carry for a datetime column.
|
|
356
|
+
*/
|
|
357
|
+
const CALENDAR_DATE_PREFIX = /^(\d{4})-(\d{2})-(\d{2})(?:[Tt ].*)?$/;
|
|
358
|
+
/** A bare calendar day, which `Date` reads as UTC midnight. Unambiguous. */
|
|
359
|
+
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
|
|
360
|
+
/**
|
|
361
|
+
* A time-bearing input that names ONE instant, accepted case-insensitively.
|
|
362
|
+
*
|
|
363
|
+
* Deliberately looser than {@link RFC_3339_INSTANT} in two ways, because this
|
|
364
|
+
* matches the INPUT while that one matches the canonical form sent to the
|
|
365
|
+
* server (the value goes through `toISOString()` first, so the server only ever
|
|
366
|
+
* sees the strict form it insists on):
|
|
367
|
+
*
|
|
368
|
+
* - RFC 3339 §5.6 permits a lower-case `t`/`z`;
|
|
369
|
+
* - seconds are optional. `2026-02-01T12:30Z` and `2026-02-01T12:30+09:00`
|
|
370
|
+
* carry an offset, so they name one instant for every reader, and `Date`
|
|
371
|
+
* parses both through its ISO path. The rule this guards is "a time must
|
|
372
|
+
* carry an offset", not "a time must state its seconds".
|
|
373
|
+
*
|
|
374
|
+
* The `T` separator is still required. RFC 3339 §5.6 allows a space by mutual
|
|
375
|
+
* agreement, but a space leaves the ISO grammar, so `Date` falls through to its
|
|
376
|
+
* implementation-defined legacy parser — which is exactly the "same text, two
|
|
377
|
+
* different instants across engines" hazard this check exists to prevent.
|
|
378
|
+
*/
|
|
379
|
+
const RFC_3339_INSTANT_INPUT = /^(\d{4})-(\d{2})-(\d{2})[Tt]([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.\d{1,9})?)?(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/;
|
|
380
|
+
/**
|
|
381
|
+
* True when an input names ONE instant, the same one for every reader.
|
|
382
|
+
*
|
|
383
|
+
* A time without an offset is read as LOCAL time by `Date`, so
|
|
384
|
+
* `?updated.gte=2026-02-01T00:00` submits `00:00Z` from London and `15:00Z`
|
|
385
|
+
* the previous day from Tokyo — the identical link returning different rows
|
|
386
|
+
* per viewer. A time-bearing value must therefore carry `Z` or a numeric
|
|
387
|
+
* offset; a bare calendar day is unambiguous and stays accepted, widened to
|
|
388
|
+
* UTC midnight exactly as `Date` already reads it.
|
|
389
|
+
*/
|
|
390
|
+
function expressibleInstantSource(text) {
|
|
391
|
+
// Returns the exact string that must be parsed, never merely a verdict about
|
|
392
|
+
// a different one. Validating `text.trim()` and then parsing `text` let
|
|
393
|
+
// `"2026-02-01 "` through: V8's ISO parser rejects the trailing space and
|
|
394
|
+
// falls back to the legacy parser, which reads a bare date as LOCAL midnight
|
|
395
|
+
// — reintroducing the per-viewer divergence this whole check exists to stop,
|
|
396
|
+
// and without even a drop to show for it.
|
|
397
|
+
const trimmed = text.trim();
|
|
398
|
+
if (!isRealCalendarDate(trimmed))
|
|
399
|
+
return undefined;
|
|
400
|
+
if (DATE_ONLY.test(trimmed))
|
|
401
|
+
return trimmed;
|
|
402
|
+
if (!RFC_3339_INSTANT_INPUT.test(trimmed))
|
|
403
|
+
return undefined;
|
|
404
|
+
// Upper-cased for the same reason the space separator is refused: ECMA-262's
|
|
405
|
+
// Date Time String Format specifies `T` and `Z`, so a lower-case `t`/`z`
|
|
406
|
+
// leaves the grammar and lands in engine-specific heuristics. V8 happens to
|
|
407
|
+
// read it as UTC — that is luck, not a guarantee. `toUpperCase()` is
|
|
408
|
+
// locale-independent and this grammar has no other letters, so the
|
|
409
|
+
// canonical form means exactly what the input did.
|
|
410
|
+
return trimmed.toUpperCase();
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* True when the date part of an input names a real calendar day.
|
|
414
|
+
*
|
|
415
|
+
* Mirrors the component round-trip `normalizedInstant` performs in
|
|
416
|
+
* `@happyvertical/smrt-core`: build the day from the parsed numbers and check
|
|
417
|
+
* that it reads back as the same numbers. `2026-02-31` does not, and must be
|
|
418
|
+
* refused rather than quietly become `2026-03-03`.
|
|
419
|
+
*/
|
|
420
|
+
function isRealCalendarDate(text) {
|
|
421
|
+
const match = CALENDAR_DATE_PREFIX.exec(text.trim());
|
|
422
|
+
if (!match)
|
|
423
|
+
return false;
|
|
424
|
+
const year = Number(match[1]);
|
|
425
|
+
const month = Number(match[2]);
|
|
426
|
+
const day = Number(match[3]);
|
|
427
|
+
const calendar = new Date(0);
|
|
428
|
+
calendar.setUTCFullYear(year, month - 1, day);
|
|
429
|
+
calendar.setUTCHours(0, 0, 0, 0);
|
|
430
|
+
return (calendar.getUTCFullYear() === year &&
|
|
431
|
+
calendar.getUTCMonth() === month - 1 &&
|
|
432
|
+
calendar.getUTCDate() === day);
|
|
433
|
+
}
|
|
434
|
+
function scalarText(value) {
|
|
435
|
+
if (typeof value === 'string')
|
|
436
|
+
return value;
|
|
437
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
438
|
+
return String(value);
|
|
439
|
+
if (typeof value === 'boolean')
|
|
440
|
+
return String(value);
|
|
441
|
+
return null;
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Coerces one filter value for a server field.
|
|
445
|
+
*
|
|
446
|
+
* A `datetime` field only accepts an RFC 3339 instant — the request normalizer
|
|
447
|
+
* rejects anything else and fails the entire query — so an unparseable date is
|
|
448
|
+
* dropped here rather than sent.
|
|
449
|
+
*/
|
|
450
|
+
function coerceValue(field, raw, truncated) {
|
|
451
|
+
const text = scalarText(raw);
|
|
452
|
+
if (text === null)
|
|
453
|
+
return undefined;
|
|
454
|
+
if (field.type === 'datetime') {
|
|
455
|
+
// Validate the INPUT, never the value derived from it. `new Date()` rolls
|
|
456
|
+
// an impossible calendar date forward — `2026-02-31` becomes March 3 — and
|
|
457
|
+
// the rolled-forward instant then passes every shape check, so the query
|
|
458
|
+
// would silently target a date the link never asked for. The server rejects
|
|
459
|
+
// it (`normalizedInstant` re-derives the components and compares), so this
|
|
460
|
+
// has to reject it too, and report the drop.
|
|
461
|
+
const source = expressibleInstantSource(text);
|
|
462
|
+
if (source === undefined)
|
|
463
|
+
return undefined;
|
|
464
|
+
const parsed = new Date(source);
|
|
465
|
+
if (Number.isNaN(parsed.getTime()))
|
|
466
|
+
return undefined;
|
|
467
|
+
const instant = parsed.toISOString();
|
|
468
|
+
// Parsing is not the same as being expressible: a year outside the
|
|
469
|
+
// four-digit range round-trips through `Date` but fails the server's
|
|
470
|
+
// RFC 3339 shape check, which would 400 the whole list.
|
|
471
|
+
return RFC_3339_INSTANT.test(instant) ? instant : undefined;
|
|
472
|
+
}
|
|
473
|
+
if (field.type === 'number') {
|
|
474
|
+
const parsed = Number(text);
|
|
475
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
476
|
+
}
|
|
477
|
+
if (field.type === 'boolean') {
|
|
478
|
+
if (text === 'true')
|
|
479
|
+
return true;
|
|
480
|
+
if (text === 'false')
|
|
481
|
+
return false;
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
const bounded = boundScalarText(text);
|
|
485
|
+
if (bounded.truncated && truncated)
|
|
486
|
+
truncated.value = true;
|
|
487
|
+
return bounded.text;
|
|
488
|
+
}
|
|
489
|
+
function resolveColumn(columnId) {
|
|
490
|
+
if (!Object.hasOwn(CONTENT_LIST_QUERY_FIELDS, columnId))
|
|
491
|
+
return undefined;
|
|
492
|
+
return CONTENT_LIST_QUERY_FIELDS[columnId];
|
|
493
|
+
}
|
|
494
|
+
function translateFilter(filter, dropped) {
|
|
495
|
+
const columnId = filter.columnId;
|
|
496
|
+
const field = resolveColumn(columnId);
|
|
497
|
+
if (field === undefined) {
|
|
498
|
+
dropped.push({ scope: 'filter', reason: 'unknown-column', columnId });
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
if (field === null) {
|
|
502
|
+
dropped.push({ scope: 'filter', reason: 'no-server-field', columnId });
|
|
503
|
+
return null;
|
|
504
|
+
}
|
|
505
|
+
const mapping = OPERATOR_MAP[filter.operator];
|
|
506
|
+
if (!mapping) {
|
|
507
|
+
dropped.push({
|
|
508
|
+
scope: 'filter',
|
|
509
|
+
reason: 'unsupported-operator',
|
|
510
|
+
columnId,
|
|
511
|
+
detail: filter.operator,
|
|
512
|
+
});
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
if (!SERVER_OPERATORS[field.type].includes(mapping.operator)) {
|
|
516
|
+
dropped.push({
|
|
517
|
+
scope: 'filter',
|
|
518
|
+
reason: 'unsupported-operator',
|
|
519
|
+
columnId,
|
|
520
|
+
detail: filter.operator,
|
|
521
|
+
});
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
// `isNull` / `isNotNull`, and a literal `null` comparand on `equals` /
|
|
525
|
+
// `notEquals`, which names the same thing. A null scalar is what the
|
|
526
|
+
// normalizer accepts for eq/ne, and the query builder lowers it to
|
|
527
|
+
// `IS NULL` / `IS NOT NULL`. Without the second case the local evaluator
|
|
528
|
+
// would match an absent row while the translator dropped the filter, so the
|
|
529
|
+
// same data-surface command would answer differently by mode.
|
|
530
|
+
if (mapping.nullValue ||
|
|
531
|
+
(filter.value === null &&
|
|
532
|
+
(mapping.operator === 'eq' || mapping.operator === 'ne'))) {
|
|
533
|
+
return {
|
|
534
|
+
kind: 'condition',
|
|
535
|
+
field: field.field,
|
|
536
|
+
operator: mapping.operator,
|
|
537
|
+
value: null,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
if (mapping.operator === 'in' || mapping.operator === 'notIn') {
|
|
541
|
+
const entries = Array.isArray(filter.value) ? filter.value : [];
|
|
542
|
+
// TRUNCATION IS ONLY PERMISSIBLE WHEN IT NARROWS.
|
|
543
|
+
//
|
|
544
|
+
// `in` is a union of equalities: losing an entry removes a disjunct, so the
|
|
545
|
+
// result can only shrink. Capping it answers a subset of the question,
|
|
546
|
+
// which is a defensible degradation.
|
|
547
|
+
//
|
|
548
|
+
// `notIn` is an intersection of inequalities: losing an entry removes an
|
|
549
|
+
// EXCLUSION, so the result grows to contain rows the operator asked not to
|
|
550
|
+
// see. The cap keeps arrival order, so a literal `null` past the hundredth
|
|
551
|
+
// entry is the one shed — and the executor then takes its "no null listed"
|
|
552
|
+
// arm and unions `IS NULL` back in, handing back every absent-valued row.
|
|
553
|
+
// A widening list operator is therefore never PARTIALLY applied: if any
|
|
554
|
+
// entry cannot be carried faithfully, the whole filter is left out and
|
|
555
|
+
// reported as such.
|
|
556
|
+
const widening = mapping.operator === 'notIn';
|
|
557
|
+
const values = [];
|
|
558
|
+
let overflowed = false;
|
|
559
|
+
let truncatedEntry = false;
|
|
560
|
+
let unusableEntry = false;
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
const truncated = { value: false };
|
|
563
|
+
// A literal `null` is MEANINGFUL, not missing: in an `in` list it says
|
|
564
|
+
// "or rows with no value", and in a `notIn` list it says "and not rows
|
|
565
|
+
// with no value". `coerceValue` reports it as unusable, so it has to be
|
|
566
|
+
// carried past the coercion — dropping it here would send
|
|
567
|
+
// `notIn ['Ada']` for `notIn ['Ada', null]` and return exactly the
|
|
568
|
+
// authorless rows the caller listed `null` to exclude, undoing the
|
|
569
|
+
// executor's own null-aware lowering.
|
|
570
|
+
const coerced = entry === null ? null : coerceValue(field, entry, truncated);
|
|
571
|
+
if (coerced === undefined) {
|
|
572
|
+
unusableEntry = true;
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
// A shortened entry is not the value the caller named: it would exclude
|
|
576
|
+
// (or match) some other row. Never emit it — losing it narrows an `in`
|
|
577
|
+
// and disqualifies a `notIn`, both of which this handles.
|
|
578
|
+
if (truncated.value) {
|
|
579
|
+
truncatedEntry = true;
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (values.includes(coerced))
|
|
583
|
+
continue;
|
|
584
|
+
if (values.length >= CONTENT_LIST_QUERY_MAX_IN_VALUES) {
|
|
585
|
+
overflowed = true;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
values.push(coerced);
|
|
589
|
+
}
|
|
590
|
+
if (widening && (overflowed || truncatedEntry || unusableEntry)) {
|
|
591
|
+
dropped.push({
|
|
592
|
+
scope: 'filter',
|
|
593
|
+
reason: 'filter-widened',
|
|
594
|
+
columnId,
|
|
595
|
+
detail: filter.operator,
|
|
596
|
+
});
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
if (values.length === 0) {
|
|
600
|
+
dropped.push({
|
|
601
|
+
scope: 'filter',
|
|
602
|
+
reason: 'unsupported-value',
|
|
603
|
+
columnId,
|
|
604
|
+
detail: filter.operator,
|
|
605
|
+
});
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
// Only `in` reaches here having lost anything, and losing narrows it — so
|
|
609
|
+
// the filter is a genuine SUBSET of the one asked for, which is allowed
|
|
610
|
+
// but must still be reported. An entry that could not be coerced at all
|
|
611
|
+
// (`?updated.in=2026-02-01,soon`) is the case that used to vanish in
|
|
612
|
+
// silence: neither exact, nor superset, nor subset-and-reported, nor
|
|
613
|
+
// not-applied. `unsupported-value` is the accurate reason — the entry could
|
|
614
|
+
// not be used, rather than being out of some range.
|
|
615
|
+
if (unusableEntry) {
|
|
616
|
+
dropped.push({
|
|
617
|
+
scope: 'filter',
|
|
618
|
+
reason: 'unsupported-value',
|
|
619
|
+
columnId,
|
|
620
|
+
detail: filter.operator,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
if (overflowed) {
|
|
624
|
+
dropped.push({
|
|
625
|
+
scope: 'filter',
|
|
626
|
+
reason: 'out-of-range',
|
|
627
|
+
columnId,
|
|
628
|
+
detail: String(entries.length),
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
if (truncatedEntry) {
|
|
632
|
+
dropped.push({
|
|
633
|
+
scope: 'filter',
|
|
634
|
+
reason: 'out-of-range',
|
|
635
|
+
columnId,
|
|
636
|
+
detail: filter.operator,
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
kind: 'condition',
|
|
641
|
+
field: field.field,
|
|
642
|
+
operator: mapping.operator,
|
|
643
|
+
value: values,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (mapping.pattern) {
|
|
647
|
+
const text = scalarText(filter.value);
|
|
648
|
+
if (text === null) {
|
|
649
|
+
dropped.push({
|
|
650
|
+
scope: 'filter',
|
|
651
|
+
reason: 'unsupported-value',
|
|
652
|
+
columnId,
|
|
653
|
+
detail: filter.operator,
|
|
654
|
+
});
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const pattern = likeValue(text, mapping.pattern);
|
|
658
|
+
if (pattern.truncated) {
|
|
659
|
+
// A shortened `like` pattern matches a SUPERSET of what was asked for.
|
|
660
|
+
dropped.push({
|
|
661
|
+
scope: 'filter',
|
|
662
|
+
reason: 'filter-widened',
|
|
663
|
+
columnId,
|
|
664
|
+
detail: filter.operator,
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
return {
|
|
668
|
+
kind: 'condition',
|
|
669
|
+
field: field.field,
|
|
670
|
+
operator: 'like',
|
|
671
|
+
value: pattern.value,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
const truncated = { value: false };
|
|
675
|
+
const value = coerceValue(field, filter.value, truncated);
|
|
676
|
+
if (value === undefined) {
|
|
677
|
+
dropped.push({
|
|
678
|
+
scope: 'filter',
|
|
679
|
+
reason: 'unsupported-value',
|
|
680
|
+
columnId,
|
|
681
|
+
detail: filter.operator,
|
|
682
|
+
});
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
if (truncated.value) {
|
|
686
|
+
// Never emit a value the caller did not name — the same rule the list path
|
|
687
|
+
// applies to its entries. A shortened comparand does not merely loosen the
|
|
688
|
+
// predicate: `gt`/`gte` would widen, `lt`/`lte` would NARROW and hide rows,
|
|
689
|
+
// and `eq`/`ne` would name some third row entirely. There is no honest
|
|
690
|
+
// single label for that, so the filter is not applied at all, which is
|
|
691
|
+
// uniformly a widening and is reported as one.
|
|
692
|
+
dropped.push({
|
|
693
|
+
scope: 'filter',
|
|
694
|
+
reason: 'filter-widened',
|
|
695
|
+
columnId,
|
|
696
|
+
detail: filter.operator,
|
|
697
|
+
});
|
|
698
|
+
return null;
|
|
699
|
+
}
|
|
700
|
+
return {
|
|
701
|
+
kind: 'condition',
|
|
702
|
+
field: field.field,
|
|
703
|
+
operator: mapping.operator,
|
|
704
|
+
value,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* Free-text search as an `any` of `like` predicates.
|
|
709
|
+
*
|
|
710
|
+
* The protocol has no search primitive — only `filter` — so search is modelled
|
|
711
|
+
* explicitly, over exactly the fields the adapter marks searchable, with the
|
|
712
|
+
* wildcards added (and the operator's own metacharacters escaped) here.
|
|
713
|
+
*/
|
|
714
|
+
function translateSearch(search, searchFields, dropped) {
|
|
715
|
+
const trimmed = search.trim();
|
|
716
|
+
if (!trimmed || searchFields.length === 0)
|
|
717
|
+
return null;
|
|
718
|
+
const pattern = likeValue(trimmed, 'contains');
|
|
719
|
+
if (pattern.truncated) {
|
|
720
|
+
// The URL layer stores a search verbatim, so an over-long `?q=` would
|
|
721
|
+
// otherwise 400 the whole list instead of searching a shorter term.
|
|
722
|
+
dropped.push({
|
|
723
|
+
scope: 'search',
|
|
724
|
+
reason: 'filter-widened',
|
|
725
|
+
detail: String(trimmed.length),
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
const filters = searchFields.map((field) => ({
|
|
729
|
+
kind: 'condition',
|
|
730
|
+
field,
|
|
731
|
+
operator: 'like',
|
|
732
|
+
value: pattern.value,
|
|
733
|
+
}));
|
|
734
|
+
return filters.length === 1 ? filters[0] : { kind: 'any', filters };
|
|
735
|
+
}
|
|
736
|
+
function translateSorting(sorting, dropped) {
|
|
737
|
+
const terms = [];
|
|
738
|
+
const seen = new Set();
|
|
739
|
+
for (const rule of sorting) {
|
|
740
|
+
const field = resolveColumn(rule.columnId);
|
|
741
|
+
if (field === undefined) {
|
|
742
|
+
dropped.push({
|
|
743
|
+
scope: 'sorting',
|
|
744
|
+
reason: 'unknown-column',
|
|
745
|
+
columnId: rule.columnId,
|
|
746
|
+
});
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
if (field === null) {
|
|
750
|
+
dropped.push({
|
|
751
|
+
scope: 'sorting',
|
|
752
|
+
reason: 'no-server-field',
|
|
753
|
+
columnId: rule.columnId,
|
|
754
|
+
});
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (seen.has(field.field))
|
|
758
|
+
continue;
|
|
759
|
+
seen.add(field.field);
|
|
760
|
+
terms.push({ field: field.field, direction: rule.direction });
|
|
761
|
+
}
|
|
762
|
+
// Nothing sortable survived: emit the schema default EXPLICITLY rather than
|
|
763
|
+
// omitting `sort`. The normalizer injects `schema.defaultSort` when the key
|
|
764
|
+
// is absent and then measures the NORMALIZED request against the byte limit,
|
|
765
|
+
// so an omitted sort makes the client's byte count 84 bytes short of the
|
|
766
|
+
// server's — a request the client accepts at 99,917-100,000 bytes and the
|
|
767
|
+
// server refuses outright. Sending it makes the two measurements identical.
|
|
768
|
+
if (terms.length === 0)
|
|
769
|
+
return [...CONTENT_LIST_QUERY_DEFAULT_SORT];
|
|
770
|
+
// A paged read must be totally ordered or two pages can repeat or skip a row.
|
|
771
|
+
if (!seen.has(CONTENT_LIST_QUERY_IDENTITY_FIELD)) {
|
|
772
|
+
terms.push({ field: CONTENT_LIST_QUERY_IDENTITY_FIELD, direction: 'asc' });
|
|
773
|
+
}
|
|
774
|
+
return terms;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* The one page-size ceiling, resolved from every configured limit.
|
|
778
|
+
*
|
|
779
|
+
* Every candidate narrows: a host that sets a server row budget through
|
|
780
|
+
* `query.request.maxPageSize` must not have it discarded because a looser
|
|
781
|
+
* `urlState.options.maxPageSize` also exists. The schema's `maxPageLimit`
|
|
782
|
+
* (mirrored by `CONTENT_LIST_MAX_PAGE_SIZE`) is always one of the candidates,
|
|
783
|
+
* so the result can never exceed what the endpoint itself enforces.
|
|
784
|
+
*
|
|
785
|
+
* Callers pass this ONE value to the controller seed, the URL sanitizer, the
|
|
786
|
+
* saved-view sanitizer, and the translator, which is what makes it impossible
|
|
787
|
+
* for the page the UI reports and the page the server returns to disagree.
|
|
788
|
+
*/
|
|
789
|
+
export function resolveContentListMaxPageSize(...candidates) {
|
|
790
|
+
const bounds = [...candidates, CONTENT_LIST_MAX_PAGE_SIZE].filter((value) => typeof value === 'number' && Number.isFinite(value) && value >= 1);
|
|
791
|
+
return Math.max(1, Math.floor(Math.min(...bounds)));
|
|
792
|
+
}
|
|
793
|
+
/** Bounds a caller-supplied request id to the normalizer's string rule. */
|
|
794
|
+
function boundRequestId(createRequestId) {
|
|
795
|
+
const candidate = (createRequestId ?? defaultRequestId)();
|
|
796
|
+
if (typeof candidate !== 'string' || candidate.length === 0) {
|
|
797
|
+
return defaultRequestId();
|
|
798
|
+
}
|
|
799
|
+
return candidate.length > CONTENT_LIST_QUERY_MAX_REQUEST_ID_LENGTH
|
|
800
|
+
? candidate.slice(0, CONTENT_LIST_QUERY_MAX_REQUEST_ID_LENGTH)
|
|
801
|
+
: candidate;
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* Bounds a caller-supplied projection against the normalizer's *rules*, not
|
|
805
|
+
* only its count.
|
|
806
|
+
*
|
|
807
|
+
* A projection entry must be a non-empty string of at most 256 characters
|
|
808
|
+
* (`stringValue`) AND name a field the schema declares projectable, or the
|
|
809
|
+
* request is refused outright with `DATA_QUERY_PROJECTION_NOT_ALLOWED` — a 400
|
|
810
|
+
* for the whole list, which is precisely what the drop-and-report contract
|
|
811
|
+
* exists to avoid. The identity field is always projected, as the normalizer
|
|
812
|
+
* does, so a projection can never be emptied to nothing.
|
|
813
|
+
*/
|
|
814
|
+
function boundProjection(projection, dropped) {
|
|
815
|
+
const allowed = new Set(CONTENT_LIST_QUERY_PROJECTABLE_FIELDS);
|
|
816
|
+
const unique = new Set([CONTENT_LIST_QUERY_IDENTITY_FIELD]);
|
|
817
|
+
for (const field of projection) {
|
|
818
|
+
if (typeof field !== 'string' ||
|
|
819
|
+
field.length === 0 ||
|
|
820
|
+
field.length > CONTENT_LIST_QUERY_MAX_FIELD_ID_LENGTH ||
|
|
821
|
+
!allowed.has(field)) {
|
|
822
|
+
dropped.push({
|
|
823
|
+
scope: 'state',
|
|
824
|
+
reason: 'unsupported-value',
|
|
825
|
+
detail: typeof field === 'string' ? field.slice(0, 64) : undefined,
|
|
826
|
+
});
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
unique.add(field);
|
|
830
|
+
}
|
|
831
|
+
const fields = [...unique];
|
|
832
|
+
if (fields.length <= CONTENT_LIST_QUERY_MAX_PROJECTION_FIELDS)
|
|
833
|
+
return fields;
|
|
834
|
+
dropped.push({
|
|
835
|
+
scope: 'state',
|
|
836
|
+
reason: 'out-of-range',
|
|
837
|
+
detail: String(fields.length),
|
|
838
|
+
});
|
|
839
|
+
return fields.slice(0, CONTENT_LIST_QUERY_MAX_PROJECTION_FIELDS);
|
|
840
|
+
}
|
|
841
|
+
const requestEncoder = new TextEncoder();
|
|
842
|
+
function jsonByteLength(value) {
|
|
843
|
+
return requestEncoder.encode(JSON.stringify(value) ?? 'null').byteLength;
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Counts filter nodes exactly the way `normalizeFilter` budgets them: every
|
|
847
|
+
* node, container or condition, costs one.
|
|
848
|
+
*/
|
|
849
|
+
function countFilterNodes(filter) {
|
|
850
|
+
if (filter.kind === 'condition')
|
|
851
|
+
return 1;
|
|
852
|
+
if (filter.kind === 'not')
|
|
853
|
+
return 1 + countFilterNodes(filter.filter);
|
|
854
|
+
return filter.filters.reduce((total, child) => total + countFilterNodes(child), 1);
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* The executor's operator inversion, mirroring `inverseOperator` in
|
|
858
|
+
* `content-query.ts`. `like` has no inverse there — a negated `like` is refused
|
|
859
|
+
* outright — so it is reported as unbounded, which makes the branch budget shed
|
|
860
|
+
* such a filter instead of letting the request 400.
|
|
861
|
+
*/
|
|
862
|
+
function invertedBranchOperator(operator) {
|
|
863
|
+
switch (operator) {
|
|
864
|
+
case 'eq':
|
|
865
|
+
return 'ne';
|
|
866
|
+
case 'ne':
|
|
867
|
+
return 'eq';
|
|
868
|
+
case 'gt':
|
|
869
|
+
return 'lte';
|
|
870
|
+
case 'gte':
|
|
871
|
+
return 'lt';
|
|
872
|
+
case 'lt':
|
|
873
|
+
return 'gte';
|
|
874
|
+
case 'lte':
|
|
875
|
+
return 'gt';
|
|
876
|
+
case 'in':
|
|
877
|
+
return 'notIn';
|
|
878
|
+
case 'notIn':
|
|
879
|
+
return 'in';
|
|
880
|
+
case 'like':
|
|
881
|
+
return null;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function hasNullEntry(value) {
|
|
885
|
+
return Array.isArray(value) && value.some((entry) => entry === null);
|
|
886
|
+
}
|
|
887
|
+
function hasNonNullEntry(value) {
|
|
888
|
+
return Array.isArray(value) && value.some((entry) => entry !== null);
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Counts the disjunctive-normal-form branches the executor expands a filter
|
|
892
|
+
* into, mirroring `conditionToDnf`, `filterToDnf` and `crossProduct` in
|
|
893
|
+
* `content-query.ts` — including De Morgan under `not`, where an `any` becomes
|
|
894
|
+
* a product and each condition's operator is inverted.
|
|
895
|
+
*
|
|
896
|
+
* `ne` and `notIn` lower to a UNION with `IS NULL` so their meaning matches the
|
|
897
|
+
* local evaluator's, which costs a second branch each unless the caller listed
|
|
898
|
+
* a `null` explicitly, and a NEGATED ordered comparison unions for the same
|
|
899
|
+
* reason. An `all` multiplies its children. Past
|
|
900
|
+
* {@link CONTENT_LIST_QUERY_MAX_OR_BRANCHES} the executor refuses the request
|
|
901
|
+
* outright, so the translator has to stop adding filters before that rather
|
|
902
|
+
* than hand the operator an error panel.
|
|
903
|
+
*
|
|
904
|
+
* The translator itself emits only conditions, the search `any`, and the outer
|
|
905
|
+
* `all`; the negation arm exists so a future `not` emitter cannot silently
|
|
906
|
+
* under-count and trade shedding for a 400.
|
|
907
|
+
*/
|
|
908
|
+
function countFilterBranches(filter, negate = false) {
|
|
909
|
+
if (filter.kind === 'condition') {
|
|
910
|
+
const operator = negate
|
|
911
|
+
? invertedBranchOperator(filter.operator)
|
|
912
|
+
: filter.operator;
|
|
913
|
+
if (operator === null)
|
|
914
|
+
return Number.POSITIVE_INFINITY;
|
|
915
|
+
// The complement of an ordered comparison unions IS NULL, so it splits.
|
|
916
|
+
if (negate &&
|
|
917
|
+
(operator === 'gt' ||
|
|
918
|
+
operator === 'gte' ||
|
|
919
|
+
operator === 'lt' ||
|
|
920
|
+
operator === 'lte')) {
|
|
921
|
+
return 2;
|
|
922
|
+
}
|
|
923
|
+
// A listed null means "exclude absent rows too", which is one AND group.
|
|
924
|
+
if (operator === 'ne')
|
|
925
|
+
return filter.value === null ? 1 : 2;
|
|
926
|
+
if (operator === 'notIn')
|
|
927
|
+
return hasNullEntry(filter.value) ? 1 : 2;
|
|
928
|
+
// `in` splits only when the list mixes null and non-null entries.
|
|
929
|
+
if (operator === 'in') {
|
|
930
|
+
return hasNullEntry(filter.value) && hasNonNullEntry(filter.value)
|
|
931
|
+
? 2
|
|
932
|
+
: 1;
|
|
933
|
+
}
|
|
934
|
+
return 1;
|
|
935
|
+
}
|
|
936
|
+
if (filter.kind === 'not')
|
|
937
|
+
return countFilterBranches(filter.filter, !negate);
|
|
938
|
+
// De Morgan: a negated `any` behaves as an `all`, and vice versa.
|
|
939
|
+
const combineWithAnd = (filter.kind === 'all' && !negate) || (filter.kind === 'any' && negate);
|
|
940
|
+
return filter.filters.reduce((total, child) => combineWithAnd
|
|
941
|
+
? total * countFilterBranches(child, negate)
|
|
942
|
+
: total + countFilterBranches(child, negate), combineWithAnd ? 1 : 0);
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Translates a content-list view state into a bounded `DataQueryRequest`.
|
|
946
|
+
*
|
|
947
|
+
* Everything unmappable is dropped and reported rather than thrown: a stale
|
|
948
|
+
* saved view or a crafted link must still produce a valid query, minus the
|
|
949
|
+
* parts the server cannot express.
|
|
950
|
+
*
|
|
951
|
+
* Selection and expansion are never translated — they address rendered rows,
|
|
952
|
+
* not a query.
|
|
953
|
+
*/
|
|
954
|
+
export function contentListViewStateToDataQueryRequest(state, options = {}) {
|
|
955
|
+
const dropped = [];
|
|
956
|
+
const projection = boundProjection(options.projection ?? CONTENT_LIST_QUERY_PROJECTION, dropped);
|
|
957
|
+
const maxPageSize = resolveContentListMaxPageSize(options.maxPageSize);
|
|
958
|
+
const defaultSize = Math.min(Math.max(1, Math.floor(options.defaultPageSize ?? CONTENT_LIST_QUERY_DEFAULT_PAGE_SIZE)), maxPageSize);
|
|
959
|
+
if (state.pageSize === null) {
|
|
960
|
+
// `null` means "unpaginated", which a server query cannot express: the
|
|
961
|
+
// endpoint always applies a limit. Silently falling back would render one
|
|
962
|
+
// page of `limit` rows with no page controls and no way to reach the rest,
|
|
963
|
+
// so the coercion is reported instead. Local mode keeps null semantics.
|
|
964
|
+
dropped.push({ scope: 'pageSize', reason: 'unpaginated-unsupported' });
|
|
965
|
+
}
|
|
966
|
+
const requestedSize = state.pageSize ?? defaultSize;
|
|
967
|
+
let limit = Math.max(1, Math.floor(requestedSize));
|
|
968
|
+
if (limit > maxPageSize) {
|
|
969
|
+
// A restored link or saved view must never turn into a larger row budget
|
|
970
|
+
// than the surface publishes.
|
|
971
|
+
dropped.push({
|
|
972
|
+
scope: 'pageSize',
|
|
973
|
+
reason: 'out-of-range',
|
|
974
|
+
detail: String(requestedSize),
|
|
975
|
+
});
|
|
976
|
+
limit = maxPageSize;
|
|
977
|
+
}
|
|
978
|
+
const requestedPage = Math.max(1, Math.floor(state.page ?? 1));
|
|
979
|
+
let effectivePage = requestedPage;
|
|
980
|
+
let offset = (effectivePage - 1) * limit;
|
|
981
|
+
if (offset > CONTENT_LIST_QUERY_MAX_OFFSET) {
|
|
982
|
+
dropped.push({
|
|
983
|
+
scope: 'page',
|
|
984
|
+
reason: 'out-of-range',
|
|
985
|
+
detail: String(requestedPage),
|
|
986
|
+
});
|
|
987
|
+
offset = Math.floor(CONTENT_LIST_QUERY_MAX_OFFSET / limit) * limit;
|
|
988
|
+
// The caller has to be able to move its own page marker to the page the
|
|
989
|
+
// request will actually read, or the UI labels this answer with a page
|
|
990
|
+
// number the server never saw and navigation from it is nonsense.
|
|
991
|
+
effectivePage = Math.floor(offset / limit) + 1;
|
|
992
|
+
}
|
|
993
|
+
const branches = [];
|
|
994
|
+
const search = translateSearch(state.search ?? '', CONTENT_LIST_QUERY_SEARCH_FIELDS, dropped);
|
|
995
|
+
// The node budget is spent in the caller's own priority order: search first —
|
|
996
|
+
// it is the operator's most recent, most visible intent — then the
|
|
997
|
+
// declarative filters. The outer `all` container costs a node too.
|
|
998
|
+
let nodeBudget = CONTENT_LIST_QUERY_MAX_FILTER_NODES - 1;
|
|
999
|
+
// The executor lowers the filter to disjunctive normal form and refuses one
|
|
1000
|
+
// that expands past its ceiling. The top-level `all` is a cross product, so
|
|
1001
|
+
// this budget MULTIPLIES rather than subtracts.
|
|
1002
|
+
let branchBudget = 1;
|
|
1003
|
+
if (search) {
|
|
1004
|
+
branches.push(search);
|
|
1005
|
+
nodeBudget -= countFilterNodes(search);
|
|
1006
|
+
branchBudget *= countFilterBranches(search);
|
|
1007
|
+
}
|
|
1008
|
+
for (const filter of state.filters ?? []) {
|
|
1009
|
+
const translated = translateFilter(filter, dropped);
|
|
1010
|
+
if (!translated)
|
|
1011
|
+
continue;
|
|
1012
|
+
const cost = countFilterNodes(translated);
|
|
1013
|
+
if (cost > nodeBudget) {
|
|
1014
|
+
// Past this many nodes the normalizer refuses the entire request. A
|
|
1015
|
+
// partial query beats an error panel, but it is a WIDER one: every filter
|
|
1016
|
+
// left out is a restriction the operator asked for and is not getting.
|
|
1017
|
+
dropped.push({
|
|
1018
|
+
scope: 'filter',
|
|
1019
|
+
reason: 'filter-widened',
|
|
1020
|
+
columnId: filter.columnId,
|
|
1021
|
+
detail: String(CONTENT_LIST_QUERY_MAX_FILTER_NODES),
|
|
1022
|
+
});
|
|
1023
|
+
continue;
|
|
1024
|
+
}
|
|
1025
|
+
const branchCost = countFilterBranches(translated);
|
|
1026
|
+
if (branchBudget * branchCost > CONTENT_LIST_QUERY_MAX_OR_BRANCHES) {
|
|
1027
|
+
dropped.push({
|
|
1028
|
+
scope: 'filter',
|
|
1029
|
+
reason: 'filter-widened',
|
|
1030
|
+
columnId: filter.columnId,
|
|
1031
|
+
detail: String(CONTENT_LIST_QUERY_MAX_OR_BRANCHES),
|
|
1032
|
+
});
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
nodeBudget -= cost;
|
|
1036
|
+
branchBudget *= branchCost;
|
|
1037
|
+
branches.push(translated);
|
|
1038
|
+
}
|
|
1039
|
+
const sort = translateSorting(state.sorting ?? [], dropped);
|
|
1040
|
+
const build = (filters) => ({
|
|
1041
|
+
version: 1,
|
|
1042
|
+
requestId: boundRequestId(options.createRequestId),
|
|
1043
|
+
mode: 'rows',
|
|
1044
|
+
projection,
|
|
1045
|
+
...(filters.length === 0
|
|
1046
|
+
? {}
|
|
1047
|
+
: {
|
|
1048
|
+
filter: filters.length === 1
|
|
1049
|
+
? filters[0]
|
|
1050
|
+
: { kind: 'all', filters: [...filters] },
|
|
1051
|
+
}),
|
|
1052
|
+
// Always present: see `translateSorting`. Omitting it would make the
|
|
1053
|
+
// client's byte measurement disagree with the server's.
|
|
1054
|
+
sort,
|
|
1055
|
+
page: { kind: 'offset', offset, limit },
|
|
1056
|
+
});
|
|
1057
|
+
// The last bound the normalizer applies, and the only one that is a property
|
|
1058
|
+
// of the whole request rather than one part of it: 100 `in` values of 4096
|
|
1059
|
+
// characters each is inside every per-value cap and still five times the
|
|
1060
|
+
// request byte limit. Shed the newest branches until it fits.
|
|
1061
|
+
const kept = [...branches];
|
|
1062
|
+
let request = build(kept);
|
|
1063
|
+
while (kept.length > 0 &&
|
|
1064
|
+
jsonByteLength(request) > CONTENT_LIST_QUERY_MAX_REQUEST_BYTES) {
|
|
1065
|
+
kept.pop();
|
|
1066
|
+
dropped.push({
|
|
1067
|
+
scope: 'filter',
|
|
1068
|
+
reason: 'filter-widened',
|
|
1069
|
+
detail: String(CONTENT_LIST_QUERY_MAX_REQUEST_BYTES),
|
|
1070
|
+
});
|
|
1071
|
+
request = build(kept);
|
|
1072
|
+
}
|
|
1073
|
+
return { request, dropped, effectivePage };
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* A stable identity for the *semantics* of a request.
|
|
1077
|
+
*
|
|
1078
|
+
* `requestId` is correlation metadata, not query identity, so it is excluded:
|
|
1079
|
+
* a component can compare two translations to decide whether the query actually
|
|
1080
|
+
* changed rather than re-fetching on every state transition.
|
|
1081
|
+
*/
|
|
1082
|
+
export function contentListQueryRequestKey(request) {
|
|
1083
|
+
const { requestId: _requestId, ...semantic } = request;
|
|
1084
|
+
return JSON.stringify(semantic);
|
|
1085
|
+
}
|
|
1086
|
+
// ---------------------------------------------------------------------------
|
|
1087
|
+
// Result rows → ContentData
|
|
1088
|
+
// ---------------------------------------------------------------------------
|
|
1089
|
+
/**
|
|
1090
|
+
* Server field id → `ContentData` key. The second namespace bridge: a result
|
|
1091
|
+
* row is keyed by server field ids, and the presentations read `ContentData`.
|
|
1092
|
+
*/
|
|
1093
|
+
const ROW_FIELD_TO_CONTENT_KEY = Object.freeze({
|
|
1094
|
+
id: 'id',
|
|
1095
|
+
slug: 'slug',
|
|
1096
|
+
type: 'type',
|
|
1097
|
+
variant: 'variant',
|
|
1098
|
+
title: 'title',
|
|
1099
|
+
description: 'description',
|
|
1100
|
+
author: 'author',
|
|
1101
|
+
status: 'status',
|
|
1102
|
+
state: 'state',
|
|
1103
|
+
bodyFormat: 'bodyFormat',
|
|
1104
|
+
publish_date: 'publish_date',
|
|
1105
|
+
url: 'url',
|
|
1106
|
+
source: 'source',
|
|
1107
|
+
fileKey: 'fileKey',
|
|
1108
|
+
thumbnailAssetId: 'thumbnailAssetId',
|
|
1109
|
+
metadata: 'metadata',
|
|
1110
|
+
created_at: 'createdAt',
|
|
1111
|
+
updated_at: 'updatedAt',
|
|
1112
|
+
});
|
|
1113
|
+
/**
|
|
1114
|
+
* Maps one result row onto the `ContentData` shape the presentations render.
|
|
1115
|
+
* Unprojected and unknown fields are simply absent; `toContentListRows` already
|
|
1116
|
+
* treats a missing field as empty text.
|
|
1117
|
+
*/
|
|
1118
|
+
export function contentFromContentListQueryRow(row) {
|
|
1119
|
+
const content = {};
|
|
1120
|
+
for (const [field, key] of Object.entries(ROW_FIELD_TO_CONTENT_KEY)) {
|
|
1121
|
+
if (!Object.hasOwn(row, field))
|
|
1122
|
+
continue;
|
|
1123
|
+
const value = row[field];
|
|
1124
|
+
if (value === null || value === undefined)
|
|
1125
|
+
continue;
|
|
1126
|
+
content[key] = value;
|
|
1127
|
+
}
|
|
1128
|
+
return content;
|
|
1129
|
+
}
|
|
1130
|
+
/** Maps a whole result page. Order is preserved: the server already sorted. */
|
|
1131
|
+
export function contentListQueryRowsToContents(rows) {
|
|
1132
|
+
return rows.map((row) => contentFromContentListQueryRow(row));
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* The row count to DISPLAY a pager against, or `undefined` when the server
|
|
1136
|
+
* could not produce one (`total.kind === 'unavailable'`).
|
|
1137
|
+
*
|
|
1138
|
+
* Accepts an estimate: an approximate page count is what an estimate is for.
|
|
1139
|
+
* Do NOT clamp a page against this — see
|
|
1140
|
+
* {@link contentListQueryExactTotal}.
|
|
1141
|
+
*/
|
|
1142
|
+
export function contentListQueryTotalValue(total) {
|
|
1143
|
+
if (!total || total.kind === 'unavailable')
|
|
1144
|
+
return undefined;
|
|
1145
|
+
return total.value;
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* The row count a page may be CLAMPED against, or `undefined` when none exists.
|
|
1149
|
+
*
|
|
1150
|
+
* Clamping moves the operator, so it may only act on a count that is exactly
|
|
1151
|
+
* right. `DataQueryTotal` has three kinds and only one of them qualifies:
|
|
1152
|
+
*
|
|
1153
|
+
* - `exact` — authoritative. Clamp.
|
|
1154
|
+
* - `estimated` — an approximation, and clamping on one can strand a page that
|
|
1155
|
+
* really exists: an estimate of 100 rows on a 300-row query hides pages 3
|
|
1156
|
+
* onward, and the operator has no way to reach content that is there. The
|
|
1157
|
+
* opposite risk, offering a page that turns out to be empty, is visible and
|
|
1158
|
+
* self-correcting — they navigate, see nothing, and come back. Refusing to
|
|
1159
|
+
* hide reachable rows is the same rule as "truncation only when it narrows".
|
|
1160
|
+
* - `unavailable` — the total is UNKNOWN. Not zero, and emphatically not the
|
|
1161
|
+
* length of the page in hand, which would send every page above the first
|
|
1162
|
+
* back to page one the moment the request settled.
|
|
1163
|
+
*/
|
|
1164
|
+
export function contentListQueryExactTotal(total) {
|
|
1165
|
+
return total?.kind === 'exact' ? total.value : undefined;
|
|
1166
|
+
}
|
|
1167
|
+
// ---------------------------------------------------------------------------
|
|
1168
|
+
// Transport
|
|
1169
|
+
// ---------------------------------------------------------------------------
|
|
1170
|
+
/** A content query failure carrying the server's machine-readable code. */
|
|
1171
|
+
export class ContentListQueryError extends Error {
|
|
1172
|
+
/** Server error code, for example `DATA_QUERY_FILTER_NOT_ALLOWED`. */
|
|
1173
|
+
code;
|
|
1174
|
+
/** HTTP status, when the failure came from a response. */
|
|
1175
|
+
status;
|
|
1176
|
+
constructor(message, options = {}) {
|
|
1177
|
+
super(message, options.cause === undefined ? {} : { cause: options.cause });
|
|
1178
|
+
this.name = 'ContentListQueryError';
|
|
1179
|
+
this.code = options.code ?? 'CONTENT_QUERY_FAILED';
|
|
1180
|
+
this.status = options.status;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function isRecord(value) {
|
|
1184
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Reads the generated route's error envelope
|
|
1188
|
+
* (`{ error: { ok, status, code, message } }`), or returns `undefined` when the
|
|
1189
|
+
* payload is not one.
|
|
1190
|
+
*/
|
|
1191
|
+
function readErrorEnvelope(payload, status) {
|
|
1192
|
+
if (!isRecord(payload) || !isRecord(payload.error))
|
|
1193
|
+
return undefined;
|
|
1194
|
+
const error = payload.error;
|
|
1195
|
+
const message = typeof error.message === 'string' && error.message
|
|
1196
|
+
? error.message
|
|
1197
|
+
: 'The content query was refused.';
|
|
1198
|
+
return new ContentListQueryError(message, {
|
|
1199
|
+
code: typeof error.code === 'string' ? error.code : undefined,
|
|
1200
|
+
status: typeof error.status === 'number' ? error.status : status,
|
|
1201
|
+
});
|
|
1202
|
+
}
|
|
1203
|
+
function joinUrl(base, path) {
|
|
1204
|
+
return `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* A `fetch` transport for `POST /api/v1/contents/query`.
|
|
1208
|
+
*
|
|
1209
|
+
* The generated route wraps a success as `{ action: 'queryAction', result }`
|
|
1210
|
+
* and a refusal as `{ error: { ok: false, status, code, message } }`. This
|
|
1211
|
+
* unwraps the first and throws the second as a {@link ContentListQueryError} so
|
|
1212
|
+
* a binding's error state carries the server's code rather than a generic
|
|
1213
|
+
* "request failed".
|
|
1214
|
+
*
|
|
1215
|
+
* Non-JSON bodies and HTTP failures are never swallowed, and an `AbortError`
|
|
1216
|
+
* propagates untouched so cancellation stays distinguishable from a failure.
|
|
1217
|
+
*/
|
|
1218
|
+
export function createContentListQueryTransport(options = {}) {
|
|
1219
|
+
const url = joinUrl(options.apiBaseUrl ?? '/api/v1', options.path ?? 'contents/query');
|
|
1220
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
1221
|
+
if (typeof fetchImpl !== 'function') {
|
|
1222
|
+
throw new TypeError('createContentListQueryTransport requires a fetch implementation');
|
|
1223
|
+
}
|
|
1224
|
+
return {
|
|
1225
|
+
async query(request, runOptions) {
|
|
1226
|
+
const extraHeaders = typeof options.headers === 'function'
|
|
1227
|
+
? await options.headers()
|
|
1228
|
+
: options.headers;
|
|
1229
|
+
const headers = new Headers(extraHeaders);
|
|
1230
|
+
headers.set('content-type', 'application/json');
|
|
1231
|
+
if (!headers.has('accept'))
|
|
1232
|
+
headers.set('accept', 'application/json');
|
|
1233
|
+
const response = await fetchImpl(url, {
|
|
1234
|
+
method: 'POST',
|
|
1235
|
+
headers,
|
|
1236
|
+
body: JSON.stringify(request),
|
|
1237
|
+
...(options.credentials ? { credentials: options.credentials } : {}),
|
|
1238
|
+
...(runOptions?.signal ? { signal: runOptions.signal } : {}),
|
|
1239
|
+
});
|
|
1240
|
+
const text = await response.text();
|
|
1241
|
+
let payload;
|
|
1242
|
+
let parsed = false;
|
|
1243
|
+
if (text) {
|
|
1244
|
+
try {
|
|
1245
|
+
payload = JSON.parse(text);
|
|
1246
|
+
parsed = true;
|
|
1247
|
+
}
|
|
1248
|
+
catch {
|
|
1249
|
+
parsed = false;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const envelope = readErrorEnvelope(payload, response.status);
|
|
1253
|
+
if (envelope)
|
|
1254
|
+
throw envelope;
|
|
1255
|
+
if (!response.ok) {
|
|
1256
|
+
throw new ContentListQueryError(`The content query failed with HTTP ${response.status}.`, { code: 'CONTENT_QUERY_HTTP_ERROR', status: response.status });
|
|
1257
|
+
}
|
|
1258
|
+
if (!parsed || !isRecord(payload)) {
|
|
1259
|
+
throw new ContentListQueryError('The content query returned a body that is not a JSON object.', { code: 'CONTENT_QUERY_INVALID_RESPONSE', status: response.status });
|
|
1260
|
+
}
|
|
1261
|
+
// The generated action envelope; a bare result is accepted too so a host
|
|
1262
|
+
// can put its own gateway in front of the route.
|
|
1263
|
+
return Object.hasOwn(payload, 'result') &&
|
|
1264
|
+
Object.hasOwn(payload, 'action')
|
|
1265
|
+
? payload.result
|
|
1266
|
+
: payload;
|
|
1267
|
+
},
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
/** Reads the completeness flags off a result envelope, defensively. */
|
|
1271
|
+
export function readContentListQueryNotices(result) {
|
|
1272
|
+
if (!isRecord(result))
|
|
1273
|
+
return { truncated: false, warnings: [] };
|
|
1274
|
+
const warnings = Array.isArray(result.warnings)
|
|
1275
|
+
? result.warnings.filter((entry) => typeof entry === 'string')
|
|
1276
|
+
: [];
|
|
1277
|
+
return { truncated: result.truncated === true, warnings };
|
|
1278
|
+
}
|
|
1279
|
+
/**
|
|
1280
|
+
* A human-readable message for a binding error, for the list's error panel.
|
|
1281
|
+
* Returns `null` when there is no error.
|
|
1282
|
+
*/
|
|
1283
|
+
export function contentListQueryErrorMessage(error) {
|
|
1284
|
+
if (!error)
|
|
1285
|
+
return null;
|
|
1286
|
+
if (error instanceof ContentListQueryError) {
|
|
1287
|
+
return error.code === 'CONTENT_QUERY_FAILED'
|
|
1288
|
+
? error.message
|
|
1289
|
+
: `${error.message} (${error.code})`;
|
|
1290
|
+
}
|
|
1291
|
+
if (error instanceof Error)
|
|
1292
|
+
return error.message;
|
|
1293
|
+
return String(error);
|
|
1294
|
+
}
|