@ultimat3/admin 31.25.0 → 31.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -28,12 +28,13 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
28
28
  - **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag** — `import type { RouteDescriptor }` beside the dynamic `import()`; `dev/published-keys.test.ts` walks what the registries EMIT. A route fact carries `mount` (`{ by, permissions }` or `null`).
29
29
  - **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
30
30
  - **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describePages()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
31
- - **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
31
+ - **The admin's stylesheets are claimed for `app/`** (`claimStylesheets` in `stylesheet-scope(.test).ts`).
32
32
  - **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives (`policy-bridge.test.ts`).
33
33
  - **One bridge per foreign package.** `policy-bridge.ts` → `evaluate`/`definePermissions`; `route-config.ts` → `defineRoute`/`registerMountedRoutes` (+ `claim-route.ts`, the `@ultimat3/http` bridge); `mcp.ts` → `defineAppMcp`; `dev/data.ts` → introspection, dynamically. A test declares its own fixtures' permissions and calls `defineAdmin()` in `beforeAll`, never at module scope.
34
34
  - **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
35
35
  - **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
36
- - **A deliberate query loop declares itself** — `expectedQueryLoop()` from `@ultimat3/db`, with a `reason` (`search.ts`, `batch.ts`, `relations.ts`). Never a comment pragma, never an exempt list; an undeclared loop is an N+1.
36
+ - **A deliberate query loop declares itself** — `expectedQueryLoop()` from `@ultimat3/db`, with a `reason` (`search-resource.ts`, `batch.ts`, `relations.ts`), never a pragma or exempt list.
37
+ - **Search: vector, then `contains`** (`search-resource.ts`): `AdminRepo.search` iff `$search`; only `X_SEARCH_IN_MEMORY` falls back.
37
38
  - **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
38
39
  - **This catalog is the framework's one opt-OUT MCP surface, deliberately**: `mcp-tools.ts` does not call `isMcpExposed` — every tool is already gated on an admin permission. `mcp: { expose: false }` withdraws one. A *second* exception anywhere is the bug.
39
40
  - **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider — in `panel-live`, `panel-db`, `panel-timeline` and `panel-cache` alike (`dev/panel-degrade.test.ts`). Everything else reaches `panelPayload`, which renders its code and its fix.
