@ultimat3/admin 31.24.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 +4 -4
- package/README.md +12 -2
- package/package.json +17 -17
- package/src/index.ts +1 -0
- package/src/jobs/operator.ts +3 -8
- package/src/registry.ts +26 -0
- package/src/repo-entity.ts +15 -0
- package/src/search-resource.ts +108 -0
- package/src/search.ts +7 -75
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/`** (`
|
|
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`)
|
|
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
|
|
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
|
|
485
|
-
|
|
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.
|
|
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.
|
|
50
|
-
"@ultimat3/ai": "31.
|
|
51
|
-
"@ultimat3/cache": "31.
|
|
52
|
-
"@ultimat3/core": "31.
|
|
53
|
-
"@ultimat3/db": "31.
|
|
54
|
-
"@ultimat3/entity": "31.
|
|
55
|
-
"@ultimat3/http": "31.
|
|
56
|
-
"@ultimat3/i18n": "31.
|
|
57
|
-
"@ultimat3/jobs": "31.
|
|
58
|
-
"@ultimat3/mcp": "31.
|
|
59
|
-
"@ultimat3/money": "31.
|
|
60
|
-
"@ultimat3/policy": "31.
|
|
61
|
-
"@ultimat3/query": "31.
|
|
62
|
-
"@ultimat3/render": "31.
|
|
63
|
-
"@ultimat3/schema": "31.
|
|
64
|
-
"@ultimat3/ui": "31.
|
|
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
package/src/jobs/operator.ts
CHANGED
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
// boot installs it after `defineAdmin()` has run, and `x dev` swaps it — and its operator surface.
|
|
3
3
|
// A process with no driver, or one that ships no operator surface, is refused with the fix.
|
|
4
4
|
|
|
5
|
-
import { NotImplementedError } from '@ultimat3/core';
|
|
6
5
|
import {
|
|
7
6
|
DriverUnavailableError,
|
|
8
7
|
type JobDriver,
|
|
9
8
|
type JobIntrospection,
|
|
10
9
|
jobDriver,
|
|
10
|
+
requireIntrospection,
|
|
11
11
|
} from '@ultimat3/jobs';
|
|
12
12
|
|
|
13
13
|
export interface JobsOperator {
|
|
@@ -24,11 +24,6 @@ export function jobsOperator(): JobsOperator {
|
|
|
24
24
|
fix: 'run the app through x dev or the container entry, which install the queue — or in a test: installJobDriver(memoryJobDriver()) from @ultimat3/jobs/adapter',
|
|
25
25
|
});
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
cause: `the operator surface of the "${driver.name}" jobs driver is not implemented: the driver has no introspect`,
|
|
30
|
-
fix: 'export const runtime = { jobs: postgresJobDriver() } # apps/<app>/runtime.ts — or delete that line so the boot installs it; the pg and memory drivers implement introspect',
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
return { driver, introspect: driver.introspect };
|
|
27
|
+
// Jobs' own guard: one refusal (X_JOB_INTROSPECTION_UNSUPPORTED) for every operator surface.
|
|
28
|
+
return { driver, introspect: requireIntrospection(driver) };
|
|
34
29
|
}
|
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>;
|
package/src/repo-entity.ts
CHANGED
|
@@ -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:
|
|
2
|
-
// search config to drift from the entities, and no
|
|
3
|
-
//
|
|
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 {
|
|
13
|
-
import type
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|