@happyvertical/smrt-content 0.45.1 → 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.
@@ -2,7 +2,7 @@
2
2
  "version": "1.0.0",
3
3
  "timestamp": 0,
4
4
  "packageName": "@happyvertical/smrt-content",
5
- "packageVersion": "0.45.1",
5
+ "packageVersion": "0.45.2",
6
6
  "objects": {
7
7
  "@happyvertical/smrt-content:ContentAsset": {
8
8
  "name": "contentasset",
@@ -490,7 +490,9 @@
490
490
  "delete"
491
491
  ]
492
492
  },
493
- "cli": true,
493
+ "cli": {
494
+ "skipApiCheck": true
495
+ },
494
496
  "tenantScoped": {
495
497
  "mode": "optional"
496
498
  }
@@ -856,7 +858,9 @@
856
858
  "delete"
857
859
  ]
858
860
  },
859
- "cli": true,
861
+ "cli": {
862
+ "skipApiCheck": true
863
+ },
860
864
  "tenantScoped": {
861
865
  "mode": "optional"
862
866
  }
@@ -1305,7 +1309,9 @@
1305
1309
  "delete"
1306
1310
  ]
1307
1311
  },
1308
- "cli": true,
1312
+ "cli": {
1313
+ "skipApiCheck": true
1314
+ },
1309
1315
  "tenantScoped": {
1310
1316
  "mode": "optional"
1311
1317
  }
@@ -1873,7 +1879,9 @@
1873
1879
  "update"
1874
1880
  ]
1875
1881
  },
1876
- "cli": true,
1882
+ "cli": {
1883
+ "skipApiCheck": true
1884
+ },
1877
1885
  "tenantScoped": {
1878
1886
  "mode": "optional"
1879
1887
  }
@@ -2376,7 +2384,9 @@
2376
2384
  "delete"
2377
2385
  ]
2378
2386
  },
2379
- "cli": true,
2387
+ "cli": {
2388
+ "skipApiCheck": true
2389
+ },
2380
2390
  "tenantScoped": {
2381
2391
  "mode": "optional"
2382
2392
  }
@@ -2714,7 +2724,9 @@
2714
2724
  "update"
2715
2725
  ]
2716
2726
  },
2717
- "cli": true,
2727
+ "cli": {
2728
+ "skipApiCheck": true
2729
+ },
2718
2730
  "tenantScoped": {
2719
2731
  "mode": "optional"
2720
2732
  },
@@ -3205,7 +3217,9 @@
3205
3217
  "delete"
3206
3218
  ]
3207
3219
  },
3208
- "cli": true,
3220
+ "cli": {
3221
+ "skipApiCheck": true
3222
+ },
3209
3223
  "tenantScoped": {
3210
3224
  "mode": "optional"
3211
3225
  }
@@ -3624,7 +3638,9 @@
3624
3638
  "delete"
3625
3639
  ]
3626
3640
  },
3627
- "cli": true,
3641
+ "cli": {
3642
+ "skipApiCheck": true
3643
+ },
3628
3644
  "tenantScoped": {
3629
3645
  "mode": "optional"
3630
3646
  }
@@ -4078,7 +4094,9 @@
4078
4094
  "delete"
4079
4095
  ]
4080
4096
  },
4081
- "cli": true,
4097
+ "cli": {
4098
+ "skipApiCheck": true
4099
+ },
4082
4100
  "tenantScoped": {
4083
4101
  "mode": "optional"
4084
4102
  }
@@ -4312,7 +4330,9 @@
4312
4330
  "delete"
4313
4331
  ]
4314
4332
  },
4315
- "cli": true,
4333
+ "cli": {
4334
+ "skipApiCheck": true
4335
+ },
4316
4336
  "tenantScoped": {
4317
4337
  "mode": "optional"
4318
4338
  }
@@ -5020,7 +5040,9 @@
5020
5040
  "update"
5021
5041
  ]
5022
5042
  },
5023
- "cli": true,
5043
+ "cli": {
5044
+ "skipApiCheck": true
5045
+ },
5024
5046
  "tenantScoped": {
5025
5047
  "mode": "optional"
5026
5048
  },
package/dist/server.js CHANGED
@@ -1,4 +1,4 @@
1
- import { _ as assertContentQuerySchema, t as CONTENT_QUERY_CLASS_NAME, v as buildContentQuerySchema, w as Content, x as executeContentQuery } from "./chunks/content-query-BZi_ZM_c.js";
1
+ import { _ as assertContentQuerySchema, t as CONTENT_QUERY_CLASS_NAME, v as buildContentQuerySchema, w as Content, x as executeContentQuery } from "./chunks/content-query-DoUFbv1e.js";
2
2
  import { MAX_DATA_QUERY_PAGE_LIMIT, createDataQueryFingerprint, normalizeDataQueryRequest, startRestServer } from "@happyvertical/smrt-core";
3
3
  import { createHash } from "node:crypto";
4
4
  import { AsyncLocalStorage } from "node:async_hooks";
@@ -3,14 +3,14 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-content",
6
- "packageVersion": "0.45.1",
6
+ "packageVersion": "0.45.2",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "70e10b4cfa559c8cd056cf2cce799890b0163a5fe30d94b59c20b2814e26e046",
11
- "packageJson": "4147268a8ad6598b10d699c3c576731547744415081af04a9078f0d43e263f7a",
10
+ "manifest": "70f7ab85de047855fdb09f2a818053b8a58707768d1e4cda114b4d1d91a10f57",
11
+ "packageJson": "7b9f9d02c1b22f00803191d28426b6e6bf87a7d75d7217ff229603099181d2e2",
12
12
  "agents": "9bfa2ade9b555e0c49fe38053f0413a36bb497eb9fce51c2b4628e228a6e98f2",
13
- "moduleDoc:agents/content-list.md": "721cc2d9349b22dcd6f9db78cbdf4b5727e7589a144c48f401dd84af92e49e28"
13
+ "moduleDoc:agents/content-list.md": "e5e990d6688f2a89fe521741e9af6e202dad5fa827307c727cfb5db92a83145a"
14
14
  },