@@ -105,8 +106,7 @@ Why each rule is shaped the way it is: [`docs/history/admin.md`](../../docs/hist
105
106
 
106
107
  ## Commands
107
108
 
108
- From the repo root — the seal-key preload is the root's, so `bun test --filter` from this
109
- directory fails on the first sealed column:
109
+ From the repo root (the seal-key preload is the root's):
110
110
 
111
111
  ```bash
112
112
  bun test ./packages/admin/
package/README.md CHANGED
@@ -481,8 +481,18 @@ Reads are audited too, and on both branches: `adminDetail` keys its entry on the
481
481
  `adminList` and `adminSearch` key theirs on the table (`entityId: null`), and a refusal is an
482
482
  entry of its own. `AdminSearchResult.audit` carries one entry per resource the call decided
483
483
  about — searched or refused — so a jump box that walked every readable entity leaves a trace.
484
- Search is the entity's `contains` per text field — case-insensitive, the `ilike` a `gin_trgm_ops`
485
- index serves — at most three statements per resource, resources read four at a time.
484
+ Search asks an entity that declares `.searchable()` columns through its vector first —
485
+ `search_tsv`, the GIN index, one statement, `matchedField: 'search_tsv'` — then fills a short page
486
+ with the entity's `contains` per text field — case-insensitive, the `ilike` a `gin_trgm_ops` index
487
+ serves — so a whole word and a prefix ("ali" → "Alice") both find the row. At most 1 + 3
488
+ statements per resource, resources read four at a time, the row scope on every one. Hits are in
489
+ the resource's `defaultSort`, not ranked. A custom `repo:` opts in with `search(query)`
490
+ (`AdminRepo.search`, optional); the memory driver refuses a vector match (`X_SEARCH_IN_MEMORY`),
491
+ so there `contains` alone answers. As of 2026-11 the GIN index serves the vector read only when the
492
+ `defaultSort` is a column no btree covers: every handle read ends in the primary key, so a sort by
493
+ `id` (or an indexed column) with a limit walks that btree and filters `@@` row by row — 139 ms
494
+ tenanted, 283 ms untenanted, against the GIN's 11 ms, on 200,000 rows. The fix is a relevance
495
+ order (`ts_rank`), which no btree serves: deferred with ranking (plan 101-finished slice 06 U2).
486
496
 
487
497
  ## AI-first
488
498
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "31.25.0",
3
+ "version": "31.26.0",
4
4
  "description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -46,21 +46,21 @@
46
46
  "test": "bun test"
47
47
  },
48
48
  "dependencies": {
49
- "@ultimat3/action": "31.25.0",
50
- "@ultimat3/ai": "31.25.0",
51
- "@ultimat3/cache": "31.25.0",
52
- "@ultimat3/core": "31.25.0",
53
- "@ultimat3/db": "31.25.0",
54
- "@ultimat3/entity": "31.25.0",
55
- "@ultimat3/http": "31.25.0",
56
- "@ultimat3/i18n": "31.25.0",
57
- "@ultimat3/jobs": "31.25.0",
58
- "@ultimat3/mcp": "31.25.0",
59
- "@ultimat3/money": "31.25.0",
60
- "@ultimat3/policy": "31.25.0",
61
- "@ultimat3/query": "31.25.0",
62
- "@ultimat3/render": "31.25.0",
63
- "@ultimat3/schema": "31.25.0",
64
- "@ultimat3/ui": "31.25.0"
49
+ "@ultimat3/action": "31.26.0",
50
+ "@ultimat3/ai": "31.26.0",
51
+ "@ultimat3/cache": "31.26.0",
52
+ "@ultimat3/core": "31.26.0",
53
+ "@ultimat3/db": "31.26.0",
54
+ "@ultimat3/entity": "31.26.0",
55
+ "@ultimat3/http": "31.26.0",
56
+ "@ultimat3/i18n": "31.26.0",
57
+ "@ultimat3/jobs": "31.26.0",
58
+ "@ultimat3/mcp": "31.26.0",
59
+ "@ultimat3/money": "31.26.0",
60
+ "@ultimat3/policy": "31.26.0",
61
+ "@ultimat3/query": "31.26.0",
62
+ "@ultimat3/render": "31.26.0",
63
+ "@ultimat3/schema": "31.26.0",
64
+ "@ultimat3/ui": "31.26.0"
65
65
  }
66
66
  }
package/src/index.ts CHANGED
@@ -209,6 +209,7 @@ export {
209
209
  type AdminMatchingResult,
210
210
  type AdminRepo,
211
211
  type AdminRow,
212
+ type AdminSearchQuery,
212
213
  type AdminSort,
213
214
  type AdminTable,
214
215
  type AdminTableRead,
package/src/registry.ts CHANGED
@@ -95,6 +95,12 @@ export interface AdminEntity {
95
95
  * fixture has none: never a form input, and every update carries the version the admin read.
96
96
  */
97
97
  readonly $version?: string | null;
98
+ /**
99
+ * The generated `tsvector` its `.searchable()` columns derive, `null` with none. Optional only
100
+ * because a hand-built fixture has none. The admin reads the COLUMN alone: it names a full-text
101
+ * hit's `matchedField`, and its presence is what makes `adminRepoFor` offer `search`.
102
+ */
103
+ readonly $search?: { readonly column: string } | null;
98
104
  /** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
99
105
  $describe(): AdminEntityDescription;
100
106
  }
@@ -119,6 +125,11 @@ export interface AdminTableRead {
119
125
  andWhere(column: string, op: Operator, value?: unknown): AdminTableRead;
120
126
  orderBy(column: string, direction?: 'asc' | 'desc'): AdminTableRead;
121
127
  limit(rows: number): AdminTableRead;
128
+ /**
129
+ * The entity's full-text match — legal only on an entity whose `$search` is not `null`.
130
+ * Optional so a hand-built table still satisfies this surface; `database()`'s always has it.
131
+ */
132
+ search?(term: string): AdminTableRead;
122
133
  all(): Promise<readonly AdminRow[]>;
123
134
  one(): Promise<AdminRow | null>;
124
135
  count(): Promise<number>;
@@ -218,8 +229,23 @@ export interface AdminListQuery {
218
229
  readonly before?: KeysetBound;
219
230
  }
220
231
 
232
+ /** One full-text read: the search box's term over the resource's row scope, first page only. */
233
+ export interface AdminSearchQuery {
234
+ /** User text. Bound as a parameter and parsed by `websearch_to_tsquery`, never a tsquery. */
235
+ readonly term: string;
236
+ readonly where: readonly AdminFilter[];
237
+ readonly sort: AdminSort;
238
+ readonly limit: number;
239
+ }
240
+
221
241
  export interface AdminRepo<Row> {
222
242
  list(query: AdminListQuery): Promise<readonly Row[]>;
243
+ /**
244
+ * The entity's declared full-text index (`search_tsv`, GIN), when it has one. Optional: a custom
245
+ * `repo:` without it is searched by `contains` alone. The memory driver rejects it
246
+ * `X_SEARCH_IN_MEMORY`, which `adminSearch` answers with the `contains` loop.
247
+ */
248
+ search?(query: AdminSearchQuery): Promise<readonly Row[]>;
223
249
  find(id: string): Promise<Row | null>;
224
250
  create(input: Readonly<Record<string, unknown>>): Promise<Row>;
225
251
  update(id: string, patch: Readonly<Record<string, unknown>>): Promise<Row>;
@@ -13,6 +13,7 @@ import type {
13
13
  AdminListQuery,
14
14
  AdminRepo,
15
15
  AdminRow,
16
+ AdminSearchQuery,
16
17
  AdminTable,
17
18
  AdminTableRead,
18
19
  } from './registry';
@@ -105,6 +106,7 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
105
106
  const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
106
107
  const typed = typedBy(entity);
107
108
  const columns = adminColumnsOf(entity);
109
+ const searchOf = table.search;
108
110
  /**
109
111
  * `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
110
112
  * OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
@@ -159,6 +161,19 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
159
161
  },
160
162
  );
