@happyvertical/smrt-content 0.45.0 → 0.45.2

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.
@@ -1,1054 +1,268 @@
1
- <!-- Module doc for packages/content/AGENTS.md. Linked from the Modules table there. -->
2
-
3
- # ContentList: shared adapter, query state, saved views, and lifecycle
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
- ## Trash, restore, and permanent deletion (#2454)
11
-
12
- Lifecycle controls are an opt-in server-authoritative seam. Pass a
13
- `ContentListLifecycleBinding` as `lifecycle`; set `lifecycleMode="trash"` for a
14
- trash view. Trash mode locks the live status predicate to `deleted`, hides the
15
- ordinary status control, removes legacy edit/delete buttons, and exposes
16
- restore, permanent-delete, and empty-trash actions. Omit the binding and
17
- ContentList keeps its existing `onDelete` behavior.
18
-
19
- The browser never treats rendered rows as mutation authority. It sends a
20
- selection reference, canonical query for `all-matching`, expected count, and
21
- server revision through `createContentListLifecycleTransport()`. Preview,
22
- initial apply, and every replay use distinct per-attempt request ids. Apply and
23
- replay retain the opaque confirmation and idempotency authority envelope, while
24
- the result's `auditReference` continues to identify the original execution.
25
- Changing the query or selection invalidates the preview; expired, stale-revision,
26
- row-revision, query-fingerprint, token, or matching-count failures require a
27
- new preview.
28
-
29
- Mount `createContentListLifecycleRoute()` from
30
- `@happyvertical/smrt-content/server` at the transport's default
31
- `POST /api/v1/contents/lifecycle` path. The host supplies a callback that
32
- authenticates each native `Request` and returns its current principal/tenant
33
- action context. The route accepts only the three lifecycle action ids and
34
- delegates to the same `createContentListActionAdapter()` instance used by agent
35
- and bulk-workflow callers; it never owns a second authorization or mutation
36
- path. Invalid JSON, missing authentication, and internal failures return
37
- bounded error envelopes without exposing private error text.
38
-
39
- Lifecycle action ids and restore input are stable:
40
-
41
- | action | eligible server state | input / consequence |
1
+ <!-- Module doc for packages/content/AGENTS.md. -->
2
+
3
+ # ContentList contracts
4
+
5
+ Read for changes to `src/svelte/content-list-*.ts`, `ContentList.svelte`, or the
6
+ server content-query/action adapters. Browser exports come from `/svelte`;
7
+ principal-bound query/action factories come from `/server`.
8
+
9
+ ## Ownership and integration
10
+
11
+ - `content-list-controller.ts` is the shared adapter over `DataTableController`.
12
+ Search, filters, sort, paging, selection, columns, and action eligibility have
13
+ one implementation across grid, detailed, and compact presentations. Controller
14
+ modes are manual. Local arrays use `toContentListRows`, `selectContentListRows`,
15
+ then `paginateContentListRows`; server rows must never be transformed again.
16
+ - `query`, `urlState`, `savedViews`, `lifecycle`, `jobs`, and `dataSurface` are
17
+ optional bindings. Without query, rows come from `contents`; with it, contents
18
+ is ignored. `type` locks/hides the type filter; `defaultViewMode` seeds once.
19
+ Existing callbacks (`onEdit`, `onDelete`, `onAdd`, `getViewHref`) remain usable.
20
+ - `query.bind()` runs once during component initialization so a structural
21
+ `remoteQuery(collection, transport)` binding registers teardown in that scope.
22
+ Do not import smrt-svelte/web or its browser data runtime into the content
23
+ Svelte barrel: public content pages depend on that code-split boundary.
24
+ - The default query transport targets `POST /api/v1/contents/query`.
25
+ `CONTENT_LIST_QUERY_FIELDS` maps column IDs to server fields;
26
+ `ROW_FIELD_TO_CONTENT_KEY` maps response fields to ContentData. For example,
27
+ column `updated`, client `updatedAt`, and server `updated_at` differ.
28
+ `publish` maps to `publish_date`. Derived `site` comes from url/source and has
29
+ no server field; server filtering/sorting on it is dropped and reported.
30
+ - Missing/duplicate durable IDs still render with positional keys but are
31
+ `identified: false`. Disable and explain their selection controls. Page select
32
+ all and normalization exclude synthetic IDs; server selection must survive
33
+ page changes, so current-page membership is not a durability test.
34
+ - Compact uses content-owned `select` and `actions` columns, included in
35
+ `CONTENT_LIST_TABLE_COLUMN_IDS`. DataTable's generic selection cannot enforce
36
+ per-row eligibility. Resolve links/actions through the shared adapter.
37
+
38
+ ## Paging and freshness
39
+
40
+ ContentList owns the pager in every presentation and passes no `totalRows` to
41
+ DataTable, whose independent clamp would mutate the shared controller without
42
+ knowing which query produced a count.
43
+
44
+ - Clamp on the local full-array count or an exact total for the active settled
45
+ server query only. Never clamp on an estimate, unavailable total, page length,
46
+ previous-query response, or before the current response arrives. An estimate
47
+ may still drive the displayed pager. Test paging in grid and compact modes.
48
+ - Resolve one page-size ceiling with `resolveContentListMaxPageSize`: the minimum
49
+ of configured limits and schema limit (default 200). Use it for controller
50
+ seed, URL/saved-view sanitizers, translator, and live state. Server seed defaults
51
+ to 50; null page size becomes the seed and oversized values become the ceiling,
52
+ with reported drops. Local mode may remain unpaginated. Omitted URL size must
53
+ restore the server seed. `setPageSize` resets page.
54
+ - Offset only; maximum offset is 1,000,000. Report capping and update the marker
55
+ to `effectivePage`; retain the notice until the operator leaves that page.
56
+ Pager maximum is `floor(MAX_OFFSET / pageSize) + 1`, while clamp uses the true
57
+ total so crafted out-of-range pages receive the explanatory notice.
58
+ - Subscribe after the first request; query changes rebind the exact subscription.
59
+ Offline retains usable stale rows. Online calls live `reconnect()` (refresh
60
+ before resubscription), otherwise `refresh()`. Refresh errors are inline over
61
+ retained rows. Show loading, retry, refreshing, last update, offline, truncation,
62
+ and warnings across presentations. Read completeness flags from retry envelopes
63
+ as well as execute envelopes; a successful retry replaces old flags.
64
+
65
+ ## Query authority and protocol
66
+
67
+ `executeContentQuery()` applies tenancy, failing closed to global rows. Host
68
+ application scope comes from authenticated context, never request body; caller
69
+ filters only narrow it. Omitted scope means no extra application restriction;
70
+ `scope: []` means no permitted rows (`id IS NULL`). AND scope into every query
71
+ branch alongside tenancy. Empty object conditions are configuration errors.
72
+
73
+ Keep field maps, projectable fields, operator/search vocabulary, and mirrored
74
+ limits asserted against `buildContentQuerySchema()` and core constants or their
75
+ observable acceptance boundaries.
76
+
77
+ - No cursor paging, body queries, metadata-path filters, or ETag/version slot.
78
+ Read body through GET by ID; use queryFingerprint/freshness.asOf for freshness.
79
+ - Search emits an `any` of `like` over title/description/author, escaping caller
80
+ `%`/`_`. Drop and report unsupported `notContains`, datetime LIKE operators,
81
+ invalid datetime inputs, unknown projection fields, and derived site queries.
82
+ - Bound normalized request bytes, including default sort. Always emit sort
83
+ (fall back to `CONTENT_LIST_QUERY_DEFAULT_SORT`) so client accounting is not
84
+ smaller than the normalizer's. Limits: 100 list values, 50 filter nodes
85
+ (containers count; search costs four), 128 DNF branches, 50 projection fields
86
+ with 1–256-character IDs, 128-character request ID, 4096 UTF-16 units per scalar,
87
+ 100,000 serialized request bytes. Count escapes and surrogate pairs without
88
+ splitting code points. DNF costs include null unions and De Morgan negation.
89
+ - Preserve comparands exactly or omit/report the filter. Oversized/unusable `in`
90
+ entries may be removed as a reported subset (`out-of-range`/`unsupported-value`).
91
+ Never partially apply `notIn`: drop/report `filter-widened`. Shed conjuncts for
92
+ node/branch/byte budgets with that same report. Shortened LIKE/search patterns
93
+ report widening; retain leading code points for contains/startsWith and trailing
94
+ ones for endsWith. Never shorten scalar eq/ne/ordered comparands into a value
95
+ the caller did not name. Every change is either exact, a reported superset,
96
+ a reported subset, or a reported omitted predicate.
97
+ - Datetimes require a real calendar day, four-digit year, T separator and an
98
+ offset when time is present. Date-only means midnight UTC; minutes without
99
+ seconds and lowercase t/z are accepted. Trim and uppercase before parsing,
100
+ validate that same string, and calendar-round-trip to reject rolled dates.
101
+
102
+ ### Comparison semantics and dialect limits
103
+
104
+ Fold token filter values (type/status/state); preserve free-text case. Local
105
+ text comparison is case-insensitive; server free-text matching follows its SQL
106
+ operator/dialect. PostgreSQL/DuckDB LIKE is case-sensitive; SQLite LIKE is ASCII
107
+ case-insensitive. Do not promise uniform free-text matching.
108
+
109
+ SQLite's LIKE lacks the default backslash escape used by PostgreSQL/DuckDB;
110
+ escaped wildcards can fail closed there. Fixing portability needs SQL-boundary
111
+ ESCAPE support. Null placement is likewise dialect-dependent: local sorts absent
112
+ last ascending/first descending, matching PostgreSQL/DuckDB; SQLite defaults
113
+ oppositely. The query sort grammar cannot express NULLS FIRST/LAST.
114
+
115
+ - Read absence from original ContentData, not flattened display text. Type's
116
+ `content` and title's `Untitled content` fallbacks are presentation only; missing
117
+ values compare/search as null, genuinely blank strings remain present.
118
+ - eq/in/LIKE exclude absent values unless null is explicitly listed. ne of a
119
+ non-null value and notIn without null include absence. notIn containing null
120
+ excludes absence. eq/ne null lower to IS NULL/IS NOT NULL. Direct ordered
121
+ comparisons exclude absence; their negations include it. Every supported
122
+ predicate and its negation must partition rows without gaps or overlap,
123
+ including nested all/any and double negation. Negated LIKE is refused.
124
+ - Preserve literal null through sanitization, saved views, translation and local
125
+ evaluation; only undefined denotes unusable input. Test null, false, zero and
126
+ empty strings. Blank scalar clears a filter; an empty entry within a list is
127
+ real text, but a wholly empty list parameter is refused/reported.
128
+
129
+ ## Result budgets
130
+
131
+ Call `assertContentQuerySchema(schema)` at configuration time. Every executor
132
+ mode, including count, rejects maxResultBytes below
133
+ `CONTENT_QUERY_MIN_RESULT_BYTES`; this is a host error, not caller validation.
134
+
135
+ - Never drop rows to fit bytes: offset advances by requested limit, so omitted
136
+ rows would become unreachable. Seat every row's irreducible floor first, then
137
+ distribute surplus max-min fairly by appetite (cost minus floor).
138
+ `allocateRowBytes` guarantees a page whenever floors fit; otherwise fail loudly.
139
+ Preserve `floor <= cost` and refund the final unused array separator at exact
140
+ byte boundaries.
141
+ - Within rows use one sorted water-filling walk. Strings may shorten; datetime
142
+ and JSON values are all-or-nothing (null only if smaller). Numbers, booleans,
143
+ null, and identity never shrink. Prefer shortening free text before losing a
144
+ format-constrained value; new constrained types are all-or-nothing by default.
145
+ Report shortening with truncated/warnings.
146
+ - Bound JSON documents before result normalization: strings 65,536 characters,
147
+ containers 1,000 items, depth 16; finite numbers, no cycles, plain values only.
148
+ Drop forbidden __proto__/constructor/prototype keys and build with null
149
+ prototypes. Report truncation rather than allowing one stored document to
150
+ invalidate an entire page.
151
+ - Facets share the result byte budget; drop excess facet values and flag both
152
+ facet and result. Per-value caps alone do not bound the envelope.
153
+
154
+ ## URL state and saved views
155
+
156
+ Read urlState.params once; later onChange(params, state) preserves foreign
157
+ parameters and lets the host own routing. `applyContentListViewState` is the
158
+ public application boundary: sanitize every patch, including raw saved snapshots,
159
+ then merge over current state without resetting restored page. Sanitize the patch,
160
+ not the merged result, because selection is intentionally absent from sanitizer
161
+ output. Keep raw saved-view payloads so stale-field drops remain reportable.
162
+
163
+ - Escape list separators and double literal backslashes. List null uses the
164
+ collision-free `\0` token; a literal backslash-zero string is doubled. Scalar
165
+ null serializes through isNull/isNotNull. Preserve foreign parameters by base
166
+ name, even when they have a known operator suffix; unknown suffixed columns
167
+ may still be reported as refused without being deleted.
168
+ - The type lock applies to live state, including agent reset/filter commands.
169
+ Fold it into restored patches to preserve paging. Clear it only on an actual
170
+ lock-removal transition, not every unlocked render.
171
+ - Toolbar selects must state the live predicate honestly. Unlisted equals values
172
+ render as extra options and produce a notice. Lists, non-equals operators, and
173
+ multiple predicates render a disabled summary and notice. Use operator-aware
174
+ `readContentListSelectFilter`, not value-only `readContentListFilter`.
175
+ Choosing a real option replaces all filters on that column. Summary sentinel
176
+ uses U+001F; NUL changes during HTML parsing and breaks hydration.
177
+ - Review is an ordinary status; deleted belongs exclusively to opt-in trash
178
+ mode. Type options are display vocabulary, not the freeform model domain.
179
+ Restore/translation refusals appear in one dismissible notice; stale state
180
+ should still open with the refused parts omitted.
181
+
182
+ ## Shared mutations and lifecycle
183
+
184
+ Human transports and agent/bulk callers terminate at the same
185
+ `createContentListActionAdapter()` instance. Preview/apply is principal-bound;
186
+ rendered rows are never mutation authority.
187
+
188
+ - Supported workflows: trash, draft, review submission, publish, archive, restore,
189
+ automated review, formatting, categorization, optimization. Applications inject
190
+ formatting/optimization handlers; handlers mutate supplied objects but must
191
+ not save them because the adapter owns guarded writes.
192
+ - Explicit/current-page/all-matching selections share a default 200-row cap.
193
+ Match server and browser maxSelectionSize. All-matching is canonical query,
194
+ fingerprint and exact count, never browser-page expansion. Above-cap requests
195
+ fail closed, including forged/agent requests; UI asks the operator to narrow.
196
+ - Read-only preview returns resolved scope/count/labels/eligibility/consequences.
197
+ Apply uses opaque token and idempotency key, re-resolves query under live
198
+ principal/tenant, rechecks tool/operation permissions and eligibility, and
199
+ compares aggregate updated_at revision. Query/count/membership/row drift fails
200
+ before mutation. Each save also guards updated_at atomically at UPDATE.
201
+ Required projection: id, title, status, updated_at, even under host overrides.
202
+ - Publication workflows require every snapshot read permission, explicitly
203
+ including contentassets:read and assets:read. Keep assertions aligned with
204
+ `ContentVersion.createSnapshot()`; embedded adapters rely on them without RLS.
205
+ - Declare workflowStorage capability. PostgreSQL, SQLite, ordinary DuckDB and
206
+ non-immediate exported-file modes are supported. JSON default/immediate and
207
+ DuckDB immediate writes are rejected at setup because exports cannot roll back.
208
+ - Partial results separate accepted/skipped/failed rows; clear only accepted IDs.
209
+ Failures retain selection. Refresh the exact query and expose progress,
210
+ exceptions, representative labels, resourceType/resourceId and auditReference.
211
+
212
+ Lifecycle binding enables trash controls; trash mode locks deleted and hides
213
+ ordinary status/edit/delete controls. Without it legacy onDelete remains.
214
+ `createContentListLifecycleRoute()` mounts POST /api/v1/contents/lifecycle;
215
+ its host authenticates every native Request and supplies current action context.
216
+ Only move-to-trash, restore, permanent-delete are accepted; malformed/auth/internal
217
+ failures use bounded envelopes without private error text.
218
+
219
+ | Action | Eligible state | Contract |
42
220
  |---|---|---|