15
15
  "exports": [
16
16
  ".",
@@ -11966,7 +11966,7 @@
11966
11966
  {
11967
11967
  "path": "agents/content-list.md",
11968
11968
  "module": "content-list",
11969
- "content": "<!-- Module doc for packages/content/AGENTS.md. Linked from the Modules table there. -->\n\n# ContentList: shared adapter, query state, saved views, and lifecycle\n\n`ContentList` reads every row, column, filter, and action through one shared\nadapter (`src/svelte/content-list-controller.ts`) over a `DataTableController`,\nand can source those rows either from a client array or from the bounded\ncontent query endpoint. This doc covers both modes end to end.\n\n## Trash, restore, and permanent deletion (#2454)\n\nLifecycle controls are an opt-in server-authoritative seam. Pass a\n`ContentListLifecycleBinding` as `lifecycle`; set `lifecycleMode=\"trash\"` for a\ntrash view. Trash mode locks the live status predicate to `deleted`, hides the\nordinary status control, removes legacy edit/delete buttons, and exposes\nrestore, permanent-delete, and empty-trash actions. Omit the binding and\nContentList keeps its existing `onDelete` behavior.\n\nThe browser never treats rendered rows as mutation authority. It sends a\nselection reference, canonical query for `all-matching`, expected count, and\nserver revision through `createContentListLifecycleTransport()`. Preview,\ninitial apply, and every replay use distinct per-attempt request ids. Apply and\nreplay retain the opaque confirmation and idempotency authority envelope, while\nthe result's `auditReference` continues to identify the original execution.\nChanging the query or selection invalidates the preview; expired, stale-revision,\nrow-revision, query-fingerprint, token, or matching-count failures require a\nnew preview.\n\nMount `createContentListLifecycleRoute()` from\n`@happyvertical/smrt-content/server` at the transport's default\n`POST /api/v1/contents/lifecycle` path. The host supplies a callback that\nauthenticates each native `Request` and returns its current principal/tenant\naction context. The route accepts only the three lifecycle action ids and\ndelegates to the same `createContentListActionAdapter()` instance used by agent\nand bulk-workflow callers; it never owns a second authorization or mutation\npath. Invalid JSON, missing authentication, and internal failures return\nbounded error envelopes without exposing private error text.\n\nLifecycle action ids and restore input are stable:\n\n| action | eligible server state | input / consequence |\n|---|---|---|\n| `move-to-trash` | not already `deleted` | soft-deletes content into trash |\n| `restore` | `deleted` | `{ status: 'draft' \\| 'review' \\| 'published' }`; the server re-runs permission and publish-readiness checks |\n| `permanent-delete` | `deleted` | irreversible deletion; the operator must type the exact server-resolved count |\n\nPermanent deletion binds the row's `updated_at` revision into the final delete.\nOn PostgreSQL that comparison is part of the `DELETE` predicate and any cascade\nshares its transaction; embedded adapters compare under the framework's shared\nwrite queue before cascading. A writer racing preview/apply therefore produces\n`row_revision_drifted` instead of deleting newer content.\n\nThe result details expose `count`, accepted/skipped/failed counts,\nrepresentative labels, canonical subtype-aware outcomes\n(`resourceType`/`resourceId`), and `auditReference`. Applying a partial result\nremoves only accepted ids from selection, keeping skipped and failed rows\nselected for inspection or retry. The exact query refreshes after success;\nprogress, partial exceptions, and the audit reference remain visible in a\npolite live region. Empty trash is available only when an exact server count\nand canonical query are present, so a page-sized guess cannot stand in for its\nscope. `maxSelectionSize` defaults to 200; larger matching sets are announced\nand fail closed until the operator narrows the query, matching the server's\nbounded one-preview selection contract.\n\n## Realtime, freshness, and background workflows (#2455)\n\nA server-backed list consumes the complete reactive seam exposed by\n`remoteQuery(...)`: initial loading, stale-while-refreshing rows, error, retry,\nlast-updated time, and an optional query-shaped live subscription. ContentList\nsubscribes after its first request. The remote-query controller carries that\nlive intent across query changes, so search/filter/page changes rebind to the\nnew exact query rather than subscribing to the whole collection. Browser\n`offline` keeps usable rows on screen and announces their freshness; `online`\ncalls the live handle's `reconnect()` (which refreshes the exact request before\nresubscribing), or falls back to `refresh()` when the transport has no live\nhandle. A refresh failure over existing rows is an inline error and never\nreplaces them with an empty/error-only screen.\n\nLong-running actions share a framework-free\n`createContentListJobController()`. Give that controller to action controls and\npass it to ContentList as `jobs`. Its `submit({ actionId, submissionKey,\ntarget }, start)` method coalesces duplicate active submission keys before\ncalling `start`, publishes the accepted job id/progress, and leaves a rejected\nsubmission failed until an explicit retry. Server progress enters through\n`update(job)`; terminal state cannot be reverted by an out-of-order progress\nevent. Row-targeted pending work disables that row's selection/edit/delete\ncontrols.\n\nJob targets are deliberately precise:\n\n- `{ kind: 'rows', rowIds }` refreshes only when at least one affected row is\n visible;\n- `{ kind: 'query', queryKey }` refreshes only when the key equals\n `contentListQueryRequestKey(activeRequest)`.\n\nOnly a transition to `succeeded` triggers that refresh. Failed jobs remain\nvisible with their error and retry affordance, so error recovery cannot report\nsuccess or refresh as though the action applied. The tracker is exported from\n`@happyvertical/smrt-content/svelte` for the bulk-action slice to reuse rather\nthan creating a second pending-state machine.\n## Cross-page bulk workflows (#2453)\n\nServer-backed lists can opt into one shared preview/apply workflow contract for\nhuman and agent callers. The browser integration is exported from\n`@happyvertical/smrt-content/svelte`; the principal-bound adapter is exported\nfrom `@happyvertical/smrt-content/server`.\n\nThe supported workflows are move to trash, mark draft, submit for review,\npublish, archive, restore, automated review, body formatting, categorization,\nand optimization. Applications inject body-formatting and optimization\nhandlers because those AI pipelines are application-owned; the content package\ndoes not guess a model or rewrite policy.\n\nImportant invariants:\n\n- `all-matching` is the canonical server query plus its query fingerprint and\n exact count. It is never expanded from the IDs loaded in the browser.\n- Preview is read-only and returns the resolved scope, exact count,\n representative labels, eligibility outcomes, and consequences. Apply uses\n the opaque preview token and an idempotency key.\n- Apply resolves the query again under the same principal and tenant scope,\n rechecks tool/operation authorization and eligibility, and compares the\n aggregate `updated_at` row revision. Every content save also includes that\n revision in its database `UPDATE`, so an edit racing the final write fails\n atomically instead of being overwritten. Application formatting/optimization\n handlers mutate the supplied object but must not call `save()`; the adapter\n owns the guarded save. Query, count, membership, or row drift returns a stale\n failure before mutation.\n- Publication-producing workflows require every read used to construct their\n version snapshot, including `contentassets:read` and `assets:read`. Keep those\n explicit catalog assertions in sync with `ContentVersion.createSnapshot()`;\n they are the authorization boundary on adapters without PostgreSQL RLS.\n- Workflow queries must project `id`, `title`, `status`, and `updated_at` so the\n server can bind labels, eligibility, and row revisions. The standard\n ContentList query projection already includes them; a host-supplied projection\n override must retain them.\n- The default workflow cap is 200 rows across explicit, current-page, and\n all-matching scopes. Set the same `maxSelectionSize` on the server adapter and\n browser workflow binding when overriding it. The UI does not offer\n all-matching selection above that cap, and the server returns\n `limit_exceeded` for forged or agent requests above it.\n- Automated review, formatting, and optimization are background actions. A\n successful apply returns a job ID; the queue callback repeats the guarded\n apply, and idempotency prevents duplicate execution. Hosts must supply\n `resolveDeferredPrincipal` to re-resolve the complete live persona/TenantAgent\n binding before queued mutations; missing, mismatched, or incomplete bindings\n fail closed. Hosts can configure the\n authenticated `jobStatusPath` transport (or supply `client.status`) so the\n list can keep an identical intent locked while queued/running, reconcile\n terminal row outcomes, and unlock a failed or cancelled intent for retry.\n Selection controls are disabled while a status request is in flight, and the\n captured intent is rechecked before any returned outcomes are reconciled.\n- Foreground results report accepted, skipped, and failed rows separately. The\n client clears only accepted IDs; a failed request never reports success or\n clears selection.\n\n`createContentListWorkflowTransport()` supplies the default JSON transport.\nHosts that already have an RPC layer can instead provide a structural workflow\nclient with the same `preview(request)` and `apply(request)` methods, plus the\noptional `status(jobId)` method when background progress is shown. Both paths\nmust terminate at the same `createContentListActionAdapter()` instance used by\nagent actions; separate human-only mutation handlers would bypass the shared\npreview and authorization contract.\n\nThe server adapter also requires a `workflowStorage` capability declaration.\nPostgreSQL, SQLite, ordinary DuckDB, and non-immediate exported-file modes are\nsupported. JSON's default/immediate mode and DuckDB `writeStrategy:\n'immediate'` fail before adapter setup because those SDK modes export writes\nbefore the surrounding transaction commits and cannot roll the file back.\n\n## ContentList migration (#2451)\n\n`ContentList` no longer holds bespoke local state. `src/svelte/content-list-controller.ts`\nis the single adapter every presentation reads from, and one shared\n`DataTableController` (from `@happyvertical/smrt-ui/data`) owns search, filters,\nsorting, page, and selection.\n\n| Before | After |\n|--------|-------|\n| local `searchTerm`/`selectedType`/`selectedStatus` runes | controller commands `setSearch` / `setFilters` (stable filter ids `type`, `status`) |\n| `filteredContents` `$derived` per view | `toContentListRows` → `selectContentListRows` → `paginateContentListRows`, computed once for all three modes |\n| bespoke `<table>` markup in compact mode | smrt-ui `DataTable` with the shared columns plus per-column cell snippets |\n| no selection | checkbox selection in every mode via `toggleRowSelection` / `setSelectedRows` |\n| `getViewHref` called inline three times | `resolveContentHref` / `contentListRowActions` (one eligibility source) |\n\nProps are unchanged and still exported as `ContentListProps`: `apiBaseUrl`,\n`contents`, `type` (still locks and hides the type filter), `defaultViewMode`\n(still seeds once), `onEdit`, `onDelete`, `onAdd`, `controls`, `getViewHref`.\nNew optional props: `loading`, `error`, `onRetry`, and `dataSurface`\n(`{ registry, descriptor? }`).\n\nAdapter exports (also re-exported from `./svelte`): `createContentListController`,\n`buildContentListColumns`, `buildContentListSurfaceDescriptor`,\n`toContentListRows`, `selectContentListRows`, `paginateContentListRows`,\n`contentListFilters`, `readContentListFilter`, `applyContentListFilter`,\n`contentListRowActions`, `resolveContentHref`, `selectableContentListRowIds`,\n`resolveSelectedContentListRows`, `resolveSelectedContents`, plus the\n`CONTENT_LIST_*` identity constants.\n\n## Agent data-surface conformance (#2456)\n\n`dataSurface` publishes the mounted list's readable columns, supported query\noperators, visible state, selection scope, freshness, and only the actions its\nmounted workflow/lifecycle bindings can execute. Visible commands run through\nthe registry's revision check; their acknowledgements describe the accepted\ncontroller revision, while a disconnected registry refuses them.\n\nFor silent agent reads, register\n`createContentListDataSurfaceDefinition({ collection, scope })` from\n`@happyvertical/smrt-content/server` in the server-owned\n`createDataSurfaceTools()` catalog. The factory resolves both the collection\nand optional application scope from the live principal context, then delegates\nrows, counts, facets, and offset continuations to `executeContentQuery()`.\nTenant, hidden, and sensitive fields remain outside discovery and projection;\nthe model request can only narrow the trusted scope. Durable actions continue\nthrough `createContentListActionAdapter()` and its principal-bound\npreview/apply contract rather than through the browser registry.\n\nNotes:\n\n- Controller modes are all `manual`: the adapter owns search, filters, sorting,\n and paging in **every** presentation, and the compact table receives\n `data={pageRows}` and no `totalRows` at all (see \"ContentList owns paging in\n EVERY presentation\" below for why that prop is withheld rather than computed).\n Letting DataTable filter locally over already-filtered rows re-ran the\n transform with subtly different semantics (untrimmed search, its own equality\n rules), so the two presentations could disagree. The component clamps the page\n with `controller.clampPage(queryRows.length)`. #2452 replaces the local\n implementation of that transform with a server query behind the same contract.\n- A `type` prop lock is enforced against live state, not just against the prop:\n a data-surface `set-filters` or `reset` command that drops the type filter is\n re-applied by the lock effect (equality-guarded, so it settles). The unlocked\n branch clears the filter only on an actual lock-REMOVAL transition — tracked\n with a non-reactive `previousLockedType` — because clearing on every run would\n also discard a type filter restored from a link or a saved view, and clearing\n never would strand the old lock after the prop went away.\n- Selection may only address durable rows. All three presentations render a\n disabled, explained checkbox for `identified: false` rows, page select-all\n skips them, and a normalization effect re-dispatches `setSelectedRows` without\n any non-durable id, which covers data-surface commands too.\n- Compact mode renders a content-owned `select` column (header + cell snippets)\n instead of passing `selectable` to DataTable. DataTable has no per-row\n selection predicate, so its header select-all addresses the synthetic id of an\n unidentified row; the normalization effect then strips it and the header stays\n indeterminate forever. Because column order is reconciled from the\n controller's known column ids, the structural `select` and `actions` ids are\n part of `CONTENT_LIST_TABLE_COLUMN_IDS` — omit them and selection renders\n behind every data column.\n- Only rendered columns are published to a data surface. `description` is a\n hidden, search-only column so search still reaches the deck; the descriptor\n additionally declares the `id` row-key column, which the surface contract\n requires but the table never renders.\n- Rows without a durable `id` (or repeating one) still render, keyed by\n position, but are marked `identified: false`;\n `resolveSelectedContentListRows` drops them so a bulk action can never act on\n an unaddressable row. `ContentData` has no expiry or site field, so the\n `site` column is derived from `url`/`source`.\n- Column ids are public identifiers and do not always match the model field, so\n the descriptor's `fieldName` comes from an explicit map\n (`publish` → `publish_date`, `updated` → `updatedAt`); the derived `site`\n column advertises no field at all rather than a nonexistent one.\n- Filter values are normalized per column (`type` via `normalizeContentType`,\n everything else via `normalizeContentToken`) through\n `normalizeContentListFilterValue`, and a blank value clears the filter — an\n `equals ''` filter would silently exclude every row.\n- The card presentations render their own page controls (smrt-ui `Pagination`\n dispatching `setPage`) and their own polite refresh status, because DataTable\n — which owns both in compact mode — is not mounted there. A page size arriving\n from a saved view or a surface command would otherwise strand the operator on\n page one, and a refresh over retained rows would be silent.\n- `dataSurface` registers at the `ContentList` boundary, independently of its\n renderer. Grid, detailed, compact, loading, empty, and error presentations\n therefore retain one stable surface identity. A `set-view` command updates\n the published `viewMode` and revision before the renderer changes, so its\n acknowledgement always describes the accepted presentation.\n- The mounted snapshot publishes the controller state and normalized selection\n together with the active query fingerprint, total, presentation, and\n freshness (`stale`, `refreshing`, `offline`, last update, truncation, and\n warnings). Context-only changes advance the same revision stream as visible\n controller commands; stale command revisions are refused by the registry.\n- Discovery publishes per-column search/filter/sort operators. A server-backed\n list narrows the derived `site` column to read-only because the content query\n translator cannot soundly project, filter, or sort that display value.\n\n## ContentList server-backed mode (#2452)\n\n`ContentList` gained three optional, independent opt-ins. Omit all three and the\ncomponent behaves exactly as it did after #2451 — `ContentWorkspaceRoute` passes\n`contents` and nothing else and is unchanged.\n\n| Prop | Type | Effect |\n|---|---|---|\n| `query` | `ContentListQuerySource` | Rows come from `POST /api/v1/contents/query`; `contents` is ignored |\n| `urlState` | `ContentListUrlStateBinding` | Restore from, and publish to, a query string |\n| `savedViews` | `ContentListSavedViewStore` | Save / apply / delete named views |\n\n### The query seam\n\n`query.bind()` is called **once**, during component initialization, and returns\na `ContentListQueryBinding`. `remoteQuery(collection, transport)` from\n`@happyvertical/smrt-svelte/web` satisfies that interface structurally, so its\n`$effect` teardown is registered in `ContentList`'s own scope and disposed with\nit:\n\n```svelte\nconst transport = createContentListQueryTransport({ apiBaseUrl: '/api/v1' });\n<ContentList query={{ bind: () => remoteQuery(collection, transport) }} … />\n```\n\nThe binding type is declared **structurally rather than imported**: pulling\n`@happyvertical/smrt-svelte` (and through it `@happyvertical/smrt-web` and\n`@tanstack/db`) into `@happyvertical/smrt-content/svelte` would put the browser\ndata runtime in a barrel that public content pages import, defeating the\ncode-split boundary that runtime's own AGENTS.md ratifies. smrt-web mirrors\n`smrt-types` for the same reason.\n\n### Three id namespaces\n\nA list value crosses three vocabularies that do not agree, and the bridge is\nexplicit in both directions:\n\n| Namespace | Example | Owner |\n|---|---|---|\n| adapter column id | `updated` | `content-list-controller.ts` |\n| `ContentData` field | `updatedAt` | `mock-smrt-client.ts` |\n| server data-query field id | `updated_at` | the registered `Content` model |\n\n`CONTENT_LIST_QUERY_FIELDS` maps column → server field\n(`publish` → `publish_date`, `updated` → `updated_at`), and\n`ROW_FIELD_TO_CONTENT_KEY` maps result row → `ContentData`\n(`updated_at` → `updatedAt`, `created_at` → `createdAt`).\n`content-list-query.test.ts` asserts the first map against the *real*\n`buildContentQuerySchema()` — field ids, declared types, and declared filter\noperators — so a model rename breaks a test rather than a production query.\n\n`site` has **no** server field: it is derived in the browser from\n`url`/`source`. A filter or sort on it is dropped from the request and reported.\n\n### The local transform is the local-mode path only\n\nIn server mode the returned rows *are* the answer — the server already applied\nsearch, filters, sort, and page — so `selectContentListRows` /\n`paginateContentListRows` must not run over them. Running them again re-filters\nwith different semantics (untrimmed search, case-insensitive comparison, a\n`site` predicate the server never saw) and can hide rows the server returned.\n`totalRows` therefore comes from `result.total`, not from the page length.\n\n**Clamping moves the operator, so it acts only on a count that is exactly\nright.** Two findings in a row were \"the clamp acted on a number that wasn't the\ntotal\", so the rule is stated as a set rather than patched case by case:\n\n| Input | Clamp against it? |\n|---|---|\n| local mode row count | yes — the supplied array IS the whole result set |\n| server total, `exact` | yes |\n| server total, `estimated` | **no** — an approximation can hide a page that really exists |\n| server total, `unavailable` | **no** — the count is unknown; `rows.length` is the page, not the total |\n| no response yet for this query | **no** — a page restored from a link survives until its own count arrives |\n| a settled response for a DIFFERENT query | **no** — the binding holds the previous total while a new request is in flight |\n| a page-size change | n/a — `setPageSize` resets the page itself |\n\n**ContentList owns paging in EVERY presentation, compact included, and\ndeliberately never passes `totalRows` to DataTable.** DataTable runs its own\n`clampPage(totalRows)` effect against the SAME controller, with no authority\nrule and no notion of which query a total belongs to — so for two rounds the\nclamp fixes above were live in the card modes and bypassed in compact, where an\n`estimated` total clamped a real page away and a stale total reset a restored\none.\n\nOne prop cannot serve both jobs: `totalRows` drives that clamp AND DataTable's\npager, so any total authoritative enough to clamp against is also the only total\nthe pager can show. Passing an authoritative-only total silences the clamp but\nleaves compact with no pager on an `estimated` total while the card modes still\nshow one, and then the two modes disagree about which pages exist — a worse bug\nthan the one being fixed. So ContentList keeps one clamp (its own effect, with\nthe authority rule) and one pager (its own `<Pagination>`, driven by\n`pageableRowCount`, which accepts an estimate because SHOWING a page and MOVING\nthe operator are different questions). The same reasoning already made the\nselection column content-owned in compact mode.\n\n**Invariant: the presentations must never disagree about which pages exist or\nwhich rows are reachable.** Anything about which page is requested, which pages\nare offered, or which rows come back belongs in `describePaging` in the test\nsuite, which runs the suite in both `grid` and `compact`. `defaultViewMode`\ndefaults to `grid`, so a plain `renderList` test proves only the arm where\nDataTable is not mounted.\n\n`estimated` is a deliberate choice, not an oversight. Clamping on an estimate\nstrands rows the operator cannot then reach; not clamping can offer a page that\ncomes back empty, which is visible and self-correcting. Hiding reachable rows is\nthe worse failure — the same reasoning as \"truncation only when it narrows\".\nShowing a pager is a different question, so `pageableRowCount` still accepts an\nestimate through `contentListQueryTotalValue`; only\n`contentListQueryExactTotal` feeds the clamp.\n\nSelection normalization also changes shape in server mode: `rows` is only the\ncurrent page, so membership cannot be the durability test (it would clear the\nselection on every page change). Only the adapter's synthetic ids are stripped.\n\n### One page-size ceiling, one page size\n\n`maxPageSize` is resolved ONCE, by `resolveContentListMaxPageSize`, as the\nminimum of every configured limit and the schema's `maxPageLimit` — every\ncandidate narrows, so a host that sets `query.request.maxPageSize` as a server\nrow budget does not lose it to a looser `urlState.options.maxPageSize`. That one\nnumber is passed to the controller seed, the URL sanitizer, the saved-view\nsanitizer and the translator, which is what makes the size the UI pages by and\nthe size the server applies the same number *by construction*.\n\nTwo page sizes are unusable in server mode, and BOTH are coerced against live\nstate (a saved view, a link, and a data-surface `set-page-size` all arrive after\nmount) and reported:\n\n| Live page size | Coerced to | Why |\n|---|---|---|\n| `null` | the seed | the endpoint always applies a limit |\n| `> maxPageSize` | `maxPageSize` | the translator clamps the request, so leaving the controller higher makes `totalPages` compute 1 and hide the page controls |\n\nThe seed itself is clamped to the ceiling too: a `defaultPageSize` above it\nwould otherwise seed a page size the request silently reduces.\n\n`pageSize: null` means \"show everything\", and the query endpoint has no way to\nexpress that. Left alone, a null page size renders one page of `limit` rows with\nno page controls and no way to reach the rest, so in server mode it is\n**coerced and reported**:\n\n- supplying `query` seeds the controller's `pageSize` from\n `query.request.defaultPageSize` (default 50), clamped to the ceiling;\n- that same number is handed to the URL layer as its `defaultPageSize`, so a\n link that omits `size` restores the seed instead of overwriting it with the\n local `null`, and a link this list writes omits `size` while at the default;\n- a null arriving later — `?size=all`, a saved view, a data-surface\n `set-page-size` — is coerced by an effect against live state, and the\n translator reports the same coercion for a direct caller.\n\nBoth report a `pageSize` / `unpaginated-unsupported` drop in the notice. Local\nmode keeps the historical unpaginated list unchanged; the coercion is\nserver-mode-only.\n\n### Search, and what the protocol cannot express\n\nThe protocol has `filter` only — no search primitive. `state.search` becomes an\n`any` of `like` predicates over the adapter's searchable columns (`title`,\n`description`, `author`), with the wildcards added client-side and the\noperator's own `%`/`_` escaped with a backslash.\n\n**Known dialect gap:** PostgreSQL and DuckDB honour a backslash as the default\n`LIKE` escape; SQLite has none and the collection query builder emits no\n`ESCAPE` clause, so on SQLite an escaped `%` matches the two literal characters.\nThat fails closed (empty result) rather than open (every row); a portable fix\nneeds an `ESCAPE` clause at the collection/SQL boundary.\n\n**Second known dialect gap — where an absent value sorts.** A sort term is\n`<field> <ASC|DESC>` and nothing more: `buildOrderBySql` splits the term on\nwhitespace and discards everything after the direction, and `DataQuerySort`\ncarries only a field and a direction, so `NULLS FIRST`/`NULLS LAST` cannot be\nexpressed from this package at all. The placement is therefore whatever the\ndialect defaults to — PostgreSQL and DuckDB put NULLs LAST ascending, SQLite\nputs them FIRST — which changes **which rows land on page one**.\n\nThe local comparator is aligned to ONE documented choice: **absent sorts last\nascending and first descending**, matching the SQL standard, the PostgreSQL and\nDuckDB defaults, and therefore the production dialects. A SQLite-backed\ndeployment will disagree with local mode on where absent values fall. That\ndivergence is dialect-level and not fixable here; fixing it needs a nulls\nplacement in the sort term at the `@happyvertical/sql` / `SmrtCollection`\nboundary, and a slot for it in `DataQuerySort`. A test pins SQLite's observed\nordering so this note cannot rot.\n\n`isNull` / `isNotNull` map to a **null-valued `eq` / `ne`**. That is null-aware\nend to end and is not a comparison against NULL: the protocol scalar type admits\n`null`, the request normalizer rejects a null value only for\n`gt`/`gte`/`lt`/`lte`/`like`, and `buildWhere` lowers `{ field: null }` to\n`IS NULL` and `{ 'field !=': null }` to `IS NOT NULL`.\n\nDropped rather than sent, because sending them would fail the *whole* request:\n\n- `notContains` — the executor refuses to negate a `like`. The only operator\n with no server expression.\n- `contains` / `startsWith` / `endsWith` on a datetime column — `like` is\n string-only in the request normalizer.\n- an unparseable datetime value — the normalizer requires an RFC 3339 instant.\n\nThe translator also enforces the normalizer's **input caps**, because exceeding\none 400s the entire list rather than degrading it. Each is capped and reported:\nat most 100 `in`/`notIn` values, at most 50 filter nodes (counting every\n`all`/`any` container, so search costs 4 and the outer `all` 1), at most 50\nprojection fields — each of which must also be a declared projectable field id\nof 1-256 characters, or it is dropped rather than 400ing the list — a request id\nof at most 128 characters, at most 4096 characters per filter value, and 100 000\nbytes for the whole serialized request (100 values of 4096 characters is inside\nevery per-value cap and still five times that limit, so the newest filter\nbranches are shed until it fits).\n\n**Measure what the server measures.** `boundRequestSize` weighs the NORMALIZED\nrequest, not the body as sent, and the normalizer injects `schema.defaultSort`\nwhen `sort` is absent — 84 bytes the client had not counted, so a request of\n99 917-100 000 bytes passed the client check and 400ed at the server. The\ntranslator therefore always emits `sort`, falling back to\n`CONTENT_LIST_QUERY_DEFAULT_SORT` (the schema default) rather than omitting the\nkey. The invariant to preserve: the client's byte count is never smaller than\ncore's.\n\n**Validate the input, never the value derived from it.** `new Date()` rolls an\nimpossible calendar date forward — `2026-02-31` becomes `2026-03-03` — so\nchecking the *produced* instant against the RFC 3339 pattern reports nothing and\nthe query silently targets a day the link never named. The translator re-derives\nthe year/month/day and compares, exactly as `normalizedInstant` does server-side,\nand drops the filter with a report. A date-only value (`2026-02-01`) is still\naccepted and widened to midnight UTC; only impossible days are refused.\n\n**Truncation is only permissible when it NARROWS.** Every cap above degrades a\nrequest rather than failing it, but degrading is only honest when the answer\nstays a subset of the question:\n\nDirection is a property of the OPERATOR, not of the site — the same cap narrows\nan `in` and widens a `notIn`, and the same truncation widens a `contains` and\ndoes neither to an `eq`. So the classification is made per operator, and where a\nchange is neither narrowing nor widening the filter is not sent at all:\n\n| Bound hit | Effect on the result | What happens |\n|---|---|---|\n| `in` list past 100 values | narrows — a disjunct is removed | capped, reported as `out-of-range` |\n| `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` |\n| filter-node, OR-branch, or request-byte budget | **widens** — a conjunct is shed | reported as `filter-widened` |\n| a shortened `contains` or `startsWith` pattern, or a shortened search term | **widens** — a shorter pattern matches a superset | sent, reported as `filter-widened` |\n| a shortened `endsWith` pattern | **widens**, but only because the TRAILING characters are the ones kept | sent, reported as `filter-widened` |\n| 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` |\n| page size, offset, projection count | does not add rows | reported as `out-of-range` |\n\n**Every path lands in one of FOUR states, and every one of them reports.** The\npartition is four-way, not three — an `in` list cut to its first hundred values\nis a genuine SUBSET, which is allowed and is not a widening:\n\n| State | Example | Reported as |\n|---|---|---|\n| applied exactly | anything within every bound | nothing to report |\n| applied as a true SUPERSET | a shortened `like` pattern | `filter-widened` |\n| 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` |\n| not applied at all | a `notIn` that cannot be carried, a shed conjunct, a comparand too long to send | `filter-widened` |\n\nThe fourth column is the point: a state without a report is a fifth state, and\nit is the one that hides. An unusable entry in an `in` list used to be exactly\nthat — dropped silently, so the toolbar stated a three-value filter while the\nquery asked for one and the rows the operator listed vanished without a word.\nAny new bound must land in one of the four rows above *and* report.\n\n**Never emit a value the caller did not name.** The list path already refused to\nsend a shortened entry; the scalar path used to send one and merely relabel the\nreport, which was affirmatively wrong for half the operators — telling an\noperator the list \"may include rows it would have excluded\" while `lt` was\nquietly hiding rows. A comparand that cannot be sent whole is now not sent at\nall, which is uniformly a widening and is honestly reported as one.\n\n**Which end of a pattern survives decides whether it widens.**\n`boundLikeSource` keeps the LEADING code points for `contains` (`%abc%`) and\n`startsWith` (`abc%`) — anything containing or starting with `abcdef` also\ncontains or starts with `abc`. A suffix pattern (`%abc`) is the mirror image, so\nit keeps the TRAILING ones: truncating `%…END` from the front would name a\ndifferent ending, dropping the row the operator asked for while picking up\nunrelated ones. That is neither a superset nor a subset, and no honest label\nexists for it.\n\nA widening `notIn` is never PARTIALLY applied. The cap keeps arrival order, so a\nliteral `null` past the hundredth entry is the entry shed — and the executor\nthen takes its \"no null listed\" arm and unions `IS NULL` back in, returning\nevery absent-valued row the caller listed `null` to exclude. Dropping the filter\nwhole is wider still, but it is *honest*: the operator is told the filter is not\nbeing applied instead of being told it was \"clamped\", which would imply it still\nworks.\n\nThat distinction is the whole point of the `filter-widened` reason. An operator\nwho is told a list was clamped reasonably assumes the answer is a subset of what\nthey asked for. Telling them that when the truth is \"this now returns rows you\nexcluded\" is worse than telling them nothing.\n\n**Measure in the server's unit.** `dataQueryScalar` tests `value.length`, which\ncounts UTF-16 code units, so an astral character costs TWO. The bounders iterate\nby code point — never splitting a surrogate pair — but charge `character.length`\nper code point plus one for an escape. Charging one per code point made a search\nof 4093 ASCII characters plus one emoji measure 4094 client-side and 4097\nserver-side: a hard 400 and a whole-list error panel.\n\nAnd mirror the normalizer's **validity rules**, not only its numeric caps. A\ndatetime input is held to three separate checks before it is parsed, because\n`Date` will happily accept and silently reinterpret what fails each one:\n\n| Check | Refuses | Why |\n|---|---|---|\n| calendar round-trip | `2026-02-31` | `Date` rolls it to March 3, so the query targets a day the link never named |\n| 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 |\n| 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 |\n| four-digit year | `+275760-09-13` | serializes to an expanded-year form the server refuses outright |\n\nThe rule is \"a time must carry an offset\", not \"a time must state its seconds\":\n`2026-02-01T12:30Z` and `2026-02-01T12:30+09:00` each name one instant for every\nreader and are accepted. A bare calendar day (`2026-02-01`) is accepted too —\n`Date` reads it as UTC midnight everywhere. Lower-case `t`/`z` is fine: RFC 3339\n§5.6 permits it, and the value is canonicalized through `toISOString()` before\nit is sent, so the server only ever sees the strict upper-case form it insists\non.\n\n**Canonicalize before parsing, too.** RFC 3339 §5.6 permits a lower-case\n`t`/`z`, but ECMA-262's Date Time String Format specifies the upper-case forms,\nso `new Date('2026-02-01t12:30z')` runs on engine-specific heuristics rather\nthan the spec — the same objection that rules out the space separator. V8 reads\nit as UTC, which is luck rather than a guarantee, so the value is upper-cased\nbefore it reaches `Date`. `toUpperCase()` is locale-independent and this grammar\nhas no other letters, so the canonical form means exactly what the input did.\n\n**Validate and parse the SAME string.** Checking `text.trim()` and then parsing\n`text` let `\"2026-02-01 \"` through, and V8's ISO parser rejects the whitespace\nand falls back to the legacy parser — which reads a bare date as LOCAL midnight,\nreintroducing the per-viewer divergence with no drop reported at all. The\nvalidator returns the exact string to parse rather than a verdict about a\ndifferent one.\n\n### Filter case: tokens are folded, free text is not\n\n`normalizeContentListFilterValue` folds case for the **token** columns only —\n`type`, `status`, `state`, whose domain is a fixed lowercase vocabulary the\nmodel writes. Every other column holds text a person typed, and its value\nbecomes a server-side `eq` or `like` compared against the STORED text: folding\n`NASA` to `%nasa%` would miss `NASA Update` on PostgreSQL or DuckDB. The helper\ndates from #2451, when every comparison was local and folding everything was\nharmless; server mode invalidated that assumption.\n\nPreserving case is safe locally because the local evaluator compares through\n`textValue()`, which lower-cases BOTH sides at compare time — a case-preserving\nstored value still matches case-insensitively there. The `type`-lock predicate\n(`isContentListFilterExactly`) goes through the same helper, so the lock still\nsettles rather than re-dispatching.\n\n**Free-text server matching is therefore dialect-dependent, and the protocol\ncannot make it uniform.** There is no `ilike` in the operator vocabulary, so:\n\n| Backend | `like` / `eq` on free text |\n|---|---|\n| PostgreSQL, DuckDB | case-SENSITIVE |\n| SQLite | case-insensitive for ASCII |\n\nLocal mode is case-insensitive everywhere. Do not describe free-text filtering\nas uniform; a portable fix needs a case-insensitive operator at the collection\nboundary.\n\n### NULL semantics are aligned\n\nThe same shared link must return the same rows whether the host passed a `query`\nor not, so every null-sensitive operator now agrees across the two modes. Two\ndifferent alignments were needed, in opposite directions, because the *meaning*\ndiffers per operator:\n\n| Operator | Server lowering | Alignment |\n|---|---|---|\n| `eq`, `in`, `like` (`contains`/`startsWith`/`endsWith`) | plain predicate | already agreed — both exclude an absent row |\n| `in` with a `null` listed | `IS NULL OR IN (…)` | already aligned |\n| `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 |\n| `notIn` (no `null` listed) | `IS NULL OR (AND of <> v)` | same |\n| `notIn` WITH a `null` listed | `(AND of <> v) AND IS NOT NULL` | **no union**: a listed `null` says absent rows are excluded too |\n| `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` |\n| `ne null` (`isNotNull`) | `IS NOT NULL` | untouched — a union would match every row |\n| `gt`, `gte`, `lt`, `lte` — asked for directly | plain predicate | the LOCAL evaluator gained null-awareness |\n| `gt`, `gte`, `lt`, `lte` — reached through `not` | `IS NULL OR <predicate>` | server gained the union, so the negation is a true complement |\n| `isNull` / `isNotNull` | `IS NULL` / `IS NOT NULL` | the LOCAL evaluator gained null-awareness |\n\n### A value that reads as absent is still a value\n\n`null` in a filter list means \"and rows with no value at all\". It was silently\ndiscarded in THREE separate layers before this was tracked to one shape: each\nlayer tested the value for PRESENCE rather than for VALIDITY, and `null` reads\nas absent to any check written with truthiness. `normalizedFilterValue` is the\ncorrection — it returns three outcomes, not two: a string, the value `null`, or\n`undefined` for genuinely unusable, which is the only case a caller may drop.\n\nEvery layer a filter value crosses, and what each does with a `null` entry:\n\n| Layer | Behaviour |\n|---|---|\n| data-surface `set-filters` → controller | passes through unchanged |\n| `sanitizeContentListViewState` | **preserves** it (was: silently dropped) |\n| controller state (`setFilters` / `replaceState`) | passes through unchanged |\n| URL serialization | writes the `\\0` token |\n| URL parse | reads the token back as `null` |\n| saved-view write (`hydrateDataTableSnapshot`) | JSON, so `null` is native |\n| saved-view read | native |\n| `restoreContentListSavedView` | preserves it (shares the sanitizer above) |\n| translator (`coerceValue`) | **preserves** it (fixed earlier) |\n| request → `normalizeFilter` | accepts a null scalar for `eq`/`ne` |\n| executor (`conditionToDnf`) | lowers a listed null to `IS NULL` / `IS NOT NULL` |\n| local evaluator | `matchesAbsentContentValue` answers the same predicate |\n\n**The URL token is collision-free by construction, not by being unlikely.**\n`escapeListEntry` doubles every backslash a real value contains, so the only\ntwo-character sequences a real entry can begin with are `\\\\` and `\\,`. A LONE\nbackslash followed by `0` is therefore unreachable from any string — including\nthe literal two characters `\\0`, which serialize as `\\\\0` and read back as\nthemselves. It survives percent-encoding as `%5C0`. Writing `null` as the word\nwould have been ambiguous with an author actually called \"null\".\n\nA **scalar** null comparand needs no token at all: `equals null` and `isNull`\nare the same predicate — both lower to `eq null`, and the local evaluator\nanswers them identically — so it is written as the valueless operator, which\nalready has a query-string form.\n\n**The bug was never really about `null`.** The same shape catches any value that\na truthiness check reads as absent, so `0`, `false`, and the empty string are\ntested alongside it. An empty entry WITHIN a list (`?author.in=a,`) is the empty\nstring, which is a real value for a column that stores one; an entirely empty\nparameter (`?author.in=`) is a list with no values and is still refused and\nreported. A blank SCALAR still clears the filter, matching\n`applyContentListFilter`.\n\n**A literal `null` has to survive the TRANSLATOR too.** `coerceValue` reports a\nnull as unusable, which is right for a value that arrived as text and wrong for\none a caller wrote deliberately: dropping it sent `notIn ['Ada']` for\n`notIn ['Ada', null]` and returned exactly the rows the caller excluded — the\nexecutor's correct lowering undone one layer up. A literal null is now carried\nthrough `in`/`notIn` lists and accepted as an `equals`/`notEquals` comparand,\nand the local evaluator resolves it to absence, so a data-surface `set-filters`\nmeans the same thing in both modes. Such a value cannot arrive from a link or a\nsaved view — the sanitizer refuses a non-text filter value — so the reachable\npath is an agent command.\n\n**A listed `null` inverts the rule, and getting that wrong is a data leak of the\nworst kind — a filter returning exactly the rows it was asked to exclude.**\n`notIn ['Ada', null]` means \"not Ada, and not blank\", so it must NOT gain the\n`IS NULL` union. It is reachable from the wire (`normalizeFilter` accepts a null\nlist entry) and through `not(in ['Ada', null])`. Without the distinction,\n`in [x, null]` and its own negation both match the absent row: a predicate\noverlapping its negation. The executor tests assert every list shape\n(no null / with null / null-only / inverted) partitions the rows with no overlap\nand no gap.\n\n**The ordered comparisons were aligned on the LOCAL side, not the server's.**\n`ContentListRow` flattens every field to display text, so an absent value read\nas `''` — which sorts below everything, made `publish_date lt X` match every\nnever-published row, and made `isNull` match nothing at all. The original\n`ContentData` still distinguishes absent from empty, so\n`isAbsentContentValue()` consults it and the null-sensitive operators exclude an\nabsent value exactly as SQL's three-valued logic does.\n\n**Absence is decided before any text comparison.** The flattened row reads an\nabsent value as empty text, so comparing it as text answers a question about\n`''` rather than about absence — and the two differ for every operator once a\nBLANK comparand is involved (`author equals ''` matched an absent row locally\nand no row in SQL; `author notEquals ''` did the reverse).\n`matchesAbsentContentValue` decides from the operator alone, following the SQL\nthe executor emits: `equals` matches only a null comparand, `notEquals` matches\nunless the comparand is null, `in`/`notIn` turn on whether the list carries a\n`null`, and `like` and every ordered comparison match nothing.\n\n**A display fallback is presentation, never data.** Two columns substitute a\nlabel when the content carries no value — `type` reads `content` and `title`\nreads `Untitled content`. Comparing the label made `?type=content` return every\nuntyped row on a client-array list and none on a server-backed one, and made a\nsearch for `untitled` find rows whose title is simply missing.\n`comparisonValue()` reads what is stored for those two columns instead: `null`\nwhen the content has no value, empty text when it is genuinely blank, and the\nflattened text otherwise, which is already faithful. Both local filtering and\nlocal search go through it. The label keeps rendering; `isNull` is how an\noperator asks for the rows behind it. This is the direction the\ndata means: \"no publish date\" is not \"published before X\". It also aligns the\nblank-comparand case (`?author.lt=`, `?author.gte=`), where the flattened `''`\nused to compare equal and the two modes disagreed, and it leaves a column that\ngenuinely stores `''` (`title`, `name`) comparing as present in both modes.\n\n**A negation must be a complement.** `not` is part of the endpoint's accepted\ngrammar even though the translator never emits one, and a direct HTTP consumer\nsending `not(gt 'B')` used to get a bare `<= 'B'` — leaving a row with no value\nmatching NEITHER the predicate nor its negation. `conditionToDnf` therefore\ntakes a `negated` flag: an ordered comparison reached through an odd number of\n`not`s unions `IS NULL`, while the same operator asked for directly does not.\n`eq` reached by negating `ne` gets no union, because the complement of\n\"IS NULL OR <> v\" is \"= v\", which excludes NULL by construction. The executor\ntests assert that **every** operator in the grammar partitions the rows against\nits own negation — no overlap, no gap — including through `all`/`any`\ncontainers and a double negation. `like` is the sole exclusion: negating one is\nrefused outright.\n\nThe `ne`/`notIn` union costs a second DNF branch each, a negated ordered\ncomparison costs a second too, and an `all` multiplies,\nso the translator also mirrors `MAX_CONTENT_QUERY_OR_BRANCHES` (128) and drops\nfilters before the executor would refuse the whole request. The mirror is exact\nrather than conservative — including the listed-`null` case, which costs one\nbranch rather than two — and it handles De Morgan under `not`, so a future\nnegating emitter cannot silently under-count.\n\n### Scope is application-supplied and server-derived\n\nA `DataQueryRequest` carries no authority. Tenancy is applied inside\n`executeContentQuery` (fail-closed to global rows). Site, organization, or\nworkspace narrowing is the host's: the framework models neither site nor\norganization, so a host calls `executeContentQuery(collection, body, { scope })`\nfrom its own route with conditions derived from the authenticated context —\nnever from the request body.\n\n**`undefined` and `[]` mean OPPOSITE things, and getting that backwards is an\nauthorization fail-open.** A host builds a scope from an allowed-resource list —\nthe sites, workspaces, or organizations this principal may see:\n\n```ts\nconst scope = permittedSiteIds.map((siteId) => ({ siteId }));\n```\n\nThat list is empty exactly when the principal may see nothing. An empty array\ntherefore means \"the set of permitted conditions is empty\" and matches no rows;\nomit the option entirely to mean \"this deployment applies no application\nscope\". Treating the empty array as absent turned *access to zero sites* into\n*access to every row in the tenant* — the precise failure the two-layer scope\ndesign exists to prevent, in the seam chosen as the application's scoping\nmechanism.\n\nAn empty scope lowers to a predicate that matches nothing (`id IS NULL`, false\nfor every row on every dialect) rather than throwing: \"you may see nothing\" is a\nlegitimate authorization state, and answering it with a 500 would be wrong. It\nis ANDed into every branch like any other scope condition, so no caller filter\nshape can escape it, and it survives alongside tenancy rather than being\nreplaced by it. A malformed condition — an empty OBJECT, which states no\nconstraint at all — is still a programming error and still throws.\n\n### Mirrored constants are self-enforcing\n\nEvery number this package copies from the schema or from\n`@happyvertical/smrt-core` is bound to its source by a test, because a\nhand-copied limit that drifts is the defect this issue kept producing: lowering\nthe server's page limit while the client still seeds and pages by the old one\nstrands rows with the whole suite green.\n\nWhere core exports the constant the assertion is direct; where it does not, the\nassertion pins core's observable BEHAVIOUR — the largest value it accepts and\nthe smallest it refuses — rather than being skipped. Add a cross-assertion\nalongside any new mirrored number. The field map, projectable fields, operator\nvocabulary and search fields are asserted against the real\n`buildContentQuerySchema()` for the same reason.\n\n### Documented limits\n\n- Offset paging only; the schema declares `supports.cursorPagination: false`.\n- `body` is not queryable — it is a document, and the envelope caps a scalar at\n 4096 characters. Read it through `GET /api/v1/contents/{id}`.\n- `metadata` path filtering is unavailable: JSON columns get no filter operators.\n- There is no ETag or version slot in the canonical envelope; `queryFingerprint`\n and `freshness.asOf` serve that role.\n- `maxResultBytes` on a host-supplied schema must be at least\n `CONTENT_QUERY_MIN_RESULT_BYTES`. The row budget is that number minus the\n envelope reserve, so a smaller one leaves nothing for rows and every query\n answers with an empty page flagged `truncated` — indistinguishable from \"no\n content matched\". It is refused with a plain `Error` naming the minimum\n rather than a `DataQueryValidationError`, because `schema` is trusted adapter\n configuration: a typed validation error would surface as a 400 and blame the\n caller for the host's mistake.\n\n **Where this actually runs:** `executeContentQuery` checks it on every\n request, so a misconfigured schema can never serve a query — but through the\n generated route an untyped error becomes an opaque 500, and the message\n naming the minimum reaches only the server log. Call\n `assertContentQuerySchema(schema)` once where the schema is configured to\n fail next to the mistake instead. The check is applied uniformly across query\n modes, `count` included: a schema too small to serve its own row mode is\n misconfigured whatever this request asked for, and letting `count` through\n would hide that until the first rows query. A zero or negative budget is\n refused too — it previously meant \"use the default\" for rows and \"zero\n budget\" for facets, which is two answers to one question.\n- **A row is never dropped to fit the byte budget — only shortened.** Offset\n paging advances by the requested LIMIT, not by the number of rows actually\n returned, so a dropped row is skipped on its own page and on every page after\n it: silent, permanent data loss. `DataQueryResult`'s offset page is\n `{ kind, offset, limit, hasMore }` with no next-offset slot, and the\n normalizer refuses a `nextCursor` on an offset page, so a continuation offset\n cannot express \"resume at 170\" either. Instead the page's budget is allocated\n **floor-first, then max-min fair**: every row is seated at its irreducible\n minimum before any surplus is shared, and the surplus goes to the smallest\n APPETITE (cost minus floor) first. Ordering by current cost instead would let\n a row that needs nothing take an even share while a large, mostly-irreducible\n row is starved below its own floor — declaring a feasible page impossible.\n Seating the floors first makes the guarantee unconditional: if the floors fit,\n the page is served. That guarantee lives in `allocateRowBytes`, which is\n exported and unit-tested, because a row's floor is dominated by the\n projection's key bytes — identical across the rows of one page — so the\n disparity that breaks cost-first ordering is not reachable through\n `executeContentQuery` itself.\n\n Within a row, the fields are levelled to a shared byte cap computed in one\n sorted walk (water-filling), not searched for by halving and re-measuring —\n the old search re-serialized the row and every field on every step, which\n turned an ordinary wide page at the default budget into ~9x the cost of the\n same unbounded read. HOW a field gives way depends on its declared type:\n\n | declared type | how it gives way | why |\n |---|---|---|\n | `string` | levelled to the shared cap | any prefix of free text is still free text |\n | `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 |\n | `json` | `null`, or not at all | a document has no incremental shortening |\n | number, boolean, `null` | never | already minimal |\n | the identity field | never | it is the row's address; emptying it fails result normalization |\n | ANY field whose reduced form is no smaller | never | `{}` and `[]` are TWO bytes, so nulling them costs four |\n\n That last row is a floor rule as much as a shrink rule. **A row's floor is the\n size it can be reduced TO, so it can never exceed the size the row already\n is.** Taking the reduced size as the floor unconditionally overstates it by\n two bytes per empty-document field per row, which refuses pages that would\n have fitted — and the refusal repeats for that page forever. The invariant\n `floor <= cost` is now stated by `measureRow` and held defensively by\n `allocateRowBytes`, rather than being an accident of a caller's short-circuit.\n\n The rule generalizes: **a value may be shortened only when its type accepts\n arbitrary prefixes.** Any format-constrained type added later is all-or-nothing\n by default. An all-or-nothing field is dropped only when the row cannot fit\n with it KEPT — losing a whole value to save a few bytes is a last resort, so a\n 200 KB `metadata` blob goes immediately while a 26-byte `updated_at` survives\n whenever the strings can absorb the difference.\n\n Byte accounting is exact at the boundary: each row charges the array separator\n that follows it, so an N-row page is refunded the one separator it does not\n need. Without that refund a page whose true size exactly equals the budget is\n needlessly shortened, or — if it is irreducible — refused outright.\n\n Shortening is already a reported state (`truncated` plus its warning) and it\n leaves offset paging exact. Only a page whose FLOORS exceed the budget fails,\n and it fails loudly rather than answering with a page that quietly omits rows.\n- A restored page size is clamped to `maxPageSize` (default\n `CONTENT_LIST_MAX_PAGE_SIZE`, 200, matching the schema's `maxPageLimit`), and\n the clamp is reported. The ceiling is resolved once and applied to **both**\n restore paths — a saved view is not a way around a limit a host set for links.\n- The server bounds its own answer: it shortens over-long values to fit\n `maxResultBytes`, flagging `truncated` with a warning. It does **not** drop\n rows — see the bullet above — precisely because the next page is computed from\n `page * limit`, so a dropped row would be skipped on the following page too.\n `ContentList` reads those flags — from the binding when it exposes them, and\n otherwise off the envelope its own `execute` resolved — and renders them in\n the same notice as the drops.\n- A `json` field (`metadata`, `tags`) is validated as a document, not a scalar,\n and `canonicalJson` rejects the WHOLE result when a nested value breaks any of\n its rules. `executeContentQuery` therefore bounds a JSON document itself —\n 65536-character strings, 1000-item containers, depth 16, finite numbers, no\n cycles, plain values only — flagging `truncated` rather than failing the page.\n It also drops the keys `plainObject` forbids (`__proto__`, `constructor`,\n `prototype`) and builds with a null prototype, because that is the *reachable*\n rule: `metadata` is the documented extension point, it is writable through the\n REST API and through `mirror()` ingestion, and `JSON.parse` of the stored\n column creates an own `__proto__` property — so one row could otherwise make\n every query projecting metadata return 400 for the whole page, permanently.\n- Facet values are held to the SAME shared byte budget as rows. Two text facets\n of 200 distinct 4096-character values are inside every per-value cap and still\n several times the 1 MB result limit; values are dropped and the facet and the\n result are flagged rather than the response being refused.\n- A capped offset moves the caller's page marker AND says so. `?page=9000&size=200`\n caps the request offset at 1 000 000; the translation returns the\n `effectivePage` the request actually reads, and the redirect is reported in\n the notice (\"page 9000 cannot be loaded — the list stops at page 5001\"). That\n drop is held apart from the rest, because the corrective dispatch re-enters\n the query effect in the same flush and the second translation caps nothing —\n storing it with the others would erase it before it was ever rendered. It\n stands until the operator moves off the page they landed on.\n- The pagers never advertise a page the endpoint cannot fetch. `totalPages` is\n capped at `floor(MAX_OFFSET / pageSize) + 1`. `clampPage` deliberately keeps\n using the TRUE total, so a crafted `?page=` still reaches the query effect and\n gets the notice instead of being silently clamped first. There is only one\n pager to cap, because ContentList renders `<Pagination>` itself in every view\n mode and hands DataTable no total to derive a second page count from.\n- A retry replaces the rendered rows, so it also replaces the completeness\n flags. `retry()`'s envelope is read through the same path as `execute()`'s;\n discarding it left a \"rows are missing\" notice standing over a complete page.\n\n### URL state and saved views\n\n`urlState` is router-agnostic on purpose: `params` is read **once** at\ninitialization, and every later change is handed back through `onChange(params,\nstate)` with foreign parameters preserved, so a SvelteKit host calls\n`replaceState`, a hash router rewrites the fragment, and a test passes a plain\n`URLSearchParams`. Restoration goes through `applyContentListViewState`, which\nmerges over current state rather than dispatching `setSearch`/`setFilters` —\nthose would reset the restored page.\n\n**INVARIANT: no exported path may apply unvalidated state to a controller.**\n`applyContentListViewState` sanitizes its patch, because it is the one\napplication point the package publishes and it is routinely composed with\nuntrusted values. That is what makes\n`applyContentListViewState(controller, store.snapshot.state)` safe even though\nthe store's read path deliberately returns a raw\n{@link RawContentListViewSnapshot} — keeping the raw payload is what lets\n`restoreContentListSavedView` still report a stale view's drops. Sanitization is\nidempotent, so ContentList's own already-validated patches are unaffected. Only\nthe patch is sanitized, never the merged result: the sanitizer never emits\nselection, so sanitizing the merge would clear the operator's selection.\n\nTwo URL-serialization rules the round trip depends on:\n\n- an `in`/`notIn` entry containing the list separator is escaped with a\n backslash on write and unescaped on read, so `author in [\"Smith, John\"]`\n restores as one value rather than two — a silently *different* query;\n- a parameter is owned (and therefore removable while rewriting) by its **base\n name** only. A host's `facet.contains=` carries a known operator suffix but\n names no ContentList column, so it survives. That is narrower than the\n recognizer in `readContentListViewStateFromSearchParams`, which still reports\n an operator-suffixed unknown column so a crafted `evil.contains=` stays\n visible: reporting a refusal and deleting a parameter are different acts.\n\nThe `type` prop lock still wins after a restore: the lock effect enforces\nagainst live state, so a restored `?type=document` is replaced by the locked\nvalue. The lock is folded into the restored patch rather than re-applied\nafterwards, because the restore replaces the whole filter set and the lock's\n`setFilters` resets paging — a locked list opening `?page=3` would otherwise\nland on page 1, silently.\n\n### A restored value the toolbar cannot show\n\nThe two toolbar selects publish a display vocabulary\n(`CONTENT_LIST_TYPE_OPTIONS`, `CONTENT_LIST_STATUS_OPTIONS`), but the sanitizer\naccepts any non-blank token, so a link can restore a filter the select has no\noption for — `?status=review` (a real `Content.status`), or a typo like\n`?type=artcile`. The select would show nothing while a live predicate emptied\nthe list.\n\nBoth selects handle it identically, and do two things rather than one:\n\n1. the value is rendered as an extra option, so the toolbar tells the truth\n about what is constraining the list and the operator can clear it;\n2. it is reported in the notice, so an empty result always has an explanation.\n\nA select can only offer a single `equals` value, though, and a link can restore\nmuch more than that. **INVARIANT: the select's displayed state either matches\nthe live predicate exactly, or the operator is told it does not.** Three states,\nall reachable from a shared link:\n\n| Live filter | Select shows | Reported |\n|---|---|---|\n| `equals` with a listed value | the value | no |\n| `equals` with an unlisted value (`?status=embargoed`, a typo) | the value, as an extra option | yes |\n| 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 |\n\nThe disabled summary carries a U+001F-prefixed sentinel value rather than a\nU+0000 one: the HTML tokenizer rewrites a NUL inside an attribute value to\nU+FFFD, so a server-rendered option would hydrate with a value the select was\nnever given and read as no selection — the exact state the summary exists to\nprevent. A client-only mount bypasses attribute parsing, so only a parse\nround-trip test catches it.\n\nThe third row is the one that matters: a value-only read reports nothing for a\nlist value and reports `draft` for `notEquals draft` — the exact inverse of the\nquery. `readContentListSelectFilter` is operator-aware and is the seam that\nkeeps the control from misstating the query; `readContentListFilter` stays\nvalue-only and must not drive a control. Choosing any real option replaces every\nfilter on that column, so the operator is never stuck.\n\n`review` is now offered outright. `deleted` is deliberately not part of the\nordinary status vocabulary: the opt-in trash lifecycle locks that predicate\nand supplies the restore/purge affordances as one inseparable mode.\n`Content.type` is freeform, so its option list is a display vocabulary rather\nthan the model's domain.\n\nEverything a restore or a translation refused is reported in one dismissible\nnotice rather than thrown — a stale link or an out-of-date saved view must still\nopen the list, minus the parts that are no longer meaningful.\n"
11969
+ "content": "<!-- Module doc for packages/content/AGENTS.md. -->\n\n# ContentList contracts\n\nRead for changes to `src/svelte/content-list-*.ts`, `ContentList.svelte`, or the\nserver content-query/action adapters. Browser exports come from `/svelte`;\nprincipal-bound query/action factories come from `/server`.\n\n## Ownership and integration\n\n- `content-list-controller.ts` is the shared adapter over `DataTableController`.\n Search, filters, sort, paging, selection, columns, and action eligibility have\n one implementation across grid, detailed, and compact presentations. Controller\n modes are manual. Local arrays use `toContentListRows`, `selectContentListRows`,\n then `paginateContentListRows`; server rows must never be transformed again.\n- `query`, `urlState`, `savedViews`, `lifecycle`, `jobs`, and `dataSurface` are\n optional bindings. Without query, rows come from `contents`; with it, contents\n is ignored. `type` locks/hides the type filter; `defaultViewMode` seeds once.\n Existing callbacks (`onEdit`, `onDelete`, `onAdd`, `getViewHref`) remain usable.\n- `query.bind()` runs once during component initialization so a structural\n `remoteQuery(collection, transport)` binding registers teardown in that scope.\n Do not import smrt-svelte/web or its browser data runtime into the content\n Svelte barrel: public content pages depend on that code-split boundary.\n- The default query transport targets `POST /api/v1/contents/query`.\n `CONTENT_LIST_QUERY_FIELDS` maps column IDs to server fields;\n `ROW_FIELD_TO_CONTENT_KEY` maps response fields to ContentData. For example,\n column `updated`, client `updatedAt`, and server `updated_at` differ.\n `publish` maps to `publish_date`. Derived `site` comes from url/source and has\n no server field; server filtering/sorting on it is dropped and reported.\n- Missing/duplicate durable IDs still render with positional keys but are\n `identified: false`. Disable and explain their selection controls. Page select\n all and normalization exclude synthetic IDs; server selection must survive\n page changes, so current-page membership is not a durability test.\n- Compact uses content-owned `select` and `actions` columns, included in\n `CONTENT_LIST_TABLE_COLUMN_IDS`. DataTable's generic selection cannot enforce\n per-row eligibility. Resolve links/actions through the shared adapter.\n\n## Paging and freshness\n\nContentList owns the pager in every presentation and passes no `totalRows` to\nDataTable, whose independent clamp would mutate the shared controller without\nknowing which query produced a count.\n\n- Clamp on the local full-array count or an exact total for the active settled\n server query only. Never clamp on an estimate, unavailable total, page length,\n previous-query response, or before the current response arrives. An estimate\n may still drive the displayed pager. Test paging in grid and compact modes.\n- Resolve one page-size ceiling with `resolveContentListMaxPageSize`: the minimum\n of configured limits and schema limit (default 200). Use it for controller\n seed, URL/saved-view sanitizers, translator, and live state. Server seed defaults\n to 50; null page size becomes the seed and oversized values become the ceiling,\n with reported drops. Local mode may remain unpaginated. Omitted URL size must\n restore the server seed. `setPageSize` resets page.\n- Offset only; maximum offset is 1,000,000. Report capping and update the marker\n to `effectivePage`; retain the notice until the operator leaves that page.\n Pager maximum is `floor(MAX_OFFSET / pageSize) + 1`, while clamp uses the true\n total so crafted out-of-range pages receive the explanatory notice.\n- Subscribe after the first request; query changes rebind the exact subscription.\n Offline retains usable stale rows. Online calls live `reconnect()` (refresh\n before resubscription), otherwise `refresh()`. Refresh errors are inline over\n retained rows. Show loading, retry, refreshing, last update, offline, truncation,\n and warnings across presentations. Read completeness flags from retry envelopes\n as well as execute envelopes; a successful retry replaces old flags.\n\n## Query authority and protocol\n\n`executeContentQuery()` applies tenancy, failing closed to global rows. Host\napplication scope comes from authenticated context, never request body; caller\nfilters only narrow it. Omitted scope means no extra application restriction;\n`scope: []` means no permitted rows (`id IS NULL`). AND scope into every query\nbranch alongside tenancy. Empty object conditions are configuration errors.\n\nKeep field maps, projectable fields, operator/search vocabulary, and mirrored\nlimits asserted against `buildContentQuerySchema()` and core constants or their\nobservable acceptance boundaries.\n\n- No cursor paging, body queries, metadata-path filters, or ETag/version slot.\n Read body through GET by ID; use queryFingerprint/freshness.asOf for freshness.\n- Search emits an `any` of `like` over title/description/author, escaping caller\n `%`/`_`. Drop and report unsupported `notContains`, datetime LIKE operators,\n invalid datetime inputs, unknown projection fields, and derived site queries.\n- Bound normalized request bytes, including default sort. Always emit sort\n (fall back to `CONTENT_LIST_QUERY_DEFAULT_SORT`) so client accounting is not\n smaller than the normalizer's. Limits: 100 list values, 50 filter nodes\n (containers count; search costs four), 128 DNF branches, 50 projection fields\n with 1–256-character IDs, 128-character request ID, 4096 UTF-16 units per scalar,\n 100,000 serialized request bytes. Count escapes and surrogate pairs without\n splitting code points. DNF costs include null unions and De Morgan negation.\n- Preserve comparands exactly or omit/report the filter. Oversized/unusable `in`\n entries may be removed as a reported subset (`out-of-range`/`unsupported-value`).\n Never partially apply `notIn`: drop/report `filter-widened`. Shed conjuncts for\n node/branch/byte budgets with that same report. Shortened LIKE/search patterns\n report widening; retain leading code points for contains/startsWith and trailing\n ones for endsWith. Never shorten scalar eq/ne/ordered comparands into a value\n the caller did not name. Every change is either exact, a reported superset,\n a reported subset, or a reported omitted predicate.\n- Datetimes require a real calendar day, four-digit year, T separator and an\n offset when time is present. Date-only means midnight UTC; minutes without\n seconds and lowercase t/z are accepted. Trim and uppercase before parsing,\n validate that same string, and calendar-round-trip to reject rolled dates.\n\n### Comparison semantics and dialect limits\n\nFold token filter values (type/status/state); preserve free-text case. Local\ntext comparison is case-insensitive; server free-text matching follows its SQL\noperator/dialect. PostgreSQL/DuckDB LIKE is case-sensitive; SQLite LIKE is ASCII\ncase-insensitive. Do not promise uniform free-text matching.\n\nSQLite's LIKE lacks the default backslash escape used by PostgreSQL/DuckDB;\nescaped wildcards can fail closed there. Fixing portability needs SQL-boundary\nESCAPE support. Null placement is likewise dialect-dependent: local sorts absent\nlast ascending/first descending, matching PostgreSQL/DuckDB; SQLite defaults\noppositely. The query sort grammar cannot express NULLS FIRST/LAST.\n\n- Read absence from original ContentData, not flattened display text. Type's\n `content` and title's `Untitled content` fallbacks are presentation only; missing\n values compare/search as null, genuinely blank strings remain present.\n- eq/in/LIKE exclude absent values unless null is explicitly listed. ne of a\n non-null value and notIn without null include absence. notIn containing null\n excludes absence. eq/ne null lower to IS NULL/IS NOT NULL. Direct ordered\n comparisons exclude absence; their negations include it. Every supported\n predicate and its negation must partition rows without gaps or overlap,\n including nested all/any and double negation. Negated LIKE is refused.\n- Preserve literal null through sanitization, saved views, translation and local\n evaluation; only undefined denotes unusable input. Test null, false, zero and\n empty strings. Blank scalar clears a filter; an empty entry within a list is\n real text, but a wholly empty list parameter is refused/reported.\n\n## Result budgets\n\nCall `assertContentQuerySchema(schema)` at configuration time. Every executor\nmode, including count, rejects maxResultBytes below\n`CONTENT_QUERY_MIN_RESULT_BYTES`; this is a host error, not caller validation.\n\n- Never drop rows to fit bytes: offset advances by requested limit, so omitted\n rows would become unreachable. Seat every row's irreducible floor first, then\n distribute surplus max-min fairly by appetite (cost minus floor).\n `allocateRowBytes` guarantees a page whenever floors fit; otherwise fail loudly.\n Preserve `floor <= cost` and refund the final unused array separator at exact\n byte boundaries.\n- Within rows use one sorted water-filling walk. Strings may shorten; datetime\n and JSON values are all-or-nothing (null only if smaller). Numbers, booleans,\n null, and identity never shrink. Prefer shortening free text before losing a\n format-constrained value; new constrained types are all-or-nothing by default.\n Report shortening with truncated/warnings.\n- Bound JSON documents before result normalization: strings 65,536 characters,\n containers 1,000 items, depth 16; finite numbers, no cycles, plain values only.\n Drop forbidden __proto__/constructor/prototype keys and build with null\n prototypes. Report truncation rather than allowing one stored document to\n invalidate an entire page.\n- Facets share the result byte budget; drop excess facet values and flag both\n facet and result. Per-value caps alone do not bound the envelope.\n\n## URL state and saved views\n\nRead urlState.params once; later onChange(params, state) preserves foreign\nparameters and lets the host own routing. `applyContentListViewState` is the\npublic application boundary: sanitize every patch, including raw saved snapshots,\nthen merge over current state without resetting restored page. Sanitize the patch,\nnot the merged result, because selection is intentionally absent from sanitizer\noutput. Keep raw saved-view payloads so stale-field drops remain reportable.\n\n- Escape list separators and double literal backslashes. List null uses the\n collision-free `\\0` token; a literal backslash-zero string is doubled. Scalar\n null serializes through isNull/isNotNull. Preserve foreign parameters by base\n name, even when they have a known operator suffix; unknown suffixed columns\n may still be reported as refused without being deleted.\n- The type lock applies to live state, including agent reset/filter commands.\n Fold it into restored patches to preserve paging. Clear it only on an actual\n lock-removal transition, not every unlocked render.\n- Toolbar selects must state the live predicate honestly. Unlisted equals values\n render as extra options and produce a notice. Lists, non-equals operators, and\n multiple predicates render a disabled summary and notice. Use operator-aware\n `readContentListSelectFilter`, not value-only `readContentListFilter`.\n Choosing a real option replaces all filters on that column. Summary sentinel\n uses U+001F; NUL changes during HTML parsing and breaks hydration.\n- Review is an ordinary status; deleted belongs exclusively to opt-in trash\n mode. Type options are display vocabulary, not the freeform model domain.\n Restore/translation refusals appear in one dismissible notice; stale state\n should still open with the refused parts omitted.\n\n## Shared mutations and lifecycle\n\nHuman transports and agent/bulk callers terminate at the same\n`createContentListActionAdapter()` instance. Preview/apply is principal-bound;\nrendered rows are never mutation authority.\n\n- Supported workflows: trash, draft, review submission, publish, archive, restore,\n automated review, formatting, categorization, optimization. Applications inject\n formatting/optimization handlers; handlers mutate supplied objects but must\n not save them because the adapter owns guarded writes.\n- Explicit/current-page/all-matching selections share a default 200-row cap.\n Match server and browser maxSelectionSize. All-matching is canonical query,\n fingerprint and exact count, never browser-page expansion. Above-cap requests\n fail closed, including forged/agent requests; UI asks the operator to narrow.\n- Read-only preview returns resolved scope/count/labels/eligibility/consequences.\n Apply uses opaque token and idempotency key, re-resolves query under live\n principal/tenant, rechecks tool/operation permissions and eligibility, and\n compares aggregate updated_at revision. Query/count/membership/row drift fails\n before mutation. Each save also guards updated_at atomically at UPDATE.\n Required projection: id, title, status, updated_at, even under host overrides.\n- Publication workflows require every snapshot read permission, explicitly\n including contentassets:read and assets:read. Keep assertions aligned with\n `ContentVersion.createSnapshot()`; embedded adapters rely on them without RLS.\n- Declare workflowStorage capability. PostgreSQL, SQLite, ordinary DuckDB and\n non-immediate exported-file modes are supported. JSON default/immediate and\n DuckDB immediate writes are rejected at setup because exports cannot roll back.\n- Partial results separate accepted/skipped/failed rows; clear only accepted IDs.\n Failures retain selection. Refresh the exact query and expose progress,\n exceptions, representative labels, resourceType/resourceId and auditReference.\n\nLifecycle binding enables trash controls; trash mode locks deleted and hides\nordinary status/edit/delete controls. Without it legacy onDelete remains.\n`createContentListLifecycleRoute()` mounts POST /api/v1/contents/lifecycle;\nits host authenticates every native Request and supplies current action context.\nOnly move-to-trash, restore, permanent-delete are accepted; malformed/auth/internal\nfailures use bounded envelopes without private error text.\n\n| Action | Eligible state | Contract |\n|---|---|---|\n| move-to-trash | not deleted | soft deletion |\n| restore | deleted | draft/review/published; recheck permissions and publication readiness |\n| permanent-delete | deleted | type exact server count; irreversible |\n\nPermanent DELETE binds updated_at in PostgreSQL's predicate with transactional\ncascade; embedded adapters compare under the shared write queue before cascade.\nRaces return row_revision_drifted. Empty-trash requires exact server count plus\ncanonical query. Query/selection changes invalidate previews; expiry, stale\nrevision, row/query/count drift or token failures require new preview. Preview,\ninitial apply and replay each get distinct attempt request IDs; apply/replay keep\nconfirmation/idempotency authority, and auditReference identifies original execution.\n\n## Jobs and agent surfaces\n\n- Automated review/formatting/optimization return job IDs. Queue callbacks repeat\n guarded apply with idempotency. Hosts must provide resolveDeferredPrincipal to\n re-resolve the complete live persona/TenantAgent binding; absent, mismatched or\n incomplete bindings fail closed. Authenticated jobStatusPath or client.status\n keeps identical intents locked while running, reconciles terminal outcomes,\n and allows failed/cancelled retry. Disable selection during status requests and\n recheck captured intent before reconciling responses.\n- `createContentListJobController().submit({ actionId, submissionKey, target },\n start)` coalesces duplicate active keys; rejected submissions stay failed until\n retry. update(job) cannot regress terminal state. Pending row work disables\n selection/edit/delete. Only succeeded transitions refresh: row targets require\n an affected visible row; query targets require the active request key. Failed\n jobs show error/retry and never masquerade as success.\n- Register dataSurface once at ContentList boundary across presentations and\n loading/empty/error states. Publish rendered columns plus required id (hidden\n description stays search-only), explicit fieldName mapping, available operators,\n query fingerprint/total, normalized selection, presentation and freshness.\n Derived site becomes read-only in server mode. Expose only mounted executable\n workflow/lifecycle actions. Context changes advance the same revision stream;\n stale revisions and disconnected registry commands are refused. set-view\n acknowledgements describe accepted state before renderer changes.\n- Silent reads use server `createContentListDataSurfaceDefinition({ collection,\n scope })` in createDataSurfaceTools. Resolve collection/scope from live principal;\n executeContentQuery owns rows/counts/facets/offset continuation. Exclude tenant,\n hidden and sensitive fields from discovery/projection. Durable agent mutations\n use the action adapter, never browser registry authority.\n\n## Validation\n\nUse package `pnpm test` (which generates routes through SvelteKit sync), never\nbare Vitest. Narrow with `-- <test-file>` to the affected content-list, query,\naction, lifecycle or component tests; include both grid and compact paging\nscenarios for reachability changes. Run package `pnpm typecheck` for Svelte\nchanges and root knowledge checks for this module documentation.\n"
11970
11970
  }
11971
11971
  ]