161
163
  },
164
+ // Offered only when the entity declares a vector: the handle refuses `.search()` on one that
165
+ // does not (`X_SEARCH_UNDECLARED`), and an absent verb is how `adminSearch` knows to skip it.
166
+ ...(entity.$search === undefined || entity.$search === null || searchOf === undefined
167
+ ? {}
168
+ : {
169
+ search: async (query: AdminSearchQuery): Promise<readonly AdminRow[]> => {
170
+ const { field, direction } = query.sort;
171
+ const read = filtered(searchOf.call(table, query.term), query.where, typed);
172
+ // The id breaks ties, as on a list page; named once when it IS the sort.
173
+ const sorted = field === idField ? read : read.orderBy(field, direction);
174
+ return [...(await sorted.orderBy(idField, direction).limit(query.limit).all())];
175
+ },
176
+ }),
162
177
  // `async`, so a refused id arrives as a rejection: these return promises, and a caller that
163
178
  // chains `.catch()` instead of `await`ing would never see a synchronous throw.
164
179
  find: async (id) => {
@@ -0,0 +1,108 @@
1
+ // One resource's answer to the search box: its declared full-text index first (`search_tsv`, GIN,
2
+ // one statement), then one `contains` per text field to fill a short page — so a whole word and a
3
+ // substring ("ali" → "Alice") both find the row. At most 1 + `MAX_FIELDS_PER_RESOURCE` statements.
4
+
5
+ import { isUltimateError } from '@ultimat3/core';
6
+ import { expectedQueryLoop } from '@ultimat3/db';
7
+ import type { AdminActor } from './authz';
8
+ import { rowWhere } from './list-scope';
9
+ import type { AdminFilter, AdminRepo, AdminRow } from './registry';
10
+ import { rowId } from './registry';
11
+ import { labelOf } from './relations';
12
+ import { type AdminResource, repoOf } from './resource';
13
+
14
+ /** Per resource, and per field within it. Search is a jump box, not a report. */
15
+ const MAX_FIELDS_PER_RESOURCE = 3;
16
+
17
+ export interface AdminSearchHit {
18
+ readonly entity: string;
19
+ readonly id: string;
20
+ /** The row's label-field value. Already a string; the view does not format it. */
21
+ readonly label: string;
22
+ /** The text field a `contains` matched, or the vector column for a full-text hit. */
23
+ readonly matchedField: string;
24
+ /** Mount-relative link to the detail page. */
25
+ readonly href: string;
26
+ }
27
+
28
+ /**
29
+ * The full-text read, or `[]` where it cannot run: no vector, a repo with no `search`, or the
30
+ * memory driver — which refuses a vector match (`X_SEARCH_IN_MEMORY`) rather than answer one
31
+ * Postgres would not, so there the `contains` loop is the whole answer. Any other failure is
32
+ * the resource's failure, as a `contains` read's is.
33
+ */
34
+ async function vectorRows(
35
+ resource: AdminResource,
36
+ repo: AdminRepo<AdminRow>,
37
+ where: readonly AdminFilter[],
38
+ term: string,
39
+ limit: number,
40
+ ): Promise<readonly AdminRow[]> {
41
+ if (repo.search === undefined || (resource.entity.$search ?? null) === null) return [];
42
+ try {
43
+ return await repo.search({ term, where, sort: resource.defaultSort, limit });
44
+ } catch (error) {
45
+ if (isUltimateError(error) && error.code === 'X_SEARCH_IN_MEMORY') return [];
46
+ throw error;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * One `contains` per searchable field rather than one query with an OR: the admin's query IR is a
52
+ * conjunction by design, and each field's `contains` is the entity's case-insensitive `ilike` —
53
+ * served by that column's `gin_trgm_ops` index where the app declared one (`X_QUERY_UNINDEXED`
54
+ * names the declaration where it did not), which one OR across columns could not use.
55
+ *
56
+ * That argument is declared to the runtime and not only to the reader — `expectedQueryLoop`
57
+ * carries it onto every statement the loop issues, so a statement diagnostic reports the loops
58
+ * nobody argued for and never this one. The scope ends with the loop: anything the repo does
59
+ * afterwards is judged normally.
60
+ */
61
+ export async function searchResource(
62
+ resource: AdminResource,
63
+ actor: AdminActor,
64
+ term: string,
65
+ limit: number,
66
+ ): Promise<readonly AdminSearchHit[]> {
67
+ const repo = repoOf(resource);
68
+ // The resource's row scope rides every lookup, the vector's included: a hit is a row, and a row
69
+ // this actor's `rows` leaves out is not one they may be told exists.
70
+ const visible = rowWhere(resource, actor);
71
+ const seen = new Set<string>();
72
+ const hits: AdminSearchHit[] = [];
73
+ const take = (rows: readonly AdminRow[], matchedField: string): boolean => {
74
+ for (const row of rows) {
75
+ const id = rowId(row, resource.idField);
76
+ if (id === '' || seen.has(id)) continue;
77
+ seen.add(id);
78
+ hits.push({
79
+ entity: resource.name,
80
+ id,
81
+ label: labelOf(row, resource),
82
+ matchedField,
83
+ href: `${resource.path}/${id}`,
84
+ });
85
+ if (hits.length >= limit) return true;
86
+ }
87
+ return false;
88
+ };
89
+
90
+ return expectedQueryLoop(
91
+ 'admin search runs one indexed lookup per text field, which beats one unindexed OR',
92
+ async () => {
93
+ const vector = resource.entity.$search?.column;
94
+ if (vector !== undefined) {
95
+ if (take(await vectorRows(resource, repo, visible, term, limit), vector)) return hits;
96
+ }
97
+ for (const field of resource.searchFields.slice(0, MAX_FIELDS_PER_RESOURCE)) {
98
+ const where: readonly AdminFilter[] = [
99
+ ...visible,
100
+ { field: field.name, op: 'contains', value: term },
101
+ ];
102
+ const rows = await repo.list({ where, sort: resource.defaultSort, limit });
103
+ if (take(rows, field.name)) return hits;
104
+ }
105
+ return hits;
106
+ },
107
+ );
108
+ }
package/src/search.ts CHANGED
@@ -1,22 +1,17 @@
1
- // Cross-entity search, derived: every resource's text fields are the index. No separate
2
- // search config to drift from the entities, and no result an actor could not have opened —
3
- // the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
1
+ // Cross-entity search, derived: an entity's declared full-text index, then its text fields, are
2
+ // the index (`search-resource.ts`). No separate search config to drift from the entities, and no
3
+ // result an actor could not have opened — `admin:read` + `<entity>:read` gate hit and detail alike.
4
4
 
5
5
  import { finiteCount, mapBounded } from '@ultimat3/core';
6
- import { expectedQueryLoop } from '@ultimat3/db';
7
6
  import { type AuditEntry, deniedDraft } from './audit';
8
- import type { AdminActor } from './authz';
9
7
  import { adminDenied } from './authz';
10
8
  import type { CrudCtx } from './crud';
11
9
  import { decideOperation } from './crud';
12
- import { rowWhere } from './list-scope';
13
- import type { AdminFilter } from './registry';
14
- import { rowId } from './registry';
15
- import { labelOf } from './relations';
16
- import { type AdminResource, repoOf } from './resource';
10
+ import type { AdminResource } from './resource';
11
+ import { type AdminSearchHit, searchResource } from './search-resource';
12
+
13
+ export type { AdminSearchHit } from './search-resource';
17
14
 
18
- /** Per resource, and per field within it. Search is a jump box, not a report. */
19
- const MAX_FIELDS_PER_RESOURCE = 3;
20
15
  const DEFAULT_LIMIT_PER_RESOURCE = 5;
21
16
  /**
22
17
  * Resources read at once. Independent reads, so their latencies overlap rather than add — and a
@@ -24,16 +19,6 @@ const DEFAULT_LIMIT_PER_RESOURCE = 5;
24
19
  */
25
20
  const SEARCH_WIDTH = 4;
26
21
 
27
- export interface AdminSearchHit {
28
- readonly entity: string;
29
- readonly id: string;
30
- /** The row's label-field value. Already a string; the view does not format it. */
31
- readonly label: string;
32
- readonly matchedField: string;
33
- /** Mount-relative link to the detail page. */
34
- readonly href: string;
35
- }
36
-
37
22
  export interface AdminSearchResult {
38
23
  readonly term: string;
39
24
  readonly hits: readonly AdminSearchHit[];
@@ -57,59 +42,6 @@ export interface AdminSearchInput {
57
42
  readonly limitPerResource?: number;
58
43
  }
59
44
 
60
- /**
61
- * One `contains` per searchable field rather than one query with an OR: the admin's query IR is a
62
- * conjunction by design, and each field's `contains` is the entity's case-insensitive `ilike` —
63
- * served by that column's `gin_trgm_ops` index where the app declared one (`X_QUERY_UNINDEXED`
64
- * names the declaration where it did not), which one OR across columns could not use.
65
- *
66
- * That argument is declared to the runtime and not only to the reader — `expectedQueryLoop`
67
- * carries it onto every statement the loop issues, so a statement diagnostic reports the loops
68
- * nobody argued for and never this one. At most `MAX_FIELDS_PER_RESOURCE` queries, and the scope
69
- * ends with the loop: anything the repo does afterwards is judged normally.
70
- */
71
- async function searchResource(
72
- resource: AdminResource,
73
- actor: AdminActor,
74
- term: string,
75
- limit: number,
76
- ): Promise<readonly AdminSearchHit[]> {
77
- const repo = repoOf(resource);
78
- // The resource's row scope rides every lookup: a hit is a row, and a row this actor's `rows`
79
- // leaves out is not one they may be told exists.
80
- const visible = rowWhere(resource, actor);
81
- const fields = resource.searchFields.slice(0, MAX_FIELDS_PER_RESOURCE);
82
- const seen = new Set<string>();
83
- const hits: AdminSearchHit[] = [];
84
-
85
- return expectedQueryLoop(
86
- 'admin search runs one indexed lookup per text field, which beats one unindexed OR',
87
- async () => {
88
- for (const field of fields) {
89
- const where: readonly AdminFilter[] = [
90
- ...visible,
91
- { field: field.name, op: 'contains', value: term },
92
- ];
93
- const rows = await repo.list({ where, sort: resource.defaultSort, limit });
94
- for (const row of rows) {
95
- const id = rowId(row, resource.idField);
96
- if (id === '' || seen.has(id)) continue;
97
- seen.add(id);
98
- hits.push({
99
- entity: resource.name,
100
- id,
101
- label: labelOf(row, resource),
102
- matchedField: field.name,
103
- href: `${resource.path}/${id}`,
104
- });
105
- if (hits.length >= limit) return hits;
106
- }
107
- }
108
- return hits;
109
- },
110
- );
111
- }
112
-
113
45
  const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
114
46
  const SKIPPED_FAILED = 'admin.search.skipped.failed';
115
47