@happyvertical/smrt-content 0.43.3 → 0.43.5

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 -0
  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 +1580 -281
  14. package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
  15. package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
  16. package/dist/svelte/content-list-controller.d.ts +306 -0
  17. package/dist/svelte/content-list-controller.d.ts.map +1 -0
  18. package/dist/svelte/content-list-controller.js +921 -0
  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 +55 -0
  29. package/dist/svelte/i18n.contribution.d.ts.map +1 -1
  30. package/dist/svelte/i18n.contribution.js +57 -0
  31. package/dist/svelte/index.d.ts +5 -0
  32. package/dist/svelte/index.d.ts.map +1 -1
  33. package/dist/svelte/index.js +10 -0
  34. package/package.json +16 -15
@@ -0,0 +1,869 @@
1
+ <!-- Module doc for packages/content/AGENTS.md. Linked from the Modules table there. -->
2
+
3
+ # ContentList: shared adapter, server query, URL state, saved views
4
+
5
+ `ContentList` reads every row, column, filter, and action through one shared
6
+ adapter (`src/svelte/content-list-controller.ts`) over a `DataTableController`,
7
+ and can source those rows either from a client array or from the bounded
8
+ content query endpoint. This doc covers both modes end to end.
9
+
10
+ ## ContentList migration (#2451)
11
+
12
+ `ContentList` no longer holds bespoke local state. `src/svelte/content-list-controller.ts`
13
+ is the single adapter every presentation reads from, and one shared
14
+ `DataTableController` (from `@happyvertical/smrt-ui/data`) owns search, filters,
15
+ sorting, page, and selection.
16
+
17
+ | Before | After |
18
+ |--------|-------|
19
+ | local `searchTerm`/`selectedType`/`selectedStatus` runes | controller commands `setSearch` / `setFilters` (stable filter ids `type`, `status`) |
20
+ | `filteredContents` `$derived` per view | `toContentListRows` → `selectContentListRows` → `paginateContentListRows`, computed once for all three modes |
21
+ | bespoke `<table>` markup in compact mode | smrt-ui `DataTable` with the shared columns plus per-column cell snippets |
22
+ | no selection | checkbox selection in every mode via `toggleRowSelection` / `setSelectedRows` |
23
+ | `getViewHref` called inline three times | `resolveContentHref` / `contentListRowActions` (one eligibility source) |
24
+
25
+ Props are unchanged and still exported as `ContentListProps`: `apiBaseUrl`,
26
+ `contents`, `type` (still locks and hides the type filter), `defaultViewMode`
27
+ (still seeds once), `onEdit`, `onDelete`, `onAdd`, `controls`, `getViewHref`.
28
+ New optional props: `loading`, `error`, `onRetry`, and `dataSurface`
29
+ (`{ registry, descriptor? }`).
30
+
31
+ Adapter exports (also re-exported from `./svelte`): `createContentListController`,
32
+ `buildContentListColumns`, `buildContentListSurfaceDescriptor`,
33
+ `toContentListRows`, `selectContentListRows`, `paginateContentListRows`,
34
+ `contentListFilters`, `readContentListFilter`, `applyContentListFilter`,
35
+ `contentListRowActions`, `resolveContentHref`, `selectableContentListRowIds`,
36
+ `resolveSelectedContentListRows`, `resolveSelectedContents`, plus the
37
+ `CONTENT_LIST_*` identity constants.
38
+
39
+ Notes:
40
+
41
+ - Controller modes are all `manual`: the adapter owns search, filters, sorting,
42
+ and paging in **every** presentation, and the compact table receives
43
+ `data={pageRows}` and no `totalRows` at all (see "ContentList owns paging in
44
+ EVERY presentation" below for why that prop is withheld rather than computed).
45
+ Letting DataTable filter locally over already-filtered rows re-ran the
46
+ transform with subtly different semantics (untrimmed search, its own equality
47
+ rules), so the two presentations could disagree. The component clamps the page
48
+ with `controller.clampPage(queryRows.length)`. #2452 replaces the local
49
+ implementation of that transform with a server query behind the same contract.
50
+ - A `type` prop lock is enforced against live state, not just against the prop:
51
+ a data-surface `set-filters` or `reset` command that drops the type filter is
52
+ re-applied by the lock effect (equality-guarded, so it settles). The unlocked
53
+ branch clears the filter only on an actual lock-REMOVAL transition — tracked
54
+ with a non-reactive `previousLockedType` — because clearing on every run would
55
+ also discard a type filter restored from a link or a saved view, and clearing
56
+ never would strand the old lock after the prop went away.
57
+ - Selection may only address durable rows. All three presentations render a
58
+ disabled, explained checkbox for `identified: false` rows, page select-all
59
+ skips them, and a normalization effect re-dispatches `setSelectedRows` without
60
+ any non-durable id, which covers data-surface commands too.
61
+ - Compact mode renders a content-owned `select` column (header + cell snippets)
62
+ instead of passing `selectable` to DataTable. DataTable has no per-row
63
+ selection predicate, so its header select-all addresses the synthetic id of an
64
+ unidentified row; the normalization effect then strips it and the header stays
65
+ indeterminate forever. Because column order is reconciled from the
66
+ controller's known column ids, the structural `select` and `actions` ids are
67
+ part of `CONTENT_LIST_TABLE_COLUMN_IDS` — omit them and selection renders
68
+ behind every data column.
69
+ - Only rendered columns are published to a data surface. `description` is a
70
+ hidden, search-only column so search still reaches the deck; the descriptor
71
+ additionally declares the `id` row-key column, which the surface contract
72
+ requires but the table never renders.
73
+ - Rows without a durable `id` (or repeating one) still render, keyed by
74
+ position, but are marked `identified: false`;
75
+ `resolveSelectedContentListRows` drops them so a bulk action can never act on
76
+ an unaddressable row. `ContentData` has no expiry or site field, so the
77
+ `site` column is derived from `url`/`source`.
78
+ - Column ids are public identifiers and do not always match the model field, so
79
+ the descriptor's `fieldName` comes from an explicit map
80
+ (`publish` → `publish_date`, `updated` → `updatedAt`); the derived `site`
81
+ column advertises no field at all rather than a nonexistent one.
82
+ - Filter values are normalized per column (`type` via `normalizeContentType`,
83
+ everything else via `normalizeContentToken`) through
84
+ `normalizeContentListFilterValue`, and a blank value clears the filter — an
85
+ `equals ''` filter would silently exclude every row.
86
+ - The card presentations render their own page controls (smrt-ui `Pagination`
87
+ dispatching `setPage`) and their own polite refresh status, because DataTable
88
+ — which owns both in compact mode — is not mounted there. A page size arriving
89
+ from a saved view or a surface command would otherwise strand the operator on
90
+ page one, and a refresh over retained rows would be silent.
91
+ - `dataSurface` registers the compact table only. Agent addressability for the
92
+ grid and detailed presentations lands with #2456.
93
+ - Compact mode stays mounted for empty and loading results — DataTable renders
94
+ its own `empty` snippet and loading row — because it owns the mounted surface:
95
+ swapping it for the shared empty panel unregisters the surface, and an agent
96
+ whose own search returned nothing then gets `not_found` on the command that
97
+ would undo it. The shared loading/empty panels are the card presentations'
98
+ only; the `error` branch still replaces the list in every mode, since a load
99
+ failure is host-driven rather than surface-driven.
100
+
101
+ ## ContentList server-backed mode (#2452)
102
+
103
+ `ContentList` gained three optional, independent opt-ins. Omit all three and the
104
+ component behaves exactly as it did after #2451 — `ContentWorkspaceRoute` passes
105
+ `contents` and nothing else and is unchanged.
106
+
107
+ | Prop | Type | Effect |
108
+ |---|---|---|
109
+ | `query` | `ContentListQuerySource` | Rows come from `POST /api/v1/contents/query`; `contents` is ignored |
110
+ | `urlState` | `ContentListUrlStateBinding` | Restore from, and publish to, a query string |
111
+ | `savedViews` | `ContentListSavedViewStore` | Save / apply / delete named views |
112
+
113
+ ### The query seam
114
+
115
+ `query.bind()` is called **once**, during component initialization, and returns
116
+ a `ContentListQueryBinding`. `remoteQuery(collection, transport)` from
117
+ `@happyvertical/smrt-svelte/web` satisfies that interface structurally, so its
118
+ `$effect` teardown is registered in `ContentList`'s own scope and disposed with
119
+ it:
120
+
121
+ ```svelte
122
+ const transport = createContentListQueryTransport({ apiBaseUrl: '/api/v1' });
123
+ <ContentList query={{ bind: () => remoteQuery(collection, transport) }} … />
124
+ ```
125
+
126
+ The binding type is declared **structurally rather than imported**: pulling
127
+ `@happyvertical/smrt-svelte` (and through it `@happyvertical/smrt-web` and
128
+ `@tanstack/db`) into `@happyvertical/smrt-content/svelte` would put the browser
129
+ data runtime in a barrel that public content pages import, defeating the
130
+ code-split boundary that runtime's own AGENTS.md ratifies. smrt-web mirrors
131
+ `smrt-types` for the same reason.
132
+
133
+ ### Three id namespaces
134
+
135
+ A list value crosses three vocabularies that do not agree, and the bridge is
136
+ explicit in both directions:
137
+
138
+ | Namespace | Example | Owner |
139
+ |---|---|---|
140
+ | adapter column id | `updated` | `content-list-controller.ts` |
141
+ | `ContentData` field | `updatedAt` | `mock-smrt-client.ts` |
142
+ | server data-query field id | `updated_at` | the registered `Content` model |
143
+
144
+ `CONTENT_LIST_QUERY_FIELDS` maps column → server field
145
+ (`publish` → `publish_date`, `updated` → `updated_at`), and
146
+ `ROW_FIELD_TO_CONTENT_KEY` maps result row → `ContentData`
147
+ (`updated_at` → `updatedAt`, `created_at` → `createdAt`).
148
+ `content-list-query.test.ts` asserts the first map against the *real*
149
+ `buildContentQuerySchema()` — field ids, declared types, and declared filter
150
+ operators — so a model rename breaks a test rather than a production query.
151
+
152
+ `site` has **no** server field: it is derived in the browser from
153
+ `url`/`source`. A filter or sort on it is dropped from the request and reported.
154
+
155
+ ### The local transform is the local-mode path only
156
+
157
+ In server mode the returned rows *are* the answer — the server already applied
158
+ search, filters, sort, and page — so `selectContentListRows` /
159
+ `paginateContentListRows` must not run over them. Running them again re-filters
160
+ with different semantics (untrimmed search, case-insensitive comparison, a
161
+ `site` predicate the server never saw) and can hide rows the server returned.
162
+ `totalRows` therefore comes from `result.total`, not from the page length.
163
+
164
+ **Clamping moves the operator, so it acts only on a count that is exactly
165
+ right.** Two findings in a row were "the clamp acted on a number that wasn't the
166
+ total", so the rule is stated as a set rather than patched case by case:
167
+
168
+ | Input | Clamp against it? |
169
+ |---|---|
170
+ | local mode row count | yes — the supplied array IS the whole result set |
171
+ | server total, `exact` | yes |
172
+ | server total, `estimated` | **no** — an approximation can hide a page that really exists |
173
+ | server total, `unavailable` | **no** — the count is unknown; `rows.length` is the page, not the total |
174
+ | no response yet for this query | **no** — a page restored from a link survives until its own count arrives |
175
+ | a settled response for a DIFFERENT query | **no** — the binding holds the previous total while a new request is in flight |
176
+ | a page-size change | n/a — `setPageSize` resets the page itself |
177
+
178
+ **ContentList owns paging in EVERY presentation, compact included, and
179
+ deliberately never passes `totalRows` to DataTable.** DataTable runs its own
180
+ `clampPage(totalRows)` effect against the SAME controller, with no authority
181
+ rule and no notion of which query a total belongs to — so for two rounds the
182
+ clamp fixes above were live in the card modes and bypassed in compact, where an
183
+ `estimated` total clamped a real page away and a stale total reset a restored
184
+ one.
185
+
186
+ One prop cannot serve both jobs: `totalRows` drives that clamp AND DataTable's
187
+ pager, so any total authoritative enough to clamp against is also the only total
188
+ the pager can show. Passing an authoritative-only total silences the clamp but
189
+ leaves compact with no pager on an `estimated` total while the card modes still
190
+ show one, and then the two modes disagree about which pages exist — a worse bug
191
+ than the one being fixed. So ContentList keeps one clamp (its own effect, with
192
+ the authority rule) and one pager (its own `<Pagination>`, driven by
193
+ `pageableRowCount`, which accepts an estimate because SHOWING a page and MOVING
194
+ the operator are different questions). The same reasoning already made the
195
+ selection column content-owned in compact mode.
196
+
197
+ **Invariant: the presentations must never disagree about which pages exist or
198
+ which rows are reachable.** Anything about which page is requested, which pages
199
+ are offered, or which rows come back belongs in `describePaging` in the test
200
+ suite, which runs the suite in both `grid` and `compact`. `defaultViewMode`
201
+ defaults to `grid`, so a plain `renderList` test proves only the arm where
202
+ DataTable is not mounted.
203
+
204
+ `estimated` is a deliberate choice, not an oversight. Clamping on an estimate
205
+ strands rows the operator cannot then reach; not clamping can offer a page that
206
+ comes back empty, which is visible and self-correcting. Hiding reachable rows is
207
+ the worse failure — the same reasoning as "truncation only when it narrows".
208
+ Showing a pager is a different question, so `pageableRowCount` still accepts an
209
+ estimate through `contentListQueryTotalValue`; only
210
+ `contentListQueryExactTotal` feeds the clamp.
211
+
212
+ Selection normalization also changes shape in server mode: `rows` is only the
213
+ current page, so membership cannot be the durability test (it would clear the
214
+ selection on every page change). Only the adapter's synthetic ids are stripped.
215
+
216
+ ### One page-size ceiling, one page size
217
+
218
+ `maxPageSize` is resolved ONCE, by `resolveContentListMaxPageSize`, as the
219
+ minimum of every configured limit and the schema's `maxPageLimit` — every
220
+ candidate narrows, so a host that sets `query.request.maxPageSize` as a server
221
+ row budget does not lose it to a looser `urlState.options.maxPageSize`. That one
222
+ number is passed to the controller seed, the URL sanitizer, the saved-view
223
+ sanitizer and the translator, which is what makes the size the UI pages by and
224
+ the size the server applies the same number *by construction*.
225
+
226
+ Two page sizes are unusable in server mode, and BOTH are coerced against live
227
+ state (a saved view, a link, and a data-surface `set-page-size` all arrive after
228
+ mount) and reported:
229
+
230
+ | Live page size | Coerced to | Why |
231
+ |---|---|---|
232
+ | `null` | the seed | the endpoint always applies a limit |
233
+ | `> maxPageSize` | `maxPageSize` | the translator clamps the request, so leaving the controller higher makes `totalPages` compute 1 and hide the page controls |
234
+
235
+ The seed itself is clamped to the ceiling too: a `defaultPageSize` above it
236
+ would otherwise seed a page size the request silently reduces.
237
+
238
+ `pageSize: null` means "show everything", and the query endpoint has no way to
239
+ express that. Left alone, a null page size renders one page of `limit` rows with
240
+ no page controls and no way to reach the rest, so in server mode it is
241
+ **coerced and reported**:
242
+
243
+ - supplying `query` seeds the controller's `pageSize` from
244
+ `query.request.defaultPageSize` (default 50), clamped to the ceiling;
245
+ - that same number is handed to the URL layer as its `defaultPageSize`, so a
246
+ link that omits `size` restores the seed instead of overwriting it with the
247
+ local `null`, and a link this list writes omits `size` while at the default;
248
+ - a null arriving later — `?size=all`, a saved view, a data-surface
249
+ `set-page-size` — is coerced by an effect against live state, and the
250
+ translator reports the same coercion for a direct caller.
251
+
252
+ Both report a `pageSize` / `unpaginated-unsupported` drop in the notice. Local
253
+ mode keeps the historical unpaginated list unchanged; the coercion is
254
+ server-mode-only.
255
+
256
+ ### Search, and what the protocol cannot express
257
+
258
+ The protocol has `filter` only — no search primitive. `state.search` becomes an
259
+ `any` of `like` predicates over the adapter's searchable columns (`title`,
260
+ `description`, `author`), with the wildcards added client-side and the
261
+ operator's own `%`/`_` escaped with a backslash.
262
+
263
+ **Known dialect gap:** PostgreSQL and DuckDB honour a backslash as the default
264
+ `LIKE` escape; SQLite has none and the collection query builder emits no
265
+ `ESCAPE` clause, so on SQLite an escaped `%` matches the two literal characters.
266
+ That fails closed (empty result) rather than open (every row); a portable fix
267
+ needs an `ESCAPE` clause at the collection/SQL boundary.
268
+
269
+ **Second known dialect gap — where an absent value sorts.** A sort term is
270
+ `<field> <ASC|DESC>` and nothing more: `buildOrderBySql` splits the term on
271
+ whitespace and discards everything after the direction, and `DataQuerySort`
272
+ carries only a field and a direction, so `NULLS FIRST`/`NULLS LAST` cannot be
273
+ expressed from this package at all. The placement is therefore whatever the
274
+ dialect defaults to — PostgreSQL and DuckDB put NULLs LAST ascending, SQLite
275
+ puts them FIRST — which changes **which rows land on page one**.
276
+
277
+ The local comparator is aligned to ONE documented choice: **absent sorts last
278
+ ascending and first descending**, matching the SQL standard, the PostgreSQL and
279
+ DuckDB defaults, and therefore the production dialects. A SQLite-backed
280
+ deployment will disagree with local mode on where absent values fall. That
281
+ divergence is dialect-level and not fixable here; fixing it needs a nulls
282
+ placement in the sort term at the `@happyvertical/sql` / `SmrtCollection`
283
+ boundary, and a slot for it in `DataQuerySort`. A test pins SQLite's observed
284
+ ordering so this note cannot rot.
285
+
286
+ `isNull` / `isNotNull` map to a **null-valued `eq` / `ne`**. That is null-aware
287
+ end to end and is not a comparison against NULL: the protocol scalar type admits
288
+ `null`, the request normalizer rejects a null value only for
289
+ `gt`/`gte`/`lt`/`lte`/`like`, and `buildWhere` lowers `{ field: null }` to
290
+ `IS NULL` and `{ 'field !=': null }` to `IS NOT NULL`.
291
+
292
+ Dropped rather than sent, because sending them would fail the *whole* request:
293
+
294
+ - `notContains` — the executor refuses to negate a `like`. The only operator
295
+ with no server expression.
296
+ - `contains` / `startsWith` / `endsWith` on a datetime column — `like` is
297
+ string-only in the request normalizer.
298
+ - an unparseable datetime value — the normalizer requires an RFC 3339 instant.
299
+
300
+ The translator also enforces the normalizer's **input caps**, because exceeding
301
+ one 400s the entire list rather than degrading it. Each is capped and reported:
302
+ at most 100 `in`/`notIn` values, at most 50 filter nodes (counting every
303
+ `all`/`any` container, so search costs 4 and the outer `all` 1), at most 50
304
+ projection fields — each of which must also be a declared projectable field id
305
+ of 1-256 characters, or it is dropped rather than 400ing the list — a request id
306
+ of at most 128 characters, at most 4096 characters per filter value, and 100 000
307
+ bytes for the whole serialized request (100 values of 4096 characters is inside
308
+ every per-value cap and still five times that limit, so the newest filter
309
+ branches are shed until it fits).
310
+
311
+ **Measure what the server measures.** `boundRequestSize` weighs the NORMALIZED
312
+ request, not the body as sent, and the normalizer injects `schema.defaultSort`
313
+ when `sort` is absent — 84 bytes the client had not counted, so a request of
314
+ 99 917-100 000 bytes passed the client check and 400ed at the server. The
315
+ translator therefore always emits `sort`, falling back to
316
+ `CONTENT_LIST_QUERY_DEFAULT_SORT` (the schema default) rather than omitting the
317
+ key. The invariant to preserve: the client's byte count is never smaller than
318
+ core's.
319
+
320
+ **Validate the input, never the value derived from it.** `new Date()` rolls an
321
+ impossible calendar date forward — `2026-02-31` becomes `2026-03-03` — so
322
+ checking the *produced* instant against the RFC 3339 pattern reports nothing and
323
+ the query silently targets a day the link never named. The translator re-derives
324
+ the year/month/day and compares, exactly as `normalizedInstant` does server-side,
325
+ and drops the filter with a report. A date-only value (`2026-02-01`) is still
326
+ accepted and widened to midnight UTC; only impossible days are refused.
327
+
328
+ **Truncation is only permissible when it NARROWS.** Every cap above degrades a
329
+ request rather than failing it, but degrading is only honest when the answer
330
+ stays a subset of the question:
331
+
332
+ Direction is a property of the OPERATOR, not of the site — the same cap narrows
333
+ an `in` and widens a `notIn`, and the same truncation widens a `contains` and
334
+ does neither to an `eq`. So the classification is made per operator, and where a
335
+ change is neither narrowing nor widening the filter is not sent at all:
336
+
337
+ | Bound hit | Effect on the result | What happens |
338
+ |---|---|---|
339
+ | `in` list past 100 values | narrows — a disjunct is removed | capped, reported as `out-of-range` |
340
+ | `notIn` list past 100 values, or carrying an entry that cannot be sent faithfully | **widens** — an exclusion is removed | the whole filter is left out, reported as `filter-widened` |
341
+ | filter-node, OR-branch, or request-byte budget | **widens** — a conjunct is shed | reported as `filter-widened` |
342
+ | a shortened `contains` or `startsWith` pattern, or a shortened search term | **widens** — a shorter pattern matches a superset | sent, reported as `filter-widened` |
343
+ | a shortened `endsWith` pattern | **widens**, but only because the TRAILING characters are the ones kept | sent, reported as `filter-widened` |
344
+ | a shortened scalar comparand (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`) | **neither** — `gt`/`gte` would widen, `lt`/`lte` would NARROW and hide rows, `eq`/`ne` would name a third row entirely | the filter is left out, reported as `filter-widened` |
345
+ | page size, offset, projection count | does not add rows | reported as `out-of-range` |
346
+
347
+ **Every path lands in one of FOUR states, and every one of them reports.** The
348
+ partition is four-way, not three — an `in` list cut to its first hundred values
349
+ is a genuine SUBSET, which is allowed and is not a widening:
350
+
351
+ | State | Example | Reported as |
352
+ |---|---|---|
353
+ | applied exactly | anything within every bound | nothing to report |
354
+ | applied as a true SUPERSET | a shortened `like` pattern | `filter-widened` |
355
+ | applied as a true SUBSET | an `in` list past 100 values, or one carrying an entry that could not be used | `out-of-range` / `unsupported-value` |
356
+ | not applied at all | a `notIn` that cannot be carried, a shed conjunct, a comparand too long to send | `filter-widened` |
357
+
358
+ The fourth column is the point: a state without a report is a fifth state, and
359
+ it is the one that hides. An unusable entry in an `in` list used to be exactly
360
+ that — dropped silently, so the toolbar stated a three-value filter while the
361
+ query asked for one and the rows the operator listed vanished without a word.
362
+ Any new bound must land in one of the four rows above *and* report.
363
+
364
+ **Never emit a value the caller did not name.** The list path already refused to
365
+ send a shortened entry; the scalar path used to send one and merely relabel the
366
+ report, which was affirmatively wrong for half the operators — telling an
367
+ operator the list "may include rows it would have excluded" while `lt` was
368
+ quietly hiding rows. A comparand that cannot be sent whole is now not sent at
369
+ all, which is uniformly a widening and is honestly reported as one.
370
+
371
+ **Which end of a pattern survives decides whether it widens.**
372
+ `boundLikeSource` keeps the LEADING code points for `contains` (`%abc%`) and
373
+ `startsWith` (`abc%`) — anything containing or starting with `abcdef` also
374
+ contains or starts with `abc`. A suffix pattern (`%abc`) is the mirror image, so
375
+ it keeps the TRAILING ones: truncating `%…END` from the front would name a
376
+ different ending, dropping the row the operator asked for while picking up
377
+ unrelated ones. That is neither a superset nor a subset, and no honest label
378
+ exists for it.
379
+
380
+ A widening `notIn` is never PARTIALLY applied. The cap keeps arrival order, so a
381
+ literal `null` past the hundredth entry is the entry shed — and the executor
382
+ then takes its "no null listed" arm and unions `IS NULL` back in, returning
383
+ every absent-valued row the caller listed `null` to exclude. Dropping the filter
384
+ whole is wider still, but it is *honest*: the operator is told the filter is not
385
+ being applied instead of being told it was "clamped", which would imply it still
386
+ works.
387
+
388
+ That distinction is the whole point of the `filter-widened` reason. An operator
389
+ who is told a list was clamped reasonably assumes the answer is a subset of what
390
+ they asked for. Telling them that when the truth is "this now returns rows you
391
+ excluded" is worse than telling them nothing.
392
+
393
+ **Measure in the server's unit.** `dataQueryScalar` tests `value.length`, which
394
+ counts UTF-16 code units, so an astral character costs TWO. The bounders iterate
395
+ by code point — never splitting a surrogate pair — but charge `character.length`
396
+ per code point plus one for an escape. Charging one per code point made a search
397
+ of 4093 ASCII characters plus one emoji measure 4094 client-side and 4097
398
+ server-side: a hard 400 and a whole-list error panel.
399
+
400
+ And mirror the normalizer's **validity rules**, not only its numeric caps. A
401
+ datetime input is held to three separate checks before it is parsed, because
402
+ `Date` will happily accept and silently reinterpret what fails each one:
403
+
404
+ | Check | Refuses | Why |
405
+ |---|---|---|
406
+ | calendar round-trip | `2026-02-31` | `Date` rolls it to March 3, so the query targets a day the link never named |
407
+ | an offset on a time-bearing value | `2026-02-01T00:00` | no offset means `Date` reads it as LOCAL time, so the identical link submits a different instant per viewer — nine hours apart between London and Tokyo |
408
+ | the `T` separator | `2026-02-01 10:00:00Z` | a space leaves the ISO grammar, so `Date` falls through to its implementation-defined legacy parser: the same hazard moved from timezone to engine |
409
+ | four-digit year | `+275760-09-13` | serializes to an expanded-year form the server refuses outright |
410
+
411
+ The rule is "a time must carry an offset", not "a time must state its seconds":
412
+ `2026-02-01T12:30Z` and `2026-02-01T12:30+09:00` each name one instant for every
413
+ reader and are accepted. A bare calendar day (`2026-02-01`) is accepted too —
414
+ `Date` reads it as UTC midnight everywhere. Lower-case `t`/`z` is fine: RFC 3339
415
+ §5.6 permits it, and the value is canonicalized through `toISOString()` before
416
+ it is sent, so the server only ever sees the strict upper-case form it insists
417
+ on.
418
+
419
+ **Canonicalize before parsing, too.** RFC 3339 §5.6 permits a lower-case
420
+ `t`/`z`, but ECMA-262's Date Time String Format specifies the upper-case forms,
421
+ so `new Date('2026-02-01t12:30z')` runs on engine-specific heuristics rather
422
+ than the spec — the same objection that rules out the space separator. V8 reads
423
+ it as UTC, which is luck rather than a guarantee, so the value is upper-cased
424
+ before it reaches `Date`. `toUpperCase()` is locale-independent and this grammar
425
+ has no other letters, so the canonical form means exactly what the input did.
426
+
427
+ **Validate and parse the SAME string.** Checking `text.trim()` and then parsing
428
+ `text` let `"2026-02-01 "` through, and V8's ISO parser rejects the whitespace
429
+ and falls back to the legacy parser — which reads a bare date as LOCAL midnight,
430
+ reintroducing the per-viewer divergence with no drop reported at all. The
431
+ validator returns the exact string to parse rather than a verdict about a
432
+ different one.
433
+
434
+ ### Filter case: tokens are folded, free text is not
435
+
436
+ `normalizeContentListFilterValue` folds case for the **token** columns only —
437
+ `type`, `status`, `state`, whose domain is a fixed lowercase vocabulary the
438
+ model writes. Every other column holds text a person typed, and its value
439
+ becomes a server-side `eq` or `like` compared against the STORED text: folding
440
+ `NASA` to `%nasa%` would miss `NASA Update` on PostgreSQL or DuckDB. The helper
441
+ dates from #2451, when every comparison was local and folding everything was
442
+ harmless; server mode invalidated that assumption.
443
+
444
+ Preserving case is safe locally because the local evaluator compares through
445
+ `textValue()`, which lower-cases BOTH sides at compare time — a case-preserving
446
+ stored value still matches case-insensitively there. The `type`-lock predicate
447
+ (`isContentListFilterExactly`) goes through the same helper, so the lock still
448
+ settles rather than re-dispatching.
449
+
450
+ **Free-text server matching is therefore dialect-dependent, and the protocol
451
+ cannot make it uniform.** There is no `ilike` in the operator vocabulary, so:
452
+
453
+ | Backend | `like` / `eq` on free text |
454
+ |---|---|
455
+ | PostgreSQL, DuckDB | case-SENSITIVE |
456
+ | SQLite | case-insensitive for ASCII |
457
+
458
+ Local mode is case-insensitive everywhere. Do not describe free-text filtering
459
+ as uniform; a portable fix needs a case-insensitive operator at the collection
460
+ boundary.
461
+
462
+ ### NULL semantics are aligned
463
+
464
+ The same shared link must return the same rows whether the host passed a `query`
465
+ or not, so every null-sensitive operator now agrees across the two modes. Two
466
+ different alignments were needed, in opposite directions, because the *meaning*
467
+ differs per operator:
468
+
469
+ | Operator | Server lowering | Alignment |
470
+ |---|---|---|
471
+ | `eq`, `in`, `like` (`contains`/`startsWith`/`endsWith`) | plain predicate | already agreed — both exclude an absent row |
472
+ | `in` with a `null` listed | `IS NULL OR IN (…)` | already aligned |
473
+ | `ne` (value ≠ null) | `IS NULL OR <> v` | server gained the union: "not v" includes rows with no value, as the local evaluator has always said |
474
+ | `notIn` (no `null` listed) | `IS NULL OR (AND of <> v)` | same |
475
+ | `notIn` WITH a `null` listed | `(AND of <> v) AND IS NOT NULL` | **no union**: a listed `null` says absent rows are excluded too |
476
+ | `eq null` / `ne null`, and `in`/`notIn` carrying a listed `null` | `IS NULL` / `IS NOT NULL` | the translator forwards a literal `null` rather than coercing it away, and the local evaluator resolves it through `isAbsentContentValue` |
477
+ | `ne null` (`isNotNull`) | `IS NOT NULL` | untouched — a union would match every row |
478
+ | `gt`, `gte`, `lt`, `lte` — asked for directly | plain predicate | the LOCAL evaluator gained null-awareness |
479
+ | `gt`, `gte`, `lt`, `lte` — reached through `not` | `IS NULL OR <predicate>` | server gained the union, so the negation is a true complement |
480
+ | `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` | the LOCAL evaluator gained null-awareness |
481
+
482
+ ### A value that reads as absent is still a value
483
+
484
+ `null` in a filter list means "and rows with no value at all". It was silently
485
+ discarded in THREE separate layers before this was tracked to one shape: each
486
+ layer tested the value for PRESENCE rather than for VALIDITY, and `null` reads
487
+ as absent to any check written with truthiness. `normalizedFilterValue` is the
488
+ correction — it returns three outcomes, not two: a string, the value `null`, or
489
+ `undefined` for genuinely unusable, which is the only case a caller may drop.
490
+
491
+ Every layer a filter value crosses, and what each does with a `null` entry:
492
+
493
+ | Layer | Behaviour |
494
+ |---|---|
495
+ | data-surface `set-filters` → controller | passes through unchanged |
496
+ | `sanitizeContentListViewState` | **preserves** it (was: silently dropped) |
497
+ | controller state (`setFilters` / `replaceState`) | passes through unchanged |
498
+ | URL serialization | writes the `\0` token |
499
+ | URL parse | reads the token back as `null` |
500
+ | saved-view write (`hydrateDataTableSnapshot`) | JSON, so `null` is native |
501
+ | saved-view read | native |
502
+ | `restoreContentListSavedView` | preserves it (shares the sanitizer above) |
503
+ | translator (`coerceValue`) | **preserves** it (fixed earlier) |
504
+ | request → `normalizeFilter` | accepts a null scalar for `eq`/`ne` |
505
+ | executor (`conditionToDnf`) | lowers a listed null to `IS NULL` / `IS NOT NULL` |
506
+ | local evaluator | `matchesAbsentContentValue` answers the same predicate |
507
+
508
+ **The URL token is collision-free by construction, not by being unlikely.**
509
+ `escapeListEntry` doubles every backslash a real value contains, so the only
510
+ two-character sequences a real entry can begin with are `\\` and `\,`. A LONE
511
+ backslash followed by `0` is therefore unreachable from any string — including
512
+ the literal two characters `\0`, which serialize as `\\0` and read back as
513
+ themselves. It survives percent-encoding as `%5C0`. Writing `null` as the word
514
+ would have been ambiguous with an author actually called "null".
515
+
516
+ A **scalar** null comparand needs no token at all: `equals null` and `isNull`
517
+ are the same predicate — both lower to `eq null`, and the local evaluator
518
+ answers them identically — so it is written as the valueless operator, which
519
+ already has a query-string form.
520
+
521
+ **The bug was never really about `null`.** The same shape catches any value that
522
+ a truthiness check reads as absent, so `0`, `false`, and the empty string are
523
+ tested alongside it. An empty entry WITHIN a list (`?author.in=a,`) is the empty
524
+ string, which is a real value for a column that stores one; an entirely empty
525
+ parameter (`?author.in=`) is a list with no values and is still refused and
526
+ reported. A blank SCALAR still clears the filter, matching
527
+ `applyContentListFilter`.
528
+
529
+ **A literal `null` has to survive the TRANSLATOR too.** `coerceValue` reports a
530
+ null as unusable, which is right for a value that arrived as text and wrong for
531
+ one a caller wrote deliberately: dropping it sent `notIn ['Ada']` for
532
+ `notIn ['Ada', null]` and returned exactly the rows the caller excluded — the
533
+ executor's correct lowering undone one layer up. A literal null is now carried
534
+ through `in`/`notIn` lists and accepted as an `equals`/`notEquals` comparand,
535
+ and the local evaluator resolves it to absence, so a data-surface `set-filters`
536
+ means the same thing in both modes. Such a value cannot arrive from a link or a
537
+ saved view — the sanitizer refuses a non-text filter value — so the reachable
538
+ path is an agent command.
539
+
540
+ **A listed `null` inverts the rule, and getting that wrong is a data leak of the
541
+ worst kind — a filter returning exactly the rows it was asked to exclude.**
542
+ `notIn ['Ada', null]` means "not Ada, and not blank", so it must NOT gain the
543
+ `IS NULL` union. It is reachable from the wire (`normalizeFilter` accepts a null
544
+ list entry) and through `not(in ['Ada', null])`. Without the distinction,
545
+ `in [x, null]` and its own negation both match the absent row: a predicate
546
+ overlapping its negation. The executor tests assert every list shape
547
+ (no null / with null / null-only / inverted) partitions the rows with no overlap
548
+ and no gap.
549
+
550
+ **The ordered comparisons were aligned on the LOCAL side, not the server's.**
551
+ `ContentListRow` flattens every field to display text, so an absent value read
552
+ as `''` — which sorts below everything, made `publish_date lt X` match every
553
+ never-published row, and made `isNull` match nothing at all. The original
554
+ `ContentData` still distinguishes absent from empty, so
555
+ `isAbsentContentValue()` consults it and the null-sensitive operators exclude an
556
+ absent value exactly as SQL's three-valued logic does.
557
+
558
+ **Absence is decided before any text comparison.** The flattened row reads an
559
+ absent value as empty text, so comparing it as text answers a question about
560
+ `''` rather than about absence — and the two differ for every operator once a
561
+ BLANK comparand is involved (`author equals ''` matched an absent row locally
562
+ and no row in SQL; `author notEquals ''` did the reverse).
563
+ `matchesAbsentContentValue` decides from the operator alone, following the SQL
564
+ the executor emits: `equals` matches only a null comparand, `notEquals` matches
565
+ unless the comparand is null, `in`/`notIn` turn on whether the list carries a
566
+ `null`, and `like` and every ordered comparison match nothing.
567
+
568
+ **A display fallback is presentation, never data.** Two columns substitute a
569
+ label when the content carries no value — `type` reads `content` and `title`
570
+ reads `Untitled content`. Comparing the label made `?type=content` return every
571
+ untyped row on a client-array list and none on a server-backed one, and made a
572
+ search for `untitled` find rows whose title is simply missing.
573
+ `comparisonValue()` reads what is stored for those two columns instead: `null`
574
+ when the content has no value, empty text when it is genuinely blank, and the
575
+ flattened text otherwise, which is already faithful. Both local filtering and
576
+ local search go through it. The label keeps rendering; `isNull` is how an
577
+ operator asks for the rows behind it. This is the direction the
578
+ data means: "no publish date" is not "published before X". It also aligns the
579
+ blank-comparand case (`?author.lt=`, `?author.gte=`), where the flattened `''`
580
+ used to compare equal and the two modes disagreed, and it leaves a column that
581
+ genuinely stores `''` (`title`, `name`) comparing as present in both modes.
582
+
583
+ **A negation must be a complement.** `not` is part of the endpoint's accepted
584
+ grammar even though the translator never emits one, and a direct HTTP consumer
585
+ sending `not(gt 'B')` used to get a bare `<= 'B'` — leaving a row with no value
586
+ matching NEITHER the predicate nor its negation. `conditionToDnf` therefore
587
+ takes a `negated` flag: an ordered comparison reached through an odd number of
588
+ `not`s unions `IS NULL`, while the same operator asked for directly does not.
589
+ `eq` reached by negating `ne` gets no union, because the complement of
590
+ "IS NULL OR <> v" is "= v", which excludes NULL by construction. The executor
591
+ tests assert that **every** operator in the grammar partitions the rows against
592
+ its own negation — no overlap, no gap — including through `all`/`any`
593
+ containers and a double negation. `like` is the sole exclusion: negating one is
594
+ refused outright.
595
+
596
+ The `ne`/`notIn` union costs a second DNF branch each, a negated ordered
597
+ comparison costs a second too, and an `all` multiplies,
598
+ so the translator also mirrors `MAX_CONTENT_QUERY_OR_BRANCHES` (128) and drops
599
+ filters before the executor would refuse the whole request. The mirror is exact
600
+ rather than conservative — including the listed-`null` case, which costs one
601
+ branch rather than two — and it handles De Morgan under `not`, so a future
602
+ negating emitter cannot silently under-count.
603
+
604
+ ### Scope is application-supplied and server-derived
605
+
606
+ A `DataQueryRequest` carries no authority. Tenancy is applied inside
607
+ `executeContentQuery` (fail-closed to global rows). Site, organization, or
608
+ workspace narrowing is the host's: the framework models neither site nor
609
+ organization, so a host calls `executeContentQuery(collection, body, { scope })`
610
+ from its own route with conditions derived from the authenticated context —
611
+ never from the request body.
612
+
613
+ **`undefined` and `[]` mean OPPOSITE things, and getting that backwards is an
614
+ authorization fail-open.** A host builds a scope from an allowed-resource list —
615
+ the sites, workspaces, or organizations this principal may see:
616
+
617
+ ```ts
618
+ const scope = permittedSiteIds.map((siteId) => ({ siteId }));
619
+ ```
620
+
621
+ That list is empty exactly when the principal may see nothing. An empty array
622
+ therefore means "the set of permitted conditions is empty" and matches no rows;
623
+ omit the option entirely to mean "this deployment applies no application
624
+ scope". Treating the empty array as absent turned *access to zero sites* into
625
+ *access to every row in the tenant* — the precise failure the two-layer scope
626
+ design exists to prevent, in the seam chosen as the application's scoping
627
+ mechanism.
628
+
629
+ An empty scope lowers to a predicate that matches nothing (`id IS NULL`, false
630
+ for every row on every dialect) rather than throwing: "you may see nothing" is a
631
+ legitimate authorization state, and answering it with a 500 would be wrong. It
632
+ is ANDed into every branch like any other scope condition, so no caller filter
633
+ shape can escape it, and it survives alongside tenancy rather than being
634
+ replaced by it. A malformed condition — an empty OBJECT, which states no
635
+ constraint at all — is still a programming error and still throws.
636
+
637
+ ### Mirrored constants are self-enforcing
638
+
639
+ Every number this package copies from the schema or from
640
+ `@happyvertical/smrt-core` is bound to its source by a test, because a
641
+ hand-copied limit that drifts is the defect this issue kept producing: lowering
642
+ the server's page limit while the client still seeds and pages by the old one
643
+ strands rows with the whole suite green.
644
+
645
+ Where core exports the constant the assertion is direct; where it does not, the
646
+ assertion pins core's observable BEHAVIOUR — the largest value it accepts and
647
+ the smallest it refuses — rather than being skipped. Add a cross-assertion
648
+ alongside any new mirrored number. The field map, projectable fields, operator
649
+ vocabulary and search fields are asserted against the real
650
+ `buildContentQuerySchema()` for the same reason.
651
+
652
+ ### Documented limits
653
+
654
+ - Offset paging only; the schema declares `supports.cursorPagination: false`.
655
+ - `body` is not queryable — it is a document, and the envelope caps a scalar at
656
+ 4096 characters. Read it through `GET /api/v1/contents/{id}`.
657
+ - `metadata` path filtering is unavailable: JSON columns get no filter operators.
658
+ - There is no ETag or version slot in the canonical envelope; `queryFingerprint`
659
+ and `freshness.asOf` serve that role.
660
+ - `maxResultBytes` on a host-supplied schema must be at least
661
+ `CONTENT_QUERY_MIN_RESULT_BYTES`. The row budget is that number minus the
662
+ envelope reserve, so a smaller one leaves nothing for rows and every query
663
+ answers with an empty page flagged `truncated` — indistinguishable from "no
664
+ content matched". It is refused with a plain `Error` naming the minimum
665
+ rather than a `DataQueryValidationError`, because `schema` is trusted adapter
666
+ configuration: a typed validation error would surface as a 400 and blame the
667
+ caller for the host's mistake.
668
+
669
+ **Where this actually runs:** `executeContentQuery` checks it on every
670
+ request, so a misconfigured schema can never serve a query — but through the
671
+ generated route an untyped error becomes an opaque 500, and the message
672
+ naming the minimum reaches only the server log. Call
673
+ `assertContentQuerySchema(schema)` once where the schema is configured to
674
+ fail next to the mistake instead. The check is applied uniformly across query
675
+ modes, `count` included: a schema too small to serve its own row mode is
676
+ misconfigured whatever this request asked for, and letting `count` through
677
+ would hide that until the first rows query. A zero or negative budget is
678
+ refused too — it previously meant "use the default" for rows and "zero
679
+ budget" for facets, which is two answers to one question.
680
+ - **A row is never dropped to fit the byte budget — only shortened.** Offset
681
+ paging advances by the requested LIMIT, not by the number of rows actually
682
+ returned, so a dropped row is skipped on its own page and on every page after
683
+ it: silent, permanent data loss. `DataQueryResult`'s offset page is
684
+ `{ kind, offset, limit, hasMore }` with no next-offset slot, and the
685
+ normalizer refuses a `nextCursor` on an offset page, so a continuation offset
686
+ cannot express "resume at 170" either. Instead the page's budget is allocated
687
+ **floor-first, then max-min fair**: every row is seated at its irreducible
688
+ minimum before any surplus is shared, and the surplus goes to the smallest
689
+ APPETITE (cost minus floor) first. Ordering by current cost instead would let
690
+ a row that needs nothing take an even share while a large, mostly-irreducible
691
+ row is starved below its own floor — declaring a feasible page impossible.
692
+ Seating the floors first makes the guarantee unconditional: if the floors fit,
693
+ the page is served. That guarantee lives in `allocateRowBytes`, which is
694
+ exported and unit-tested, because a row's floor is dominated by the
695
+ projection's key bytes — identical across the rows of one page — so the
696
+ disparity that breaks cost-first ordering is not reachable through
697
+ `executeContentQuery` itself.
698
+
699
+ Within a row, the fields are levelled to a shared byte cap computed in one
700
+ sorted walk (water-filling), not searched for by halving and re-measuring —
701
+ the old search re-serialized the row and every field on every step, which
702
+ turned an ordinary wide page at the default budget into ~9x the cost of the
703
+ same unbounded read. HOW a field gives way depends on its declared type:
704
+
705
+ | declared type | how it gives way | why |
706
+ |---|---|---|
707
+ | `string` | levelled to the shared cap | any prefix of free text is still free text |
708
+ | `datetime` | `null`, or not at all | **no prefix of an RFC 3339 instant is valid** — a shortened one makes the adapter emit a value that breaks the type it declared, and the normalizer then rejects the whole page with `must be an RFC 3339 instant`, blaming the caller |
709
+ | `json` | `null`, or not at all | a document has no incremental shortening |
710
+ | number, boolean, `null` | never | already minimal |
711
+ | the identity field | never | it is the row's address; emptying it fails result normalization |
712
+ | ANY field whose reduced form is no smaller | never | `{}` and `[]` are TWO bytes, so nulling them costs four |
713
+
714
+ That last row is a floor rule as much as a shrink rule. **A row's floor is the
715
+ size it can be reduced TO, so it can never exceed the size the row already
716
+ is.** Taking the reduced size as the floor unconditionally overstates it by
717
+ two bytes per empty-document field per row, which refuses pages that would
718
+ have fitted — and the refusal repeats for that page forever. The invariant
719
+ `floor <= cost` is now stated by `measureRow` and held defensively by
720
+ `allocateRowBytes`, rather than being an accident of a caller's short-circuit.
721
+
722
+ The rule generalizes: **a value may be shortened only when its type accepts
723
+ arbitrary prefixes.** Any format-constrained type added later is all-or-nothing
724
+ by default. An all-or-nothing field is dropped only when the row cannot fit
725
+ with it KEPT — losing a whole value to save a few bytes is a last resort, so a
726
+ 200 KB `metadata` blob goes immediately while a 26-byte `updated_at` survives
727
+ whenever the strings can absorb the difference.
728
+
729
+ Byte accounting is exact at the boundary: each row charges the array separator
730
+ that follows it, so an N-row page is refunded the one separator it does not
731
+ need. Without that refund a page whose true size exactly equals the budget is
732
+ needlessly shortened, or — if it is irreducible — refused outright.
733
+
734
+ Shortening is already a reported state (`truncated` plus its warning) and it
735
+ leaves offset paging exact. Only a page whose FLOORS exceed the budget fails,
736
+ and it fails loudly rather than answering with a page that quietly omits rows.
737
+ - A restored page size is clamped to `maxPageSize` (default
738
+ `CONTENT_LIST_MAX_PAGE_SIZE`, 200, matching the schema's `maxPageLimit`), and
739
+ the clamp is reported. The ceiling is resolved once and applied to **both**
740
+ restore paths — a saved view is not a way around a limit a host set for links.
741
+ - The server bounds its own answer: it shortens over-long values to fit
742
+ `maxResultBytes`, flagging `truncated` with a warning. It does **not** drop
743
+ rows — see the bullet above — precisely because the next page is computed from
744
+ `page * limit`, so a dropped row would be skipped on the following page too.
745
+ `ContentList` reads those flags — from the binding when it exposes them, and
746
+ otherwise off the envelope its own `execute` resolved — and renders them in
747
+ the same notice as the drops.
748
+ - A `json` field (`metadata`, `tags`) is validated as a document, not a scalar,
749
+ and `canonicalJson` rejects the WHOLE result when a nested value breaks any of
750
+ its rules. `executeContentQuery` therefore bounds a JSON document itself —
751
+ 65536-character strings, 1000-item containers, depth 16, finite numbers, no
752
+ cycles, plain values only — flagging `truncated` rather than failing the page.
753
+ It also drops the keys `plainObject` forbids (`__proto__`, `constructor`,
754
+ `prototype`) and builds with a null prototype, because that is the *reachable*
755
+ rule: `metadata` is the documented extension point, it is writable through the
756
+ REST API and through `mirror()` ingestion, and `JSON.parse` of the stored
757
+ column creates an own `__proto__` property — so one row could otherwise make
758
+ every query projecting metadata return 400 for the whole page, permanently.
759
+ - Facet values are held to the SAME shared byte budget as rows. Two text facets
760
+ of 200 distinct 4096-character values are inside every per-value cap and still
761
+ several times the 1 MB result limit; values are dropped and the facet and the
762
+ result are flagged rather than the response being refused.
763
+ - A capped offset moves the caller's page marker AND says so. `?page=9000&size=200`
764
+ caps the request offset at 1 000 000; the translation returns the
765
+ `effectivePage` the request actually reads, and the redirect is reported in
766
+ the notice ("page 9000 cannot be loaded — the list stops at page 5001"). That
767
+ drop is held apart from the rest, because the corrective dispatch re-enters
768
+ the query effect in the same flush and the second translation caps nothing —
769
+ storing it with the others would erase it before it was ever rendered. It
770
+ stands until the operator moves off the page they landed on.
771
+ - The pagers never advertise a page the endpoint cannot fetch. `totalPages` is
772
+ capped at `floor(MAX_OFFSET / pageSize) + 1`. `clampPage` deliberately keeps
773
+ using the TRUE total, so a crafted `?page=` still reaches the query effect and
774
+ gets the notice instead of being silently clamped first. There is only one
775
+ pager to cap, because ContentList renders `<Pagination>` itself in every view
776
+ mode and hands DataTable no total to derive a second page count from.
777
+ - A retry replaces the rendered rows, so it also replaces the completeness
778
+ flags. `retry()`'s envelope is read through the same path as `execute()`'s;
779
+ discarding it left a "rows are missing" notice standing over a complete page.
780
+
781
+ ### URL state and saved views
782
+
783
+ `urlState` is router-agnostic on purpose: `params` is read **once** at
784
+ initialization, and every later change is handed back through `onChange(params,
785
+ state)` with foreign parameters preserved, so a SvelteKit host calls
786
+ `replaceState`, a hash router rewrites the fragment, and a test passes a plain
787
+ `URLSearchParams`. Restoration goes through `applyContentListViewState`, which
788
+ merges over current state rather than dispatching `setSearch`/`setFilters` —
789
+ those would reset the restored page.
790
+
791
+ **INVARIANT: no exported path may apply unvalidated state to a controller.**
792
+ `applyContentListViewState` sanitizes its patch, because it is the one
793
+ application point the package publishes and it is routinely composed with
794
+ untrusted values. That is what makes
795
+ `applyContentListViewState(controller, store.snapshot.state)` safe even though
796
+ the store's read path deliberately returns a raw
797
+ {@link RawContentListViewSnapshot} — keeping the raw payload is what lets
798
+ `restoreContentListSavedView` still report a stale view's drops. Sanitization is
799
+ idempotent, so ContentList's own already-validated patches are unaffected. Only
800
+ the patch is sanitized, never the merged result: the sanitizer never emits
801
+ selection, so sanitizing the merge would clear the operator's selection.
802
+
803
+ Two URL-serialization rules the round trip depends on:
804
+
805
+ - an `in`/`notIn` entry containing the list separator is escaped with a
806
+ backslash on write and unescaped on read, so `author in ["Smith, John"]`
807
+ restores as one value rather than two — a silently *different* query;
808
+ - a parameter is owned (and therefore removable while rewriting) by its **base
809
+ name** only. A host's `facet.contains=` carries a known operator suffix but
810
+ names no ContentList column, so it survives. That is narrower than the
811
+ recognizer in `readContentListViewStateFromSearchParams`, which still reports
812
+ an operator-suffixed unknown column so a crafted `evil.contains=` stays
813
+ visible: reporting a refusal and deleting a parameter are different acts.
814
+
815
+ The `type` prop lock still wins after a restore: the lock effect enforces
816
+ against live state, so a restored `?type=document` is replaced by the locked
817
+ value. The lock is folded into the restored patch rather than re-applied
818
+ afterwards, because the restore replaces the whole filter set and the lock's
819
+ `setFilters` resets paging — a locked list opening `?page=3` would otherwise
820
+ land on page 1, silently.
821
+
822
+ ### A restored value the toolbar cannot show
823
+
824
+ The two toolbar selects publish a display vocabulary
825
+ (`CONTENT_LIST_TYPE_OPTIONS`, `CONTENT_LIST_STATUS_OPTIONS`), but the sanitizer
826
+ accepts any non-blank token, so a link can restore a filter the select has no
827
+ option for — `?status=review` (a real `Content.status`), or a typo like
828
+ `?type=artcile`. The select would show nothing while a live predicate emptied
829
+ the list.
830
+
831
+ Both selects handle it identically, and do two things rather than one:
832
+
833
+ 1. the value is rendered as an extra option, so the toolbar tells the truth
834
+ about what is constraining the list and the operator can clear it;
835
+ 2. it is reported in the notice, so an empty result always has an explanation.
836
+
837
+ A select can only offer a single `equals` value, though, and a link can restore
838
+ much more than that. **INVARIANT: the select's displayed state either matches
839
+ the live predicate exactly, or the operator is told it does not.** Three states,
840
+ all reachable from a shared link:
841
+
842
+ | Live filter | Select shows | Reported |
843
+ |---|---|---|
844
+ | `equals` with a listed value | the value | no |
845
+ | `equals` with an unlisted value (`?status=embargoed`, a typo) | the value, as an extra option | yes |
846
+ | anything else — a list value (`?status.in=draft,review`), a valueless operator (`?status.isNull=1`), an inverted one (`?status.notEquals=draft`), or two filters on one column | a DISABLED summary of the real predicate | yes |
847
+
848
+ The disabled summary carries a U+001F-prefixed sentinel value rather than a
849
+ U+0000 one: the HTML tokenizer rewrites a NUL inside an attribute value to
850
+ U+FFFD, so a server-rendered option would hydrate with a value the select was
851
+ never given and read as no selection — the exact state the summary exists to
852
+ prevent. A client-only mount bypasses attribute parsing, so only a parse
853
+ round-trip test catches it.
854
+
855
+ The third row is the one that matters: a value-only read reports nothing for a
856
+ list value and reports `draft` for `notEquals draft` — the exact inverse of the
857
+ query. `readContentListSelectFilter` is operator-aware and is the seam that
858
+ keeps the control from misstating the query; `readContentListFilter` stays
859
+ value-only and must not drive a control. Choosing any real option replaces every
860
+ filter on that column, so the operator is never stuck.
861
+
862
+ `review` is now offered outright. `deleted` is deliberately not: that is the
863
+ trash lifecycle (#2454), and offering it here would imply a restore/purge
864
+ affordance this list does not have. `Content.type` is freeform, so its option
865
+ list is a display vocabulary rather than the model's domain.
866
+
867
+ Everything a restore or a translation refused is reported in one dismissible
868
+ notice rather than thrown — a stale link or an out-of-date saved view must still
869
+ open the list, minus the parts that are no longer meaningful.