@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.
Files changed (34) hide show
  1. package/AGENTS.md +9 -86
  2. package/agents/content-list.md +869 -0
  3. package/dist/content-query.d.ts +310 -0
  4. package/dist/content-query.d.ts.map +1 -0
  5. package/dist/contents.d.ts +22 -0
  6. package/dist/contents.d.ts.map +1 -1
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +672 -4
  10. package/dist/index.js.map +1 -1
  11. package/dist/manifest.json +22 -2
  12. package/dist/smrt-knowledge.json +38 -5
  13. package/dist/svelte/components/ContentList.svelte +941 -30
  14. package/dist/svelte/components/ContentList.svelte.d.ts +44 -1
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +98 -1
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -1
  18. package/dist/svelte/content-list-controller.js +290 -19
  19. package/dist/svelte/content-list-query.d.ts +498 -0
  20. package/dist/svelte/content-list-query.d.ts.map +1 -0
  21. package/dist/svelte/content-list-query.js +1294 -0
  22. package/dist/svelte/content-list-saved-views.d.ts +172 -0
  23. package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
  24. package/dist/svelte/content-list-saved-views.js +298 -0
  25. package/dist/svelte/content-list-url-state.d.ts +211 -0
  26. package/dist/svelte/content-list-url-state.d.ts.map +1 -0
  27. package/dist/svelte/content-list-url-state.js +856 -0
  28. package/dist/svelte/i18n.contribution.d.ts +24 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +26 -0
  31. package/dist/svelte/index.d.ts +5 -1
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +8 -1
  34. 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
+ }