11972
11972
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-content",
3
- "version": "0.45.1",
3
+ "version": "0.45.2",
4
4
  "smrtJsdoc": "strict",
5
5
  "description": "Content processing module for SMRT framework - handles documents, web content, and media",
6
6
  "type": "module",
@@ -60,18 +60,18 @@
60
60
  "@happyvertical/utils": "^0.89.4",
61
61
  "fast-xml-parser": "^5.10.1",
62
62
  "yaml": "^2.9.0",
63
- "@happyvertical/smrt-assets": "0.45.1",
64
- "@happyvertical/smrt-agents": "0.45.1",
65
- "@happyvertical/smrt-chat": "0.45.1",
66
- "@happyvertical/smrt-core": "0.45.1",
67
- "@happyvertical/smrt-facts": "0.45.1",
68
- "@happyvertical/smrt-images": "0.45.1",
69
- "@happyvertical/smrt-prompts": "0.45.1",
70
- "@happyvertical/smrt-profiles": "0.45.1",
71
- "@happyvertical/smrt-tenancy": "0.45.1",
72
- "@happyvertical/smrt-types": "0.45.1",
73
- "@happyvertical/smrt-ui": "0.45.1",
74
- "@happyvertical/smrt-messages": "0.45.1"
63
+ "@happyvertical/smrt-facts": "0.45.2",
64
+ "@happyvertical/smrt-chat": "0.45.2",
65
+ "@happyvertical/smrt-assets": "0.45.2",
66
+ "@happyvertical/smrt-images": "0.45.2",
67
+ "@happyvertical/smrt-agents": "0.45.2",
68
+ "@happyvertical/smrt-core": "0.45.2",
69
+ "@happyvertical/smrt-messages": "0.45.2",
70
+ "@happyvertical/smrt-prompts": "0.45.2",
71
+ "@happyvertical/smrt-profiles": "0.45.2",
72
+ "@happyvertical/smrt-types": "0.45.2",
73
+ "@happyvertical/smrt-tenancy": "0.45.2",
74
+ "@happyvertical/smrt-ui": "0.45.2"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "svelte": "^5.56.4"
@@ -92,8 +92,8 @@
92
92
  "typescript": "5.9.3",
93
93
  "vite": "8.1.4",
94
94
  "vitest": "4.1.10",
95
- "@happyvertical/smrt-playground": "0.45.1",
96
- "@happyvertical/smrt-vitest": "0.45.1"
95
+ "@happyvertical/smrt-vitest": "0.45.2",
96
+ "@happyvertical/smrt-playground": "0.45.2"
97
97
  },
98
98
  "keywords": [
99
99
  "ai",
@@ -1 +0,0 @@
1
- {"version":3,"file":"content-corrections-B4mPd_th.js","names":[],"sources":["../../src/content-correction.ts","../../src/content-corrections.ts"],"sourcesContent":["import type { SmrtObjectOptions } from '@happyvertical/smrt-core';\nimport {\n crossPackageRef,\n foreignKey,\n SmrtObject,\n smrt,\n} from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport type {\n ContentCorrectionStatus,\n ContentCorrectionType,\n} from './content-governance';\n\nexport interface ContentCorrectionOptions extends SmrtObjectOptions {\n contentId?: string;\n contentVersionId?: string;\n factId?: string;\n replacementFactId?: string;\n correctionType?: ContentCorrectionType;\n status?: ContentCorrectionStatus;\n summary?: string;\n incorrectText?: string;\n correctedText?: string;\n publicNote?: string;\n metadata?: string | Record<string, unknown>;\n tenantId?: string | null;\n publishedAt?: Date | null;\n createdAt?: Date;\n updatedAt?: Date;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'content_corrections',\n api: { include: ['list', 'get', 'create', 'update'] },\n mcp: { include: ['list', 'get', 'create', 'update'] },\n cli: true,\n})\nexport class ContentCorrection extends SmrtObject {\n @foreignKey('Content', { required: true, onDelete: 'CASCADE' })\n contentId = '';\n\n @foreignKey('ContentVersion', { onDelete: 'CASCADE' })\n contentVersionId = '';\n\n @crossPackageRef('@happyvertical/smrt-facts:Fact')\n factId = '';\n\n @crossPackageRef('@happyvertical/smrt-facts:Fact')\n replacementFactId = '';\n correctionType: ContentCorrectionType = 'fact';\n status: ContentCorrectionStatus = 'draft';\n summary = '';\n incorrectText = '';\n correctedText = '';\n publicNote = '';\n metadata = '';\n\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n publishedAt: Date | null = null;\n createdAt = new Date();\n updatedAt = new Date();\n\n constructor(options: ContentCorrectionOptions = {}) {\n super(options);\n if (options.contentId) this.contentId = options.contentId;\n if (options.contentVersionId !== undefined)\n this.contentVersionId = options.contentVersionId;\n if (options.factId !== undefined) this.factId = options.factId;\n if (options.replacementFactId !== undefined)\n this.replacementFactId = options.replacementFactId;\n if (options.correctionType !== undefined)\n this.correctionType = options.correctionType;\n if (options.status !== undefined) this.status = options.status;\n if (options.summary !== undefined) this.summary = options.summary;\n if (options.incorrectText !== undefined)\n this.incorrectText = options.incorrectText;\n if (options.correctedText !== undefined)\n this.correctedText = options.correctedText;\n if (options.publicNote !== undefined) this.publicNote = options.publicNote;\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.publishedAt !== undefined)\n this.publishedAt = options.publishedAt;\n if (options.createdAt) this.createdAt = options.createdAt;\n if (options.updatedAt) this.updatedAt = options.updatedAt;\n\n if (options.metadata !== undefined) {\n this.metadata =\n typeof options.metadata === 'string'\n ? options.metadata\n : JSON.stringify(options.metadata);\n }\n }\n\n getMetadata(): Record<string, unknown> {\n try {\n return this.metadata ? JSON.parse(this.metadata) : {};\n } catch {\n return {};\n }\n }\n}\n","import { SmrtCollection } from '@happyvertical/smrt-core';\nimport { ContentCorrection } from './content-correction';\n\nexport class ContentCorrectionCollection extends SmrtCollection<ContentCorrection> {\n static readonly _itemClass = ContentCorrection;\n\n async listForContent(contentId: string): Promise<ContentCorrection[]> {\n return this.list({\n where: { contentId },\n orderBy: 'created_at DESC',\n });\n }\n\n async getPublishedForContent(\n contentId: string,\n ): Promise<ContentCorrection[]> {\n return this.list({\n where: { contentId, status: 'published' },\n orderBy: 'published_at DESC',\n });\n }\n\n async issue(\n options: ConstructorParameters<typeof ContentCorrection>[0],\n ): Promise<ContentCorrection> {\n if (!options) {\n return this.create({});\n }\n\n return this.create({\n ...options,\n metadata:\n typeof options.metadata === 'string'\n ? options.metadata\n : options.metadata\n ? JSON.stringify(options.metadata)\n : undefined,\n });\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"content-reviews-lBoqJY7Y.js","names":[],"sources":["../../src/content-review.ts","../../src/content-reviews.ts"],"sourcesContent":["import type { SmrtObjectOptions } from '@happyvertical/smrt-core';\nimport { foreignKey, SmrtObject, smrt } from '@happyvertical/smrt-core';\nimport { TenantScoped, tenantId } from '@happyvertical/smrt-tenancy';\nimport type {\n ContentReviewFinding,\n ContentReviewKind,\n ContentReviewStatus,\n} from './content-governance';\n\nexport interface ContentReviewOptions extends SmrtObjectOptions {\n contentId?: string;\n contentVersionId?: string;\n kind?: ContentReviewKind;\n policyKey?: string;\n status?: ContentReviewStatus;\n summary?: string;\n findings?: string | ContentReviewFinding[];\n reviewer?: string;\n metadata?: string | Record<string, unknown>;\n tenantId?: string | null;\n createdAt?: Date;\n updatedAt?: Date;\n}\n\n@TenantScoped({ mode: 'optional' })\n@smrt({\n tableName: 'content_reviews',\n api: { include: ['list', 'get', 'create', 'update'] },\n mcp: { include: ['list', 'get', 'create', 'update'] },\n cli: true,\n})\nexport class ContentReview extends SmrtObject {\n @foreignKey('Content', { required: true, onDelete: 'CASCADE' })\n contentId = '';\n\n @foreignKey('ContentVersion', { onDelete: 'CASCADE' })\n contentVersionId = '';\n kind: ContentReviewKind = 'custom';\n policyKey = '';\n status: ContentReviewStatus = 'pending';\n summary = '';\n findings = '[]';\n reviewer = 'system';\n metadata = '';\n\n @tenantId({ nullable: true })\n tenantId: string | null = null;\n\n createdAt = new Date();\n updatedAt = new Date();\n\n constructor(options: ContentReviewOptions = {}) {\n super(options);\n if (options.contentId) this.contentId = options.contentId;\n if (options.contentVersionId !== undefined)\n this.contentVersionId = options.contentVersionId;\n if (options.kind !== undefined) this.kind = options.kind;\n if (options.policyKey !== undefined) this.policyKey = options.policyKey;\n if (options.status !== undefined) this.status = options.status;\n if (options.summary !== undefined) this.summary = options.summary;\n if (options.reviewer !== undefined) this.reviewer = options.reviewer;\n if (options.tenantId !== undefined) this.tenantId = options.tenantId;\n if (options.createdAt) this.createdAt = options.createdAt;\n if (options.updatedAt) this.updatedAt = options.updatedAt;\n\n if (options.findings !== undefined) {\n this.findings =\n typeof options.findings === 'string'\n ? options.findings\n : JSON.stringify(options.findings);\n }\n\n if (options.metadata !== undefined) {\n this.metadata =\n typeof options.metadata === 'string'\n ? options.metadata\n : JSON.stringify(options.metadata);\n }\n }\n\n getFindings(): ContentReviewFinding[] {\n if (Array.isArray(this.findings)) {\n return this.findings as ContentReviewFinding[];\n }\n\n try {\n return this.findings ? JSON.parse(this.findings) : [];\n } catch {\n return [];\n }\n }\n\n getMetadata(): Record<string, unknown> {\n if (this.metadata && typeof this.metadata === 'object') {\n return { ...(this.metadata as Record<string, unknown>) };\n }\n\n try {\n return this.metadata ? JSON.parse(this.metadata) : {};\n } catch {\n return {};\n }\n }\n}\n","import { SmrtCollection } from '@happyvertical/smrt-core';\nimport type {\n ContentReviewKind,\n ContentReviewResult,\n} from './content-governance';\nimport { ContentReview } from './content-review';\n\nexport class ContentReviewCollection extends SmrtCollection<ContentReview> {\n static readonly _itemClass = ContentReview;\n\n async listForContent(\n contentId: string,\n kind?: ContentReviewKind,\n ): Promise<ContentReview[]> {\n return this.list({\n where: kind ? { contentId, kind } : { contentId },\n orderBy: 'created_at DESC',\n });\n }\n\n async getLatestForContent(\n contentId: string,\n kind?: ContentReviewKind,\n ): Promise<ContentReview | null> {\n const reviews = await this.listForContent(contentId, kind);\n return reviews[0] ?? null;\n }\n\n async listForContentByPolicyKey(\n contentId: string,\n policyKey: string,\n ): Promise<ContentReview[]> {\n return this.list({\n where: { contentId, policyKey },\n orderBy: 'created_at DESC',\n });\n }\n\n async getLatestForPolicyKey(\n contentId: string,\n policyKey: string,\n ): Promise<ContentReview | null> {\n const reviews = await this.listForContentByPolicyKey(contentId, policyKey);\n return reviews[0] ?? null;\n }\n\n async createFromResult(options: {\n contentId: string;\n contentVersionId?: string | null;\n kind: ContentReviewKind;\n policyKey: string;\n reviewer?: string;\n result: ContentReviewResult;\n metadata?: Record<string, unknown>;\n tenantId?: string | null;\n }): Promise<ContentReview> {\n return this.create({\n contentId: options.contentId,\n contentVersionId: options.contentVersionId || '',\n kind: options.kind,\n policyKey: options.policyKey,\n status: options.result.status,\n summary: options.result.summary,\n findings: JSON.stringify(options.result.findings),\n reviewer: options.reviewer || 'system',\n metadata: JSON.stringify(options.metadata || {}),\n tenantId: options.tenantId ?? null,\n });\n }\n}\n"],"mappings":"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"}