43
- | `move-to-trash` | not already `deleted` | soft-deletes content into trash |
44
- | `restore` | `deleted` | `{ status: 'draft' \| 'review' \| 'published' }`; the server re-runs permission and publish-readiness checks |
45
- | `permanent-delete` | `deleted` | irreversible deletion; the operator must type the exact server-resolved count |
46
-
47
- Permanent deletion binds the row's `updated_at` revision into the final delete.
48
- On PostgreSQL that comparison is part of the `DELETE` predicate and any cascade
49
- shares its transaction; embedded adapters compare under the framework's shared
50
- write queue before cascading. A writer racing preview/apply therefore produces
51
- `row_revision_drifted` instead of deleting newer content.
52
-
53
- The result details expose `count`, accepted/skipped/failed counts,
54
- representative labels, canonical subtype-aware outcomes
55
- (`resourceType`/`resourceId`), and `auditReference`. Applying a partial result
56
- removes only accepted ids from selection, keeping skipped and failed rows
57
- selected for inspection or retry. The exact query refreshes after success;
58
- progress, partial exceptions, and the audit reference remain visible in a
59
- polite live region. Empty trash is available only when an exact server count
60
- and canonical query are present, so a page-sized guess cannot stand in for its
61
- scope. `maxSelectionSize` defaults to 200; larger matching sets are announced
62
- and fail closed until the operator narrows the query, matching the server's
63
- bounded one-preview selection contract.
64
-
65
- ## Realtime, freshness, and background workflows (#2455)
66
-
67
- A server-backed list consumes the complete reactive seam exposed by
68
- `remoteQuery(...)`: initial loading, stale-while-refreshing rows, error, retry,
69
- last-updated time, and an optional query-shaped live subscription. ContentList
70
- subscribes after its first request. The remote-query controller carries that
71
- live intent across query changes, so search/filter/page changes rebind to the
72
- new exact query rather than subscribing to the whole collection. Browser
73
- `offline` keeps usable rows on screen and announces their freshness; `online`
74
- calls the live handle's `reconnect()` (which refreshes the exact request before
75
- resubscribing), or falls back to `refresh()` when the transport has no live
76
- handle. A refresh failure over existing rows is an inline error and never
77
- replaces them with an empty/error-only screen.
78
-
79
- Long-running actions share a framework-free
80
- `createContentListJobController()`. Give that controller to action controls and
81
- pass it to ContentList as `jobs`. Its `submit({ actionId, submissionKey,
82
- target }, start)` method coalesces duplicate active submission keys before
83
- calling `start`, publishes the accepted job id/progress, and leaves a rejected
84
- submission failed until an explicit retry. Server progress enters through
85
- `update(job)`; terminal state cannot be reverted by an out-of-order progress
86
- event. Row-targeted pending work disables that row's selection/edit/delete
87
- controls.
88
-
89
- Job targets are deliberately precise:
90
-
91
- - `{ kind: 'rows', rowIds }` refreshes only when at least one affected row is
92
- visible;
93
- - `{ kind: 'query', queryKey }` refreshes only when the key equals
94
- `contentListQueryRequestKey(activeRequest)`.
95
-
96
- Only a transition to `succeeded` triggers that refresh. Failed jobs remain
97
- visible with their error and retry affordance, so error recovery cannot report
98
- success or refresh as though the action applied. The tracker is exported from
99
- `@happyvertical/smrt-content/svelte` for the bulk-action slice to reuse rather
100
- than creating a second pending-state machine.
101
- ## Cross-page bulk workflows (#2453)
102
-
103
- Server-backed lists can opt into one shared preview/apply workflow contract for
104
- human and agent callers. The browser integration is exported from
105
- `@happyvertical/smrt-content/svelte`; the principal-bound adapter is exported
106
- from `@happyvertical/smrt-content/server`.
107
-
108
- The supported workflows are move to trash, mark draft, submit for review,
109
- publish, archive, restore, automated review, body formatting, categorization,
110
- and optimization. Applications inject body-formatting and optimization
111
- handlers because those AI pipelines are application-owned; the content package
112
- does not guess a model or rewrite policy.
113
-
114
- Important invariants:
115
-
116
- - `all-matching` is the canonical server query plus its query fingerprint and
117
- exact count. It is never expanded from the IDs loaded in the browser.
118
- - Preview is read-only and returns the resolved scope, exact count,
119
- representative labels, eligibility outcomes, and consequences. Apply uses
120
- the opaque preview token and an idempotency key.
121
- - Apply resolves the query again under the same principal and tenant scope,
122
- rechecks tool/operation authorization and eligibility, and compares the
123
- aggregate `updated_at` row revision. Every content save also includes that
124
- revision in its database `UPDATE`, so an edit racing the final write fails
125
- atomically instead of being overwritten. Application formatting/optimization
126
- handlers mutate the supplied object but must not call `save()`; the adapter
127
- owns the guarded save. Query, count, membership, or row drift returns a stale
128
- failure before mutation.
129
- - Publication-producing workflows require every read used to construct their
130
- version snapshot, including `contentassets:read` and `assets:read`. Keep those
131
- explicit catalog assertions in sync with `ContentVersion.createSnapshot()`;
132
- they are the authorization boundary on adapters without PostgreSQL RLS.
133
- - Workflow queries must project `id`, `title`, `status`, and `updated_at` so the
134
- server can bind labels, eligibility, and row revisions. The standard
135
- ContentList query projection already includes them; a host-supplied projection
136
- override must retain them.
137
- - The default workflow cap is 200 rows across explicit, current-page, and
138
- all-matching scopes. Set the same `maxSelectionSize` on the server adapter and
139
- browser workflow binding when overriding it. The UI does not offer
140
- all-matching selection above that cap, and the server returns
141
- `limit_exceeded` for forged or agent requests above it.
142
- - Automated review, formatting, and optimization are background actions. A
143
- successful apply returns a job ID; the queue callback repeats the guarded
144
- apply, and idempotency prevents duplicate execution. Hosts must supply
145
- `resolveDeferredPrincipal` to re-resolve the complete live persona/TenantAgent
146
- binding before queued mutations; missing, mismatched, or incomplete bindings
147
- fail closed. Hosts can configure the
148
- authenticated `jobStatusPath` transport (or supply `client.status`) so the
149
- list can keep an identical intent locked while queued/running, reconcile
150
- terminal row outcomes, and unlock a failed or cancelled intent for retry.
151
- Selection controls are disabled while a status request is in flight, and the
152
- captured intent is rechecked before any returned outcomes are reconciled.
153
- - Foreground results report accepted, skipped, and failed rows separately. The
154
- client clears only accepted IDs; a failed request never reports success or
155
- clears selection.
156
-
157
- `createContentListWorkflowTransport()` supplies the default JSON transport.
158
- Hosts that already have an RPC layer can instead provide a structural workflow
159
- client with the same `preview(request)` and `apply(request)` methods, plus the
160
- optional `status(jobId)` method when background progress is shown. Both paths
161
- must terminate at the same `createContentListActionAdapter()` instance used by
162
- agent actions; separate human-only mutation handlers would bypass the shared
163
- preview and authorization contract.
164
-
165
- The server adapter also requires a `workflowStorage` capability declaration.
166
- PostgreSQL, SQLite, ordinary DuckDB, and non-immediate exported-file modes are
167
- supported. JSON's default/immediate mode and DuckDB `writeStrategy:
168
- 'immediate'` fail before adapter setup because those SDK modes export writes
169
- before the surrounding transaction commits and cannot roll the file back.
170
-
171
- ## ContentList migration (#2451)
172
-
173
- `ContentList` no longer holds bespoke local state. `src/svelte/content-list-controller.ts`
174
- is the single adapter every presentation reads from, and one shared
175
- `DataTableController` (from `@happyvertical/smrt-ui/data`) owns search, filters,
176
- sorting, page, and selection.
177
-
178
- | Before | After |
179
- |--------|-------|
180
- | local `searchTerm`/`selectedType`/`selectedStatus` runes | controller commands `setSearch` / `setFilters` (stable filter ids `type`, `status`) |
181
- | `filteredContents` `$derived` per view | `toContentListRows` → `selectContentListRows` → `paginateContentListRows`, computed once for all three modes |
182
- | bespoke `<table>` markup in compact mode | smrt-ui `DataTable` with the shared columns plus per-column cell snippets |
183
- | no selection | checkbox selection in every mode via `toggleRowSelection` / `setSelectedRows` |
184
- | `getViewHref` called inline three times | `resolveContentHref` / `contentListRowActions` (one eligibility source) |
185
-
186
- Props are unchanged and still exported as `ContentListProps`: `apiBaseUrl`,
187
- `contents`, `type` (still locks and hides the type filter), `defaultViewMode`
188
- (still seeds once), `onEdit`, `onDelete`, `onAdd`, `controls`, `getViewHref`.
189
- New optional props: `loading`, `error`, `onRetry`, and `dataSurface`
190
- (`{ registry, descriptor? }`).
191
-
192
- Adapter exports (also re-exported from `./svelte`): `createContentListController`,
193
- `buildContentListColumns`, `buildContentListSurfaceDescriptor`,
194
- `toContentListRows`, `selectContentListRows`, `paginateContentListRows`,
195
- `contentListFilters`, `readContentListFilter`, `applyContentListFilter`,
196
- `contentListRowActions`, `resolveContentHref`, `selectableContentListRowIds`,
197
- `resolveSelectedContentListRows`, `resolveSelectedContents`, plus the
198
- `CONTENT_LIST_*` identity constants.
199
-
200
- ## Agent data-surface conformance (#2456)
201
-
202
- `dataSurface` publishes the mounted list's readable columns, supported query
203
- operators, visible state, selection scope, freshness, and only the actions its
204
- mounted workflow/lifecycle bindings can execute. Visible commands run through
205
- the registry's revision check; their acknowledgements describe the accepted
206
- controller revision, while a disconnected registry refuses them.
207
-
208
- For silent agent reads, register
209
- `createContentListDataSurfaceDefinition({ collection, scope })` from
210
- `@happyvertical/smrt-content/server` in the server-owned
211
- `createDataSurfaceTools()` catalog. The factory resolves both the collection
212
- and optional application scope from the live principal context, then delegates
213
- rows, counts, facets, and offset continuations to `executeContentQuery()`.
214
- Tenant, hidden, and sensitive fields remain outside discovery and projection;
215
- the model request can only narrow the trusted scope. Durable actions continue
216
- through `createContentListActionAdapter()` and its principal-bound
217
- preview/apply contract rather than through the browser registry.
218
-
219
- Notes:
220
-
221
- - Controller modes are all `manual`: the adapter owns search, filters, sorting,
222
- and paging in **every** presentation, and the compact table receives
223
- `data={pageRows}` and no `totalRows` at all (see "ContentList owns paging in
224
- EVERY presentation" below for why that prop is withheld rather than computed).
225
- Letting DataTable filter locally over already-filtered rows re-ran the
226
- transform with subtly different semantics (untrimmed search, its own equality
227
- rules), so the two presentations could disagree. The component clamps the page
228
- with `controller.clampPage(queryRows.length)`. #2452 replaces the local
229
- implementation of that transform with a server query behind the same contract.
230
- - A `type` prop lock is enforced against live state, not just against the prop:
231
- a data-surface `set-filters` or `reset` command that drops the type filter is
232
- re-applied by the lock effect (equality-guarded, so it settles). The unlocked
233
- branch clears the filter only on an actual lock-REMOVAL transition — tracked
234
- with a non-reactive `previousLockedType` — because clearing on every run would
235
- also discard a type filter restored from a link or a saved view, and clearing
236
- never would strand the old lock after the prop went away.
237
- - Selection may only address durable rows. All three presentations render a
238
- disabled, explained checkbox for `identified: false` rows, page select-all
239
- skips them, and a normalization effect re-dispatches `setSelectedRows` without
240
- any non-durable id, which covers data-surface commands too.
241
- - Compact mode renders a content-owned `select` column (header + cell snippets)
242
- instead of passing `selectable` to DataTable. DataTable has no per-row
243
- selection predicate, so its header select-all addresses the synthetic id of an
244
- unidentified row; the normalization effect then strips it and the header stays
245
- indeterminate forever. Because column order is reconciled from the
246
- controller's known column ids, the structural `select` and `actions` ids are
247
- part of `CONTENT_LIST_TABLE_COLUMN_IDS` — omit them and selection renders
248
- behind every data column.
249
- - Only rendered columns are published to a data surface. `description` is a
250
- hidden, search-only column so search still reaches the deck; the descriptor
251
- additionally declares the `id` row-key column, which the surface contract
252
- requires but the table never renders.
253
- - Rows without a durable `id` (or repeating one) still render, keyed by
254
- position, but are marked `identified: false`;
255
- `resolveSelectedContentListRows` drops them so a bulk action can never act on
256
- an unaddressable row. `ContentData` has no expiry or site field, so the
257
- `site` column is derived from `url`/`source`.
258
- - Column ids are public identifiers and do not always match the model field, so
259
- the descriptor's `fieldName` comes from an explicit map
260
- (`publish` → `publish_date`, `updated` → `updatedAt`); the derived `site`
261
- column advertises no field at all rather than a nonexistent one.
262
- - Filter values are normalized per column (`type` via `normalizeContentType`,
263
- everything else via `normalizeContentToken`) through
264
- `normalizeContentListFilterValue`, and a blank value clears the filter — an
265
- `equals ''` filter would silently exclude every row.
266
- - The card presentations render their own page controls (smrt-ui `Pagination`
267
- dispatching `setPage`) and their own polite refresh status, because DataTable
268
- — which owns both in compact mode — is not mounted there. A page size arriving
269
- from a saved view or a surface command would otherwise strand the operator on
270
- page one, and a refresh over retained rows would be silent.
271
- - `dataSurface` registers at the `ContentList` boundary, independently of its
272
- renderer. Grid, detailed, compact, loading, empty, and error presentations
273
- therefore retain one stable surface identity. A `set-view` command updates
274
- the published `viewMode` and revision before the renderer changes, so its
275
- acknowledgement always describes the accepted presentation.
276
- - The mounted snapshot publishes the controller state and normalized selection
277
- together with the active query fingerprint, total, presentation, and
278
- freshness (`stale`, `refreshing`, `offline`, last update, truncation, and
279
- warnings). Context-only changes advance the same revision stream as visible
280
- controller commands; stale command revisions are refused by the registry.
281
- - Discovery publishes per-column search/filter/sort operators. A server-backed
282
- list narrows the derived `site` column to read-only because the content query
283
- translator cannot soundly project, filter, or sort that display value.
284
-
285
- ## ContentList server-backed mode (#2452)
286
-
287
- `ContentList` gained three optional, independent opt-ins. Omit all three and the
288
- component behaves exactly as it did after #2451 — `ContentWorkspaceRoute` passes
289
- `contents` and nothing else and is unchanged.
290
-
291
- | Prop | Type | Effect |
292
- |---|---|---|
293
- | `query` | `ContentListQuerySource` | Rows come from `POST /api/v1/contents/query`; `contents` is ignored |
294
- | `urlState` | `ContentListUrlStateBinding` | Restore from, and publish to, a query string |
295
- | `savedViews` | `ContentListSavedViewStore` | Save / apply / delete named views |
296
-
297
- ### The query seam
298
-
299
- `query.bind()` is called **once**, during component initialization, and returns
300
- a `ContentListQueryBinding`. `remoteQuery(collection, transport)` from
301
- `@happyvertical/smrt-svelte/web` satisfies that interface structurally, so its
302
- `$effect` teardown is registered in `ContentList`'s own scope and disposed with
303
- it:
304
-
305
- ```svelte
306
- const transport = createContentListQueryTransport({ apiBaseUrl: '/api/v1' });
307
- <ContentList query={{ bind: () => remoteQuery(collection, transport) }} … />
308
- ```
309
-
310
- The binding type is declared **structurally rather than imported**: pulling
311
- `@happyvertical/smrt-svelte` (and through it `@happyvertical/smrt-web` and
312
- `@tanstack/db`) into `@happyvertical/smrt-content/svelte` would put the browser
313
- data runtime in a barrel that public content pages import, defeating the
314
- code-split boundary that runtime's own AGENTS.md ratifies. smrt-web mirrors
315
- `smrt-types` for the same reason.
316
-
317
- ### Three id namespaces
318
-
319
- A list value crosses three vocabularies that do not agree, and the bridge is
320
- explicit in both directions:
321
-
322
- | Namespace | Example | Owner |
323
- |---|---|---|
324
- | adapter column id | `updated` | `content-list-controller.ts` |
325
- | `ContentData` field | `updatedAt` | `mock-smrt-client.ts` |
326
- | server data-query field id | `updated_at` | the registered `Content` model |
327
-
328
- `CONTENT_LIST_QUERY_FIELDS` maps column → server field
329
- (`publish` → `publish_date`, `updated` → `updated_at`), and
330
- `ROW_FIELD_TO_CONTENT_KEY` maps result row → `ContentData`
331
- (`updated_at` → `updatedAt`, `created_at` → `createdAt`).
332
- `content-list-query.test.ts` asserts the first map against the *real*
333
- `buildContentQuerySchema()` — field ids, declared types, and declared filter
334
- operators — so a model rename breaks a test rather than a production query.
335
-
336
- `site` has **no** server field: it is derived in the browser from
337
- `url`/`source`. A filter or sort on it is dropped from the request and reported.
338
-
339
- ### The local transform is the local-mode path only
340
-
341
- In server mode the returned rows *are* the answer — the server already applied
342
- search, filters, sort, and page — so `selectContentListRows` /
343
- `paginateContentListRows` must not run over them. Running them again re-filters
344
- with different semantics (untrimmed search, case-insensitive comparison, a
345
- `site` predicate the server never saw) and can hide rows the server returned.
346
- `totalRows` therefore comes from `result.total`, not from the page length.
347
-
348
- **Clamping moves the operator, so it acts only on a count that is exactly
349
- right.** Two findings in a row were "the clamp acted on a number that wasn't the
350
- total", so the rule is stated as a set rather than patched case by case:
351
-
352
- | Input | Clamp against it? |
353
- |---|---|
354
- | local mode row count | yes — the supplied array IS the whole result set |
355
- | server total, `exact` | yes |
356
- | server total, `estimated` | **no** — an approximation can hide a page that really exists |
357
- | server total, `unavailable` | **no** — the count is unknown; `rows.length` is the page, not the total |
358
- | no response yet for this query | **no** — a page restored from a link survives until its own count arrives |
359
- | a settled response for a DIFFERENT query | **no** — the binding holds the previous total while a new request is in flight |
360
- | a page-size change | n/a — `setPageSize` resets the page itself |
361
-
362
- **ContentList owns paging in EVERY presentation, compact included, and
363
- deliberately never passes `totalRows` to DataTable.** DataTable runs its own
364
- `clampPage(totalRows)` effect against the SAME controller, with no authority
365
- rule and no notion of which query a total belongs to — so for two rounds the
366
- clamp fixes above were live in the card modes and bypassed in compact, where an
367
- `estimated` total clamped a real page away and a stale total reset a restored
368
- one.
369
-
370
- One prop cannot serve both jobs: `totalRows` drives that clamp AND DataTable's
371
- pager, so any total authoritative enough to clamp against is also the only total
372
- the pager can show. Passing an authoritative-only total silences the clamp but
373
- leaves compact with no pager on an `estimated` total while the card modes still
374
- show one, and then the two modes disagree about which pages exist — a worse bug
375
- than the one being fixed. So ContentList keeps one clamp (its own effect, with
376
- the authority rule) and one pager (its own `<Pagination>`, driven by
377
- `pageableRowCount`, which accepts an estimate because SHOWING a page and MOVING
378
- the operator are different questions). The same reasoning already made the
379
- selection column content-owned in compact mode.
380
-
381
- **Invariant: the presentations must never disagree about which pages exist or
382
- which rows are reachable.** Anything about which page is requested, which pages
383
- are offered, or which rows come back belongs in `describePaging` in the test
384
- suite, which runs the suite in both `grid` and `compact`. `defaultViewMode`
385
- defaults to `grid`, so a plain `renderList` test proves only the arm where
386
- DataTable is not mounted.
387
-
388
- `estimated` is a deliberate choice, not an oversight. Clamping on an estimate
389
- strands rows the operator cannot then reach; not clamping can offer a page that
390
- comes back empty, which is visible and self-correcting. Hiding reachable rows is
391
- the worse failure — the same reasoning as "truncation only when it narrows".
392
- Showing a pager is a different question, so `pageableRowCount` still accepts an
393
- estimate through `contentListQueryTotalValue`; only
394
- `contentListQueryExactTotal` feeds the clamp.
395
-
396
- Selection normalization also changes shape in server mode: `rows` is only the
397
- current page, so membership cannot be the durability test (it would clear the
398
- selection on every page change). Only the adapter's synthetic ids are stripped.
399
-
400
- ### One page-size ceiling, one page size
401
-
402
- `maxPageSize` is resolved ONCE, by `resolveContentListMaxPageSize`, as the
403
- minimum of every configured limit and the schema's `maxPageLimit` — every
404
- candidate narrows, so a host that sets `query.request.maxPageSize` as a server
405
- row budget does not lose it to a looser `urlState.options.maxPageSize`. That one
406
- number is passed to the controller seed, the URL sanitizer, the saved-view
407
- sanitizer and the translator, which is what makes the size the UI pages by and
408
- the size the server applies the same number *by construction*.
409
-
410
- Two page sizes are unusable in server mode, and BOTH are coerced against live
411
- state (a saved view, a link, and a data-surface `set-page-size` all arrive after
412
- mount) and reported:
413
-
414
- | Live page size | Coerced to | Why |
415
- |---|---|---|
416
- | `null` | the seed | the endpoint always applies a limit |
417
- | `> maxPageSize` | `maxPageSize` | the translator clamps the request, so leaving the controller higher makes `totalPages` compute 1 and hide the page controls |
418
-
419
- The seed itself is clamped to the ceiling too: a `defaultPageSize` above it
420
- would otherwise seed a page size the request silently reduces.
421
-
422
- `pageSize: null` means "show everything", and the query endpoint has no way to
423
- express that. Left alone, a null page size renders one page of `limit` rows with
424
- no page controls and no way to reach the rest, so in server mode it is
425
- **coerced and reported**:
426
-
427
- - supplying `query` seeds the controller's `pageSize` from
428
- `query.request.defaultPageSize` (default 50), clamped to the ceiling;
429
- - that same number is handed to the URL layer as its `defaultPageSize`, so a
430
- link that omits `size` restores the seed instead of overwriting it with the
431
- local `null`, and a link this list writes omits `size` while at the default;
432
- - a null arriving later — `?size=all`, a saved view, a data-surface
433
- `set-page-size` — is coerced by an effect against live state, and the
434
- translator reports the same coercion for a direct caller.
435
-
436
- Both report a `pageSize` / `unpaginated-unsupported` drop in the notice. Local
437
- mode keeps the historical unpaginated list unchanged; the coercion is
438
- server-mode-only.
439
-
440
- ### Search, and what the protocol cannot express
441
-
442
- The protocol has `filter` only — no search primitive. `state.search` becomes an
443
- `any` of `like` predicates over the adapter's searchable columns (`title`,
444
- `description`, `author`), with the wildcards added client-side and the
445
- operator's own `%`/`_` escaped with a backslash.
446
-
447
- **Known dialect gap:** PostgreSQL and DuckDB honour a backslash as the default
448
- `LIKE` escape; SQLite has none and the collection query builder emits no
449
- `ESCAPE` clause, so on SQLite an escaped `%` matches the two literal characters.
450
- That fails closed (empty result) rather than open (every row); a portable fix
451
- needs an `ESCAPE` clause at the collection/SQL boundary.
452
-
453
- **Second known dialect gap — where an absent value sorts.** A sort term is
454
- `<field> <ASC|DESC>` and nothing more: `buildOrderBySql` splits the term on
455
- whitespace and discards everything after the direction, and `DataQuerySort`
456
- carries only a field and a direction, so `NULLS FIRST`/`NULLS LAST` cannot be
457
- expressed from this package at all. The placement is therefore whatever the
458
- dialect defaults to — PostgreSQL and DuckDB put NULLs LAST ascending, SQLite
459
- puts them FIRST — which changes **which rows land on page one**.
460
-
461
- The local comparator is aligned to ONE documented choice: **absent sorts last
462
- ascending and first descending**, matching the SQL standard, the PostgreSQL and
463
- DuckDB defaults, and therefore the production dialects. A SQLite-backed
464
- deployment will disagree with local mode on where absent values fall. That
465
- divergence is dialect-level and not fixable here; fixing it needs a nulls
466
- placement in the sort term at the `@happyvertical/sql` / `SmrtCollection`
467
- boundary, and a slot for it in `DataQuerySort`. A test pins SQLite's observed
468
- ordering so this note cannot rot.
469
-
470
- `isNull` / `isNotNull` map to a **null-valued `eq` / `ne`**. That is null-aware
471
- end to end and is not a comparison against NULL: the protocol scalar type admits
472
- `null`, the request normalizer rejects a null value only for
473
- `gt`/`gte`/`lt`/`lte`/`like`, and `buildWhere` lowers `{ field: null }` to
474
- `IS NULL` and `{ 'field !=': null }` to `IS NOT NULL`.
475
-
476
- Dropped rather than sent, because sending them would fail the *whole* request:
477
-
478
- - `notContains` — the executor refuses to negate a `like`. The only operator
479
- with no server expression.
480
- - `contains` / `startsWith` / `endsWith` on a datetime column — `like` is
481
- string-only in the request normalizer.
482
- - an unparseable datetime value — the normalizer requires an RFC 3339 instant.
483
-
484
- The translator also enforces the normalizer's **input caps**, because exceeding
485
- one 400s the entire list rather than degrading it. Each is capped and reported:
486
- at most 100 `in`/`notIn` values, at most 50 filter nodes (counting every
487
- `all`/`any` container, so search costs 4 and the outer `all` 1), at most 50
488
- projection fields — each of which must also be a declared projectable field id
489
- of 1-256 characters, or it is dropped rather than 400ing the list — a request id
490
- of at most 128 characters, at most 4096 characters per filter value, and 100 000
491
- bytes for the whole serialized request (100 values of 4096 characters is inside
492
- every per-value cap and still five times that limit, so the newest filter
493
- branches are shed until it fits).
494
-
495
- **Measure what the server measures.** `boundRequestSize` weighs the NORMALIZED
496
- request, not the body as sent, and the normalizer injects `schema.defaultSort`
497
- when `sort` is absent — 84 bytes the client had not counted, so a request of
498
- 99 917-100 000 bytes passed the client check and 400ed at the server. The
499
- translator therefore always emits `sort`, falling back to
500
- `CONTENT_LIST_QUERY_DEFAULT_SORT` (the schema default) rather than omitting the
501
- key. The invariant to preserve: the client's byte count is never smaller than
502
- core's.
503
-
504
- **Validate the input, never the value derived from it.** `new Date()` rolls an
505
- impossible calendar date forward — `2026-02-31` becomes `2026-03-03` — so
506
- checking the *produced* instant against the RFC 3339 pattern reports nothing and
507
- the query silently targets a day the link never named. The translator re-derives
508
- the year/month/day and compares, exactly as `normalizedInstant` does server-side,
509
- and drops the filter with a report. A date-only value (`2026-02-01`) is still
510
- accepted and widened to midnight UTC; only impossible days are refused.
511
-
512
- **Truncation is only permissible when it NARROWS.** Every cap above degrades a
513
- request rather than failing it, but degrading is only honest when the answer
514
- stays a subset of the question:
515
-
516
- Direction is a property of the OPERATOR, not of the site — the same cap narrows
517
- an `in` and widens a `notIn`, and the same truncation widens a `contains` and
518
- does neither to an `eq`. So the classification is made per operator, and where a
519
- change is neither narrowing nor widening the filter is not sent at all:
520
-
521
- | Bound hit | Effect on the result | What happens |
522
- |---|---|---|
523
- | `in` list past 100 values | narrows — a disjunct is removed | capped, reported as `out-of-range` |
524
- | `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` |
525
- | filter-node, OR-branch, or request-byte budget | **widens** — a conjunct is shed | reported as `filter-widened` |
526
- | a shortened `contains` or `startsWith` pattern, or a shortened search term | **widens** — a shorter pattern matches a superset | sent, reported as `filter-widened` |
527
- | a shortened `endsWith` pattern | **widens**, but only because the TRAILING characters are the ones kept | sent, reported as `filter-widened` |
528
- | 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` |
529
- | page size, offset, projection count | does not add rows | reported as `out-of-range` |
530
-
531
- **Every path lands in one of FOUR states, and every one of them reports.** The
532
- partition is four-way, not three — an `in` list cut to its first hundred values
533
- is a genuine SUBSET, which is allowed and is not a widening:
534
-
535
- | State | Example | Reported as |
536
- |---|---|---|
537
- | applied exactly | anything within every bound | nothing to report |
538
- | applied as a true SUPERSET | a shortened `like` pattern | `filter-widened` |
539
- | 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` |
540
- | not applied at all | a `notIn` that cannot be carried, a shed conjunct, a comparand too long to send | `filter-widened` |
541
-
542
- The fourth column is the point: a state without a report is a fifth state, and
543
- it is the one that hides. An unusable entry in an `in` list used to be exactly
544
- that — dropped silently, so the toolbar stated a three-value filter while the
545
- query asked for one and the rows the operator listed vanished without a word.
546
- Any new bound must land in one of the four rows above *and* report.
547
-
548
- **Never emit a value the caller did not name.** The list path already refused to
549
- send a shortened entry; the scalar path used to send one and merely relabel the
550
- report, which was affirmatively wrong for half the operators — telling an
551
- operator the list "may include rows it would have excluded" while `lt` was
552
- quietly hiding rows. A comparand that cannot be sent whole is now not sent at
553
- all, which is uniformly a widening and is honestly reported as one.
554
-
555
- **Which end of a pattern survives decides whether it widens.**
556
- `boundLikeSource` keeps the LEADING code points for `contains` (`%abc%`) and
557
- `startsWith` (`abc%`) — anything containing or starting with `abcdef` also
558
- contains or starts with `abc`. A suffix pattern (`%abc`) is the mirror image, so
559
- it keeps the TRAILING ones: truncating `%…END` from the front would name a
560
- different ending, dropping the row the operator asked for while picking up
561
- unrelated ones. That is neither a superset nor a subset, and no honest label
562
- exists for it.
563
-
564
- A widening `notIn` is never PARTIALLY applied. The cap keeps arrival order, so a
565
- literal `null` past the hundredth entry is the entry shed — and the executor
566
- then takes its "no null listed" arm and unions `IS NULL` back in, returning
567
- every absent-valued row the caller listed `null` to exclude. Dropping the filter
568
- whole is wider still, but it is *honest*: the operator is told the filter is not
569
- being applied instead of being told it was "clamped", which would imply it still
570
- works.
571
-
572
- That distinction is the whole point of the `filter-widened` reason. An operator
573
- who is told a list was clamped reasonably assumes the answer is a subset of what
574
- they asked for. Telling them that when the truth is "this now returns rows you
575
- excluded" is worse than telling them nothing.
576
-
577
- **Measure in the server's unit.** `dataQueryScalar` tests `value.length`, which
578
- counts UTF-16 code units, so an astral character costs TWO. The bounders iterate
579
- by code point — never splitting a surrogate pair — but charge `character.length`
580
- per code point plus one for an escape. Charging one per code point made a search
581
- of 4093 ASCII characters plus one emoji measure 4094 client-side and 4097
582
- server-side: a hard 400 and a whole-list error panel.
583
-
584
- And mirror the normalizer's **validity rules**, not only its numeric caps. A
585
- datetime input is held to three separate checks before it is parsed, because
586
- `Date` will happily accept and silently reinterpret what fails each one:
587
-
588
- | Check | Refuses | Why |
589
- |---|---|---|
590
- | calendar round-trip | `2026-02-31` | `Date` rolls it to March 3, so the query targets a day the link never named |
591
- | 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 |
592
- | 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 |
593
- | four-digit year | `+275760-09-13` | serializes to an expanded-year form the server refuses outright |
594
-
595
- The rule is "a time must carry an offset", not "a time must state its seconds":
596
- `2026-02-01T12:30Z` and `2026-02-01T12:30+09:00` each name one instant for every
597
- reader and are accepted. A bare calendar day (`2026-02-01`) is accepted too —
598
- `Date` reads it as UTC midnight everywhere. Lower-case `t`/`z` is fine: RFC 3339
599
- §5.6 permits it, and the value is canonicalized through `toISOString()` before
600
- it is sent, so the server only ever sees the strict upper-case form it insists
601
- on.
602
-
603
- **Canonicalize before parsing, too.** RFC 3339 §5.6 permits a lower-case
604
- `t`/`z`, but ECMA-262's Date Time String Format specifies the upper-case forms,
605
- so `new Date('2026-02-01t12:30z')` runs on engine-specific heuristics rather
606
- than the spec — the same objection that rules out the space separator. V8 reads
607
- it as UTC, which is luck rather than a guarantee, so the value is upper-cased
608
- before it reaches `Date`. `toUpperCase()` is locale-independent and this grammar
609
- has no other letters, so the canonical form means exactly what the input did.
610
-
611
- **Validate and parse the SAME string.** Checking `text.trim()` and then parsing
612
- `text` let `"2026-02-01 "` through, and V8's ISO parser rejects the whitespace
613
- and falls back to the legacy parser — which reads a bare date as LOCAL midnight,
614
- reintroducing the per-viewer divergence with no drop reported at all. The
615
- validator returns the exact string to parse rather than a verdict about a
616
- different one.
617
-
618
- ### Filter case: tokens are folded, free text is not
619
-
620
- `normalizeContentListFilterValue` folds case for the **token** columns only —
621
- `type`, `status`, `state`, whose domain is a fixed lowercase vocabulary the
622
- model writes. Every other column holds text a person typed, and its value
623
- becomes a server-side `eq` or `like` compared against the STORED text: folding
624
- `NASA` to `%nasa%` would miss `NASA Update` on PostgreSQL or DuckDB. The helper
625
- dates from #2451, when every comparison was local and folding everything was
626
- harmless; server mode invalidated that assumption.
627
-
628
- Preserving case is safe locally because the local evaluator compares through
629
- `textValue()`, which lower-cases BOTH sides at compare time — a case-preserving
630
- stored value still matches case-insensitively there. The `type`-lock predicate
631
- (`isContentListFilterExactly`) goes through the same helper, so the lock still
632
- settles rather than re-dispatching.
633
-
634
- **Free-text server matching is therefore dialect-dependent, and the protocol
635
- cannot make it uniform.** There is no `ilike` in the operator vocabulary, so:
636
-
637
- | Backend | `like` / `eq` on free text |
638
- |---|---|
639
- | PostgreSQL, DuckDB | case-SENSITIVE |
640
- | SQLite | case-insensitive for ASCII |
641
-
642
- Local mode is case-insensitive everywhere. Do not describe free-text filtering
643
- as uniform; a portable fix needs a case-insensitive operator at the collection
644
- boundary.
645
-
646
- ### NULL semantics are aligned
647
-
648
- The same shared link must return the same rows whether the host passed a `query`
649
- or not, so every null-sensitive operator now agrees across the two modes. Two
650
- different alignments were needed, in opposite directions, because the *meaning*
651
- differs per operator:
652
-
653
- | Operator | Server lowering | Alignment |
654
- |---|---|---|
655
- | `eq`, `in`, `like` (`contains`/`startsWith`/`endsWith`) | plain predicate | already agreed — both exclude an absent row |
656
- | `in` with a `null` listed | `IS NULL OR IN (…)` | already aligned |
657
- | `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 |
658
- | `notIn` (no `null` listed) | `IS NULL OR (AND of <> v)` | same |
659
- | `notIn` WITH a `null` listed | `(AND of <> v) AND IS NOT NULL` | **no union**: a listed `null` says absent rows are excluded too |
660
- | `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` |
661
- | `ne null` (`isNotNull`) | `IS NOT NULL` | untouched — a union would match every row |
662
- | `gt`, `gte`, `lt`, `lte` — asked for directly | plain predicate | the LOCAL evaluator gained null-awareness |
663
- | `gt`, `gte`, `lt`, `lte` — reached through `not` | `IS NULL OR <predicate>` | server gained the union, so the negation is a true complement |
664
- | `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` | the LOCAL evaluator gained null-awareness |
665
-
666
- ### A value that reads as absent is still a value
667
-
668
- `null` in a filter list means "and rows with no value at all". It was silently
669
- discarded in THREE separate layers before this was tracked to one shape: each
670
- layer tested the value for PRESENCE rather than for VALIDITY, and `null` reads
671
- as absent to any check written with truthiness. `normalizedFilterValue` is the
672
- correction — it returns three outcomes, not two: a string, the value `null`, or
673
- `undefined` for genuinely unusable, which is the only case a caller may drop.
674
-
675
- Every layer a filter value crosses, and what each does with a `null` entry:
676
-
677
- | Layer | Behaviour |
678
- |---|---|
679
- | data-surface `set-filters` → controller | passes through unchanged |
680
- | `sanitizeContentListViewState` | **preserves** it (was: silently dropped) |
681
- | controller state (`setFilters` / `replaceState`) | passes through unchanged |
682
- | URL serialization | writes the `\0` token |
683
- | URL parse | reads the token back as `null` |
684
- | saved-view write (`hydrateDataTableSnapshot`) | JSON, so `null` is native |
685
- | saved-view read | native |
686
- | `restoreContentListSavedView` | preserves it (shares the sanitizer above) |
687
- | translator (`coerceValue`) | **preserves** it (fixed earlier) |
688
- | request → `normalizeFilter` | accepts a null scalar for `eq`/`ne` |
689
- | executor (`conditionToDnf`) | lowers a listed null to `IS NULL` / `IS NOT NULL` |
690
- | local evaluator | `matchesAbsentContentValue` answers the same predicate |
691
-
692
- **The URL token is collision-free by construction, not by being unlikely.**
693
- `escapeListEntry` doubles every backslash a real value contains, so the only
694
- two-character sequences a real entry can begin with are `\\` and `\,`. A LONE
695
- backslash followed by `0` is therefore unreachable from any string — including
696
- the literal two characters `\0`, which serialize as `\\0` and read back as
697
- themselves. It survives percent-encoding as `%5C0`. Writing `null` as the word
698
- would have been ambiguous with an author actually called "null".
699
-
700
- A **scalar** null comparand needs no token at all: `equals null` and `isNull`
701
- are the same predicate — both lower to `eq null`, and the local evaluator
702
- answers them identically — so it is written as the valueless operator, which
703
- already has a query-string form.
704
-
705
- **The bug was never really about `null`.** The same shape catches any value that
706
- a truthiness check reads as absent, so `0`, `false`, and the empty string are
707
- tested alongside it. An empty entry WITHIN a list (`?author.in=a,`) is the empty
708
- string, which is a real value for a column that stores one; an entirely empty
709
- parameter (`?author.in=`) is a list with no values and is still refused and
710
- reported. A blank SCALAR still clears the filter, matching
711
- `applyContentListFilter`.
712
-
713
- **A literal `null` has to survive the TRANSLATOR too.** `coerceValue` reports a
714
- null as unusable, which is right for a value that arrived as text and wrong for
715
- one a caller wrote deliberately: dropping it sent `notIn ['Ada']` for
716
- `notIn ['Ada', null]` and returned exactly the rows the caller excluded — the
717
- executor's correct lowering undone one layer up. A literal null is now carried
718
- through `in`/`notIn` lists and accepted as an `equals`/`notEquals` comparand,
719
- and the local evaluator resolves it to absence, so a data-surface `set-filters`
720
- means the same thing in both modes. Such a value cannot arrive from a link or a
721
- saved view — the sanitizer refuses a non-text filter value — so the reachable
722
- path is an agent command.
723
-
724
- **A listed `null` inverts the rule, and getting that wrong is a data leak of the
725
- worst kind — a filter returning exactly the rows it was asked to exclude.**
726
- `notIn ['Ada', null]` means "not Ada, and not blank", so it must NOT gain the
727
- `IS NULL` union. It is reachable from the wire (`normalizeFilter` accepts a null
728
- list entry) and through `not(in ['Ada', null])`. Without the distinction,
729
- `in [x, null]` and its own negation both match the absent row: a predicate
730
- overlapping its negation. The executor tests assert every list shape
731
- (no null / with null / null-only / inverted) partitions the rows with no overlap
732
- and no gap.
733
-
734
- **The ordered comparisons were aligned on the LOCAL side, not the server's.**
735
- `ContentListRow` flattens every field to display text, so an absent value read
736
- as `''` — which sorts below everything, made `publish_date lt X` match every
737
- never-published row, and made `isNull` match nothing at all. The original
738
- `ContentData` still distinguishes absent from empty, so
739
- `isAbsentContentValue()` consults it and the null-sensitive operators exclude an
740
- absent value exactly as SQL's three-valued logic does.
741
-
742
- **Absence is decided before any text comparison.** The flattened row reads an
743
- absent value as empty text, so comparing it as text answers a question about
744
- `''` rather than about absence — and the two differ for every operator once a
745
- BLANK comparand is involved (`author equals ''` matched an absent row locally
746
- and no row in SQL; `author notEquals ''` did the reverse).
747
- `matchesAbsentContentValue` decides from the operator alone, following the SQL
748
- the executor emits: `equals` matches only a null comparand, `notEquals` matches
749
- unless the comparand is null, `in`/`notIn` turn on whether the list carries a
750
- `null`, and `like` and every ordered comparison match nothing.
751
-
752
- **A display fallback is presentation, never data.** Two columns substitute a
753
- label when the content carries no value — `type` reads `content` and `title`
754
- reads `Untitled content`. Comparing the label made `?type=content` return every
755
- untyped row on a client-array list and none on a server-backed one, and made a
756
- search for `untitled` find rows whose title is simply missing.
757
- `comparisonValue()` reads what is stored for those two columns instead: `null`
758
- when the content has no value, empty text when it is genuinely blank, and the
759
- flattened text otherwise, which is already faithful. Both local filtering and
760
- local search go through it. The label keeps rendering; `isNull` is how an
761
- operator asks for the rows behind it. This is the direction the
762
- data means: "no publish date" is not "published before X". It also aligns the
763
- blank-comparand case (`?author.lt=`, `?author.gte=`), where the flattened `''`
764
- used to compare equal and the two modes disagreed, and it leaves a column that
765
- genuinely stores `''` (`title`, `name`) comparing as present in both modes.
766
-
767
- **A negation must be a complement.** `not` is part of the endpoint's accepted
768
- grammar even though the translator never emits one, and a direct HTTP consumer
769
- sending `not(gt 'B')` used to get a bare `<= 'B'` — leaving a row with no value
770
- matching NEITHER the predicate nor its negation. `conditionToDnf` therefore
771
- takes a `negated` flag: an ordered comparison reached through an odd number of
772
- `not`s unions `IS NULL`, while the same operator asked for directly does not.
773
- `eq` reached by negating `ne` gets no union, because the complement of
774
- "IS NULL OR <> v" is "= v", which excludes NULL by construction. The executor
775
- tests assert that **every** operator in the grammar partitions the rows against
776
- its own negation — no overlap, no gap — including through `all`/`any`
777
- containers and a double negation. `like` is the sole exclusion: negating one is
778
- refused outright.
779
-
780
- The `ne`/`notIn` union costs a second DNF branch each, a negated ordered
781
- comparison costs a second too, and an `all` multiplies,
782
- so the translator also mirrors `MAX_CONTENT_QUERY_OR_BRANCHES` (128) and drops
783
- filters before the executor would refuse the whole request. The mirror is exact
784
- rather than conservative — including the listed-`null` case, which costs one
785
- branch rather than two — and it handles De Morgan under `not`, so a future
786
- negating emitter cannot silently under-count.
787
-
788
- ### Scope is application-supplied and server-derived
789
-
790
- A `DataQueryRequest` carries no authority. Tenancy is applied inside
791
- `executeContentQuery` (fail-closed to global rows). Site, organization, or
792
- workspace narrowing is the host's: the framework models neither site nor
793
- organization, so a host calls `executeContentQuery(collection, body, { scope })`
794
- from its own route with conditions derived from the authenticated context —
795
- never from the request body.
796
-
797
- **`undefined` and `[]` mean OPPOSITE things, and getting that backwards is an
798
- authorization fail-open.** A host builds a scope from an allowed-resource list —
799
- the sites, workspaces, or organizations this principal may see:
800
-
801
- ```ts
802
- const scope = permittedSiteIds.map((siteId) => ({ siteId }));
803
- ```
804
-
805
- That list is empty exactly when the principal may see nothing. An empty array
806
- therefore means "the set of permitted conditions is empty" and matches no rows;
807
- omit the option entirely to mean "this deployment applies no application
808
- scope". Treating the empty array as absent turned *access to zero sites* into
809
- *access to every row in the tenant* — the precise failure the two-layer scope
810
- design exists to prevent, in the seam chosen as the application's scoping
811
- mechanism.
812
-
813
- An empty scope lowers to a predicate that matches nothing (`id IS NULL`, false
814
- for every row on every dialect) rather than throwing: "you may see nothing" is a
815
- legitimate authorization state, and answering it with a 500 would be wrong. It
816
- is ANDed into every branch like any other scope condition, so no caller filter
817
- shape can escape it, and it survives alongside tenancy rather than being
818
- replaced by it. A malformed condition — an empty OBJECT, which states no
819
- constraint at all — is still a programming error and still throws.
820
-
821
- ### Mirrored constants are self-enforcing
822
-
823
- Every number this package copies from the schema or from
824
- `@happyvertical/smrt-core` is bound to its source by a test, because a
825
- hand-copied limit that drifts is the defect this issue kept producing: lowering
826
- the server's page limit while the client still seeds and pages by the old one
827
- strands rows with the whole suite green.
828
-
829
- Where core exports the constant the assertion is direct; where it does not, the
830
- assertion pins core's observable BEHAVIOUR — the largest value it accepts and
831
- the smallest it refuses — rather than being skipped. Add a cross-assertion
832
- alongside any new mirrored number. The field map, projectable fields, operator
833
- vocabulary and search fields are asserted against the real
834
- `buildContentQuerySchema()` for the same reason.
835
-
836
- ### Documented limits
837
-
838
- - Offset paging only; the schema declares `supports.cursorPagination: false`.
839
- - `body` is not queryable — it is a document, and the envelope caps a scalar at
840
- 4096 characters. Read it through `GET /api/v1/contents/{id}`.
841
- - `metadata` path filtering is unavailable: JSON columns get no filter operators.
842
- - There is no ETag or version slot in the canonical envelope; `queryFingerprint`
843
- and `freshness.asOf` serve that role.
844
- - `maxResultBytes` on a host-supplied schema must be at least
845
- `CONTENT_QUERY_MIN_RESULT_BYTES`. The row budget is that number minus the
846
- envelope reserve, so a smaller one leaves nothing for rows and every query
847
- answers with an empty page flagged `truncated` — indistinguishable from "no
848
- content matched". It is refused with a plain `Error` naming the minimum
849
- rather than a `DataQueryValidationError`, because `schema` is trusted adapter
850
- configuration: a typed validation error would surface as a 400 and blame the
851
- caller for the host's mistake.
852
-
853
- **Where this actually runs:** `executeContentQuery` checks it on every
854
- request, so a misconfigured schema can never serve a query — but through the
855
- generated route an untyped error becomes an opaque 500, and the message
856
- naming the minimum reaches only the server log. Call
857
- `assertContentQuerySchema(schema)` once where the schema is configured to
858
- fail next to the mistake instead. The check is applied uniformly across query
859
- modes, `count` included: a schema too small to serve its own row mode is
860
- misconfigured whatever this request asked for, and letting `count` through
861
- would hide that until the first rows query. A zero or negative budget is
862
- refused too — it previously meant "use the default" for rows and "zero
863
- budget" for facets, which is two answers to one question.
864
- - **A row is never dropped to fit the byte budget — only shortened.** Offset
865
- paging advances by the requested LIMIT, not by the number of rows actually
866
- returned, so a dropped row is skipped on its own page and on every page after
867
- it: silent, permanent data loss. `DataQueryResult`'s offset page is
868
- `{ kind, offset, limit, hasMore }` with no next-offset slot, and the
869
- normalizer refuses a `nextCursor` on an offset page, so a continuation offset
870
- cannot express "resume at 170" either. Instead the page's budget is allocated
871
- **floor-first, then max-min fair**: every row is seated at its irreducible
872
- minimum before any surplus is shared, and the surplus goes to the smallest
873
- APPETITE (cost minus floor) first. Ordering by current cost instead would let
874
- a row that needs nothing take an even share while a large, mostly-irreducible
875
- row is starved below its own floor — declaring a feasible page impossible.
876
- Seating the floors first makes the guarantee unconditional: if the floors fit,
877
- the page is served. That guarantee lives in `allocateRowBytes`, which is
878
- exported and unit-tested, because a row's floor is dominated by the
879
- projection's key bytes — identical across the rows of one page — so the
880
- disparity that breaks cost-first ordering is not reachable through
881
- `executeContentQuery` itself.
882
-
883
- Within a row, the fields are levelled to a shared byte cap computed in one
884
- sorted walk (water-filling), not searched for by halving and re-measuring —
885
- the old search re-serialized the row and every field on every step, which
886
- turned an ordinary wide page at the default budget into ~9x the cost of the
887
- same unbounded read. HOW a field gives way depends on its declared type:
888
-
889
- | declared type | how it gives way | why |
890
- |---|---|---|
891
- | `string` | levelled to the shared cap | any prefix of free text is still free text |
892
- | `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 |
893
- | `json` | `null`, or not at all | a document has no incremental shortening |
894
- | number, boolean, `null` | never | already minimal |
895
- | the identity field | never | it is the row's address; emptying it fails result normalization |
896
- | ANY field whose reduced form is no smaller | never | `{}` and `[]` are TWO bytes, so nulling them costs four |
897
-
898
- That last row is a floor rule as much as a shrink rule. **A row's floor is the
899
- size it can be reduced TO, so it can never exceed the size the row already
900
- is.** Taking the reduced size as the floor unconditionally overstates it by
901
- two bytes per empty-document field per row, which refuses pages that would
902
- have fitted — and the refusal repeats for that page forever. The invariant
903
- `floor <= cost` is now stated by `measureRow` and held defensively by
904
- `allocateRowBytes`, rather than being an accident of a caller's short-circuit.
905
-
906
- The rule generalizes: **a value may be shortened only when its type accepts
907
- arbitrary prefixes.** Any format-constrained type added later is all-or-nothing
908
- by default. An all-or-nothing field is dropped only when the row cannot fit
909
- with it KEPT — losing a whole value to save a few bytes is a last resort, so a
910
- 200 KB `metadata` blob goes immediately while a 26-byte `updated_at` survives
911
- whenever the strings can absorb the difference.
912
-
913
- Byte accounting is exact at the boundary: each row charges the array separator
914
- that follows it, so an N-row page is refunded the one separator it does not
915
- need. Without that refund a page whose true size exactly equals the budget is
916
- needlessly shortened, or — if it is irreducible — refused outright.
917
-
918
- Shortening is already a reported state (`truncated` plus its warning) and it
919
- leaves offset paging exact. Only a page whose FLOORS exceed the budget fails,
920
- and it fails loudly rather than answering with a page that quietly omits rows.
921
- - A restored page size is clamped to `maxPageSize` (default
922
- `CONTENT_LIST_MAX_PAGE_SIZE`, 200, matching the schema's `maxPageLimit`), and
923
- the clamp is reported. The ceiling is resolved once and applied to **both**
924
- restore paths — a saved view is not a way around a limit a host set for links.
925
- - The server bounds its own answer: it shortens over-long values to fit
926
- `maxResultBytes`, flagging `truncated` with a warning. It does **not** drop
927
- rows — see the bullet above — precisely because the next page is computed from
928
- `page * limit`, so a dropped row would be skipped on the following page too.
929
- `ContentList` reads those flags — from the binding when it exposes them, and
930
- otherwise off the envelope its own `execute` resolved — and renders them in
931
- the same notice as the drops.
932
- - A `json` field (`metadata`, `tags`) is validated as a document, not a scalar,
933
- and `canonicalJson` rejects the WHOLE result when a nested value breaks any of
934
- its rules. `executeContentQuery` therefore bounds a JSON document itself —
935
- 65536-character strings, 1000-item containers, depth 16, finite numbers, no
936
- cycles, plain values only — flagging `truncated` rather than failing the page.
937
- It also drops the keys `plainObject` forbids (`__proto__`, `constructor`,
938
- `prototype`) and builds with a null prototype, because that is the *reachable*
939
- rule: `metadata` is the documented extension point, it is writable through the
940
- REST API and through `mirror()` ingestion, and `JSON.parse` of the stored
941
- column creates an own `__proto__` property — so one row could otherwise make
942
- every query projecting metadata return 400 for the whole page, permanently.
943
- - Facet values are held to the SAME shared byte budget as rows. Two text facets
944
- of 200 distinct 4096-character values are inside every per-value cap and still
945
- several times the 1 MB result limit; values are dropped and the facet and the
946
- result are flagged rather than the response being refused.
947
- - A capped offset moves the caller's page marker AND says so. `?page=9000&size=200`
948
- caps the request offset at 1 000 000; the translation returns the
949
- `effectivePage` the request actually reads, and the redirect is reported in
950
- the notice ("page 9000 cannot be loaded — the list stops at page 5001"). That
951
- drop is held apart from the rest, because the corrective dispatch re-enters
952
- the query effect in the same flush and the second translation caps nothing —
953
- storing it with the others would erase it before it was ever rendered. It
954
- stands until the operator moves off the page they landed on.
955
- - The pagers never advertise a page the endpoint cannot fetch. `totalPages` is
956
- capped at `floor(MAX_OFFSET / pageSize) + 1`. `clampPage` deliberately keeps
957
- using the TRUE total, so a crafted `?page=` still reaches the query effect and
958
- gets the notice instead of being silently clamped first. There is only one
959
- pager to cap, because ContentList renders `<Pagination>` itself in every view
960
- mode and hands DataTable no total to derive a second page count from.
961
- - A retry replaces the rendered rows, so it also replaces the completeness
962
- flags. `retry()`'s envelope is read through the same path as `execute()`'s;
963
- discarding it left a "rows are missing" notice standing over a complete page.
964
-
965
- ### URL state and saved views
966
-
967
- `urlState` is router-agnostic on purpose: `params` is read **once** at
968
- initialization, and every later change is handed back through `onChange(params,
969
- state)` with foreign parameters preserved, so a SvelteKit host calls
970
- `replaceState`, a hash router rewrites the fragment, and a test passes a plain
971
- `URLSearchParams`. Restoration goes through `applyContentListViewState`, which
972
- merges over current state rather than dispatching `setSearch`/`setFilters` —
973
- those would reset the restored page.
974
-
975
- **INVARIANT: no exported path may apply unvalidated state to a controller.**
976
- `applyContentListViewState` sanitizes its patch, because it is the one
977
- application point the package publishes and it is routinely composed with
978
- untrusted values. That is what makes
979
- `applyContentListViewState(controller, store.snapshot.state)` safe even though
980
- the store's read path deliberately returns a raw
981
- {@link RawContentListViewSnapshot} — keeping the raw payload is what lets
982
- `restoreContentListSavedView` still report a stale view's drops. Sanitization is
983
- idempotent, so ContentList's own already-validated patches are unaffected. Only
984
- the patch is sanitized, never the merged result: the sanitizer never emits
985
- selection, so sanitizing the merge would clear the operator's selection.
986
-
987
- Two URL-serialization rules the round trip depends on:
988
-
989
- - an `in`/`notIn` entry containing the list separator is escaped with a
990
- backslash on write and unescaped on read, so `author in ["Smith, John"]`
991
- restores as one value rather than two — a silently *different* query;
992
- - a parameter is owned (and therefore removable while rewriting) by its **base
993
- name** only. A host's `facet.contains=` carries a known operator suffix but
994
- names no ContentList column, so it survives. That is narrower than the
995
- recognizer in `readContentListViewStateFromSearchParams`, which still reports
996
- an operator-suffixed unknown column so a crafted `evil.contains=` stays
997
- visible: reporting a refusal and deleting a parameter are different acts.
998
-
999
- The `type` prop lock still wins after a restore: the lock effect enforces
1000
- against live state, so a restored `?type=document` is replaced by the locked
1001
- value. The lock is folded into the restored patch rather than re-applied
1002
- afterwards, because the restore replaces the whole filter set and the lock's
1003
- `setFilters` resets paging — a locked list opening `?page=3` would otherwise
1004
- land on page 1, silently.
1005
-
1006
- ### A restored value the toolbar cannot show
1007
-
1008
- The two toolbar selects publish a display vocabulary
1009
- (`CONTENT_LIST_TYPE_OPTIONS`, `CONTENT_LIST_STATUS_OPTIONS`), but the sanitizer
1010
- accepts any non-blank token, so a link can restore a filter the select has no
1011
- option for — `?status=review` (a real `Content.status`), or a typo like
1012
- `?type=artcile`. The select would show nothing while a live predicate emptied
1013
- the list.
1014
-
1015
- Both selects handle it identically, and do two things rather than one:
1016
-
1017
- 1. the value is rendered as an extra option, so the toolbar tells the truth
1018
- about what is constraining the list and the operator can clear it;
1019
- 2. it is reported in the notice, so an empty result always has an explanation.
1020
-
1021
- A select can only offer a single `equals` value, though, and a link can restore
1022
- much more than that. **INVARIANT: the select's displayed state either matches
1023
- the live predicate exactly, or the operator is told it does not.** Three states,
1024
- all reachable from a shared link:
1025
-
1026
- | Live filter | Select shows | Reported |
1027
- |---|---|---|
1028
- | `equals` with a listed value | the value | no |
1029
- | `equals` with an unlisted value (`?status=embargoed`, a typo) | the value, as an extra option | yes |
1030
- | 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 |
1031
-
1032
- The disabled summary carries a U+001F-prefixed sentinel value rather than a
1033
- U+0000 one: the HTML tokenizer rewrites a NUL inside an attribute value to
1034
- U+FFFD, so a server-rendered option would hydrate with a value the select was
1035
- never given and read as no selection — the exact state the summary exists to
1036
- prevent. A client-only mount bypasses attribute parsing, so only a parse
1037
- round-trip test catches it.
1038
-
1039
- The third row is the one that matters: a value-only read reports nothing for a
1040
- list value and reports `draft` for `notEquals draft` — the exact inverse of the
1041
- query. `readContentListSelectFilter` is operator-aware and is the seam that
1042
- keeps the control from misstating the query; `readContentListFilter` stays
1043
- value-only and must not drive a control. Choosing any real option replaces every
1044
- filter on that column, so the operator is never stuck.
1045
-
1046
- `review` is now offered outright. `deleted` is deliberately not part of the
1047
- ordinary status vocabulary: the opt-in trash lifecycle locks that predicate
1048
- and supplies the restore/purge affordances as one inseparable mode.
1049
- `Content.type` is freeform, so its option list is a display vocabulary rather
1050
- than the model's domain.
1051
-
1052
- Everything a restore or a translation refused is reported in one dismissible
1053
- notice rather than thrown — a stale link or an out-of-date saved view must still
1054
- open the list, minus the parts that are no longer meaningful.
221
+ | move-to-trash | not deleted | soft deletion |
222
+ | restore | deleted | draft/review/published; recheck permissions and publication readiness |
223
+ | permanent-delete | deleted | type exact server count; irreversible |
224
+
225
+ Permanent DELETE binds updated_at in PostgreSQL's predicate with transactional
226
+ cascade; embedded adapters compare under the shared write queue before cascade.
227
+ Races return row_revision_drifted. Empty-trash requires exact server count plus
228
+ canonical query. Query/selection changes invalidate previews; expiry, stale
229
+ revision, row/query/count drift or token failures require new preview. Preview,
230
+ initial apply and replay each get distinct attempt request IDs; apply/replay keep
231
+ confirmation/idempotency authority, and auditReference identifies original execution.
232
+
233
+ ## Jobs and agent surfaces
234
+
235
+ - Automated review/formatting/optimization return job IDs. Queue callbacks repeat
236
+ guarded apply with idempotency. Hosts must provide resolveDeferredPrincipal to
237
+ re-resolve the complete live persona/TenantAgent binding; absent, mismatched or
238
+ incomplete bindings fail closed. Authenticated jobStatusPath or client.status
239
+ keeps identical intents locked while running, reconciles terminal outcomes,
240
+ and allows failed/cancelled retry. Disable selection during status requests and
241
+ recheck captured intent before reconciling responses.
242
+ - `createContentListJobController().submit({ actionId, submissionKey, target },
243
+ start)` coalesces duplicate active keys; rejected submissions stay failed until
244
+ retry. update(job) cannot regress terminal state. Pending row work disables
245
+ selection/edit/delete. Only succeeded transitions refresh: row targets require
246
+ an affected visible row; query targets require the active request key. Failed
247
+ jobs show error/retry and never masquerade as success.
248
+ - Register dataSurface once at ContentList boundary across presentations and
249
+ loading/empty/error states. Publish rendered columns plus required id (hidden
250
+ description stays search-only), explicit fieldName mapping, available operators,
251
+ query fingerprint/total, normalized selection, presentation and freshness.
252
+ Derived site becomes read-only in server mode. Expose only mounted executable
253
+ workflow/lifecycle actions. Context changes advance the same revision stream;
254
+ stale revisions and disconnected registry commands are refused. set-view
255
+ acknowledgements describe accepted state before renderer changes.
256
+ - Silent reads use server `createContentListDataSurfaceDefinition({ collection,
257
+ scope })` in createDataSurfaceTools. Resolve collection/scope from live principal;
258
+ executeContentQuery owns rows/counts/facets/offset continuation. Exclude tenant,
259
+ hidden and sensitive fields from discovery/projection. Durable agent mutations
260
+ use the action adapter, never browser registry authority.
261
+
262
+ ## Validation
263
+
264
+ Use package `pnpm test` (which generates routes through SvelteKit sync), never
265
+ bare Vitest. Narrow with `-- <test-file>` to the affected content-list, query,
266
+ action, lifecycle or component tests; include both grid and compact paging
267
+ scenarios for reachability changes. Run package `pnpm typecheck` for Svelte
268
+ changes and root knowledge checks for this module documentation.