@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.
- package/AGENTS.md +9 -0
- package/agents/content-list.md +869 -0
- package/dist/content-query.d.ts +310 -0
- package/dist/content-query.d.ts.map +1 -0
- package/dist/contents.d.ts +22 -0
- package/dist/contents.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +672 -4
- package/dist/index.js.map +1 -1
- package/dist/manifest.json +22 -2
- package/dist/smrt-knowledge.json +38 -5
- package/dist/svelte/components/ContentList.svelte +1580 -281
- package/dist/svelte/components/ContentList.svelte.d.ts +54 -2
- package/dist/svelte/components/ContentList.svelte.d.ts.map +1 -1
- package/dist/svelte/content-list-controller.d.ts +306 -0
- package/dist/svelte/content-list-controller.d.ts.map +1 -0
- package/dist/svelte/content-list-controller.js +921 -0
- package/dist/svelte/content-list-query.d.ts +498 -0
- package/dist/svelte/content-list-query.d.ts.map +1 -0
- package/dist/svelte/content-list-query.js +1294 -0
- package/dist/svelte/content-list-saved-views.d.ts +172 -0
- package/dist/svelte/content-list-saved-views.d.ts.map +1 -0
- package/dist/svelte/content-list-saved-views.js +298 -0
- package/dist/svelte/content-list-url-state.d.ts +211 -0
- package/dist/svelte/content-list-url-state.d.ts.map +1 -0
- package/dist/svelte/content-list-url-state.js +856 -0
- package/dist/svelte/i18n.contribution.d.ts +55 -0
- package/dist/svelte/i18n.contribution.d.ts.map +1 -1
- package/dist/svelte/i18n.contribution.js +57 -0
- package/dist/svelte/index.d.ts +5 -0
- package/dist/svelte/index.d.ts.map +1 -1
- package/dist/svelte/index.js +10 -0
- 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.
|