@ultimat3/admin 31.16.0 → 31.18.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/README.md CHANGED
@@ -85,6 +85,12 @@ the actor may not list has no tile, no segment and no matrix; one that did not o
85
85
  no tile and never fails the page. A count reads no row, so it is not audited. Each matrix is a
86
86
  closed `<details>` whose summary reads "N of M allowed".
87
87
 
88
+ `count: 'estimate'` instead reads `AdminRepo.approximateCount()` — the planner's whole-table
89
+ figure, constant time — when the resource declares no `rows` scope and the estimate is at least
90
+ 10,000 rows; the tile reads `~1,200,000` (`admin.dashboard.records.approximate`). Anything else —
91
+ a row scope, a small or never-analysed table, a tenant-scoped or soft-deleting one the estimate
92
+ refuses — is the exact `count()`.
93
+
88
94
  | Input | What it is | Omitted |
89
95
  |---|---|---|
90
96
  | `entities` | the objects `entity()` returned — `adminEntitiesOf(db)` is every entity the handle serves | required |
@@ -127,7 +133,7 @@ unfiltered list. An empty value is no parameter: a GET form submits every input
127
133
 
128
134
  | Filter shape | Derived for | Control | Operators (first is the default) |
129
135
  |---|---|---|---|
130
- | `text` | a text column `LIKE` runs against — the label always, others when indexed | search box | `contains` `eq` `neq` `is-null` |
136
+ | `text` | a text column `contains` runs against — the label always, others when indexed; case-insensitive (`ilike`, served by a `gin_trgm_ops` index) | search box | `contains` `eq` `neq` `is-null` |
131
137
  | `exact` | a uuid, `bigint` or `numeric` column | text box | `eq` `neq` `in` `is-null` |
132
138
  | `choice` | an enum (its own values, labelled by the catalog), a locale, a zone | select | `eq` `neq` `in` `is-null` |
133
139
  | `boolean` | a boolean | any / yes / no | `eq` |
@@ -164,6 +170,7 @@ defineAdmin({
164
170
  |---|---|
165
171
  | `scopes.<name>` | `where` is predicates or `(actor) => predicates`; `default: true` at most once; `count: true` reads `AdminRepo.count` — never the caller's filters, so a tab's number is the scope's size |
166
172
  | `rows` | applied by every read of the resource; a row outside it is "not found", exactly as a missing one |
173
+ | a declared `contains` | in `rows` or a scope's `where`: case-SENSITIVE (`like`, `caseSensitive: true` on the filter), on the read and the write — a row scope is authorization and never widens. Only what an operator types — search, list filter, lookup — ignores case |
167
174
  | `columns.<name>` | `render`: `badge` · `relative-time` · `money` · `truncate` · `link` · `json`, or `(props) => JSX`; the row it reads carries no sealed column; a name that is also an entity column is refused |
168
175
  | `labelField` | what a reference to this resource SHOWS, linked to the row; read once per page per target, never per row |
169
176
 
@@ -474,6 +481,8 @@ Reads are audited too, and on both branches: `adminDetail` keys its entry on the
474
481
  `adminList` and `adminSearch` key theirs on the table (`entityId: null`), and a refusal is an
475
482
  entry of its own. `AdminSearchResult.audit` carries one entry per resource the call decided
476
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.
477
486
 
478
487
  ## AI-first
479
488
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "31.16.0",
3
+ "version": "31.18.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.16.0",
50
- "@ultimat3/ai": "31.16.0",
51
- "@ultimat3/cache": "31.16.0",
52
- "@ultimat3/core": "31.16.0",
53
- "@ultimat3/db": "31.16.0",
54
- "@ultimat3/entity": "31.16.0",
55
- "@ultimat3/http": "31.16.0",
56
- "@ultimat3/i18n": "31.16.0",
57
- "@ultimat3/jobs": "31.16.0",
58
- "@ultimat3/mcp": "31.16.0",
59
- "@ultimat3/money": "31.16.0",
60
- "@ultimat3/policy": "31.16.0",
61
- "@ultimat3/query": "31.16.0",
62
- "@ultimat3/render": "31.16.0",
63
- "@ultimat3/schema": "31.16.0",
64
- "@ultimat3/ui": "31.16.0"
49
+ "@ultimat3/action": "31.18.0",
50
+ "@ultimat3/ai": "31.18.0",
51
+ "@ultimat3/cache": "31.18.0",
52
+ "@ultimat3/core": "31.18.0",
53
+ "@ultimat3/db": "31.18.0",
54
+ "@ultimat3/entity": "31.18.0",
55
+ "@ultimat3/http": "31.18.0",
56
+ "@ultimat3/i18n": "31.18.0",
57
+ "@ultimat3/jobs": "31.18.0",
58
+ "@ultimat3/mcp": "31.18.0",
59
+ "@ultimat3/money": "31.18.0",
60
+ "@ultimat3/policy": "31.18.0",
61
+ "@ultimat3/query": "31.18.0",
62
+ "@ultimat3/render": "31.18.0",
63
+ "@ultimat3/schema": "31.18.0",
64
+ "@ultimat3/ui": "31.18.0"
65
65
  }
66
66
  }
package/src/fields.ts CHANGED
@@ -232,8 +232,8 @@ export function sortable(type: AdminFieldType, column: AdminColumnFacts): boolea
232
232
  *
233
233
  * It has to be the KIND and not the field type, because several kinds render in a text box and
234
234
  * only two of them can be searched: `adminSearch` issues one `contains` filter per searchable
235
- * field and the driver compiles `contains` to `<column> like $1` with NO cast
236
- * (`packages/entity/src/pg-sql.ts`), so a searchable column of any other kind makes the admin's
235
+ * field and the driver compiles `contains` to `<column> ilike $1` with NO cast
236
+ * (`packages/entity/src/text-contains.ts`), so a searchable column of any other kind makes the admin's
237
237
  * search box answer a database error rather than an empty result.
238
238
  */
239
239
  const LIKE_ABLE_COLUMN_KINDS: ReadonlySet<string> = new Set(['text', 'char']);
@@ -3,7 +3,12 @@
3
3
  // from `overviewData`, so the chart's bucketing is tested without a queue.
4
4
 
5
5
  import { expectedQueryLoop } from '@ultimat3/db';
6
- import { type CounterTotals, inspectQueues, type JobCounter } from '@ultimat3/jobs';
6
+ import {
7
+ type CounterTotals,
8
+ inspectQueues,
9
+ type JobCounter,
10
+ type JobIntrospection,
11
+ } from '@ultimat3/jobs';
7
12
  import { AdminFilterInvalidError } from '../errors';
8
13
  import { jobsOperator } from './operator';
9
14
 
@@ -90,6 +95,22 @@ export function nameRowsOf(totals: readonly CounterTotals[]): readonly NameRow[]
90
95
  });
91
96
  }
92
97
 
98
+ /** The fallback for a driver that ships no `countersMany`: one read per name, declared. */
99
+ function countersPerName(
100
+ introspect: JobIntrospection,
101
+ jobs: readonly string[],
102
+ sinceMs: number,
103
+ ): Promise<readonly JobCounter[]> {
104
+ return expectedQueryLoop(
105
+ 'the jobs overview reads each settled job name’s counter buckets: this driver has no countersMany',
106
+ async () => {
107
+ const all: JobCounter[] = [];
108
+ for (const job of jobs) all.push(...(await introspect.counters({ job, sinceMs })));
109
+ return all;
110
+ },
111
+ );
112
+ }
113
+
93
114
  export interface OverviewData {
94
115
  readonly totals: Awaited<ReturnType<typeof inspectQueues>>['totals'];
95
116
  readonly oldestReadyMs: number;
@@ -99,9 +120,10 @@ export interface OverviewData {
99
120
  }
100
121
 
101
122
  /**
102
- * Two statements and one per job name that settled in the range: the totals say which names have
103
- * buckets at all, and each one's buckets are read by name — the store keys counters by job. The
104
- * loop is declared rather than hidden: it is bounded by the app's registered job names.
123
+ * Four statements whatever the app's size: queue depth, the worker registry, the totals (which
124
+ * names have buckets at all), then every one of those names' buckets in ONE `countersMany` read.
125
+ * A driver without it is read one `counters` call per name — declared, bounded by the app's
126
+ * registered job names.
105
127
  */
106
128
  export async function overviewData(range: OverviewRange, nowMs: number): Promise<OverviewData> {
107
129
  const { driver, introspect } = jobsOperator();
@@ -111,14 +133,11 @@ export async function overviewData(range: OverviewRange, nowMs: number): Promise
111
133
  introspect.workers(),
112
134
  introspect.counterTotals(sinceMs),
113
135
  ]);
114
- const counters = await expectedQueryLoop(
115
- 'the jobs overview reads each settled job name’s counter buckets; the store keys them by name',
116
- async () => {
117
- const all: JobCounter[] = [];
118
- for (const row of totals) all.push(...(await introspect.counters({ job: row.job, sinceMs })));
119
- return all;
120
- },
121
- );
136
+ const jobs = totals.map((row) => row.job);
137
+ const counters =
138
+ introspect.countersMany === undefined
139
+ ? await countersPerName(introspect, jobs, sinceMs)
140
+ : await introspect.countersMany({ jobs, sinceMs });
122
141
  return {
123
142
  totals: depth.totals,
124
143
  oldestReadyMs: depth.oldestReadyMs,
@@ -216,7 +216,7 @@ export function checkedFilter(resource: AdminResource, asked: AskedFilter): Admi
216
216
  * Predicates the APP declared — a scope's `where`, a resource's `rows` — held to a narrower rule
217
217
  * than a caller's: any column of the entity may be named (the app knows which of its columns are
218
218
  * indexed), but never one that is not a column and never a sealed one, whose ciphertext no
219
- * predicate can be written against.
219
+ * predicate can be written against. A declared `contains` comes back `caseSensitive`.
220
220
  */
221
221
  export function declaredFilters(
222
222
  resource: Pick<AdminResource, 'name' | 'entity'>,
@@ -242,5 +242,9 @@ export function declaredFilters(
242
242
  if (!open.includes(filter.field)) refuse('names a field that is not a column', open);
243
243
  if (!isOp(filter.op)) refuse('names an operator the admin does not have', FILTER_OPS);
244
244
  }
245
- return where;
245
+ // Marked, so the read (`repo-entity.ts`) and the write (`row-scope-write.ts`) both keep a
246
+ // declared `contains` exact — the case-insensitive one is for what an operator types.
247
+ return where.map((filter) =>
248
+ filter.op === 'contains' ? { ...filter, caseSensitive: true as const } : filter,
249
+ );
246
250
  }
package/src/registry.ts CHANGED
@@ -122,6 +122,8 @@ export interface AdminTableRead {
122
122
  all(): Promise<readonly AdminRow[]>;
123
123
  one(): Promise<AdminRow | null>;
124
124
  count(): Promise<number>;
125
+ /** The planner's whole-table estimate, `null` when it has none. Optional: a slice may lack it. */
126
+ approximateCount?(): Promise<number | null>;
125
127
  /** Read for the entity NAME only: it is how a handle is matched to the entity it serves. */
126
128
  plan(): { readonly entity: string };
127
129
  }
@@ -171,6 +173,13 @@ export interface AdminFilter {
171
173
  readonly field: string;
172
174
  readonly op: FilterOp;
173
175
  readonly value: string | number | boolean | null | readonly string[];
176
+ /**
177
+ * Set on every predicate the APP declared — a `rows` scope, a scope tab's `where` — by
178
+ * `declaredFilters`, and never on one a caller typed: such a `contains` is the exact,
179
+ * case-sensitive substring it shipped as, because a row scope is authorization and folding case
180
+ * would widen it. A caller's `contains` (search box, list filter, lookup) ignores case.
181
+ */
182
+ readonly caseSensitive?: true;
174
183
  }
175
184
 
176
185
  export interface AdminSort {
@@ -216,6 +225,12 @@ export interface AdminRepo<Row> {
216
225
  update(id: string, patch: Readonly<Record<string, unknown>>): Promise<Row>;
217
226
  destroy(id: string): Promise<void>;
218
227
  count?(where?: readonly AdminFilter[]): Promise<number>;
228
+ /**
229
+ * The store's whole-table estimate in constant time — `null` when it has none, a rejection when
230
+ * the table cannot be estimated whole (a tenant-scoped one). What `count: 'estimate'` reads;
231
+ * never asked with a predicate.
232
+ */
233
+ approximateCount?(): Promise<number | null>;
219
234
  }
220
235
 
221
236
  /** What the admin runs an action with. The action's own `ctx` is richer; this is the slice
@@ -20,13 +20,16 @@ import type {
20
20
  type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
21
21
 
22
22
  /**
23
- * `contains` is the admin's word for it; `like` with both wildcards is what a driver runs. A Map,
24
- * so an operator that is not one of the seven answers `undefined` rather than a prototype member.
23
+ * Every admin operator is the handle's own, `contains` included: on a `text()`/`char()` column the
24
+ * entity's `contains` is a case-insensitive substring, compiled to the bound `ilike` a
25
+ * `gin_trgm_ops` index serves (`packages/entity/src/text-contains.ts`) and escaped there, so a `%`
26
+ * an operator types is a percent sign. A declared one (`caseSensitive`) stays an exact `like`.
27
+ * A Map, so an operator that is not one of these answers `undefined` rather than a prototype member.
25
28
  */
26
29
  const OPERATOR_OF: ReadonlyMap<AdminFilter['op'], TableOperator> = new Map([
27
30
  ['eq', 'eq'],
28
31
  ['neq', 'neq'],
29
- ['contains', 'like'],
32
+ ['contains', 'contains'],
30
33
  ['gt', 'gt'],
31
34
  ['gte', 'gte'],
32
35
  ['lt', 'lt'],
@@ -35,7 +38,7 @@ const OPERATOR_OF: ReadonlyMap<AdminFilter['op'], TableOperator> = new Map([
35
38
  ['is-null', 'is-null'],
36
39
  ]);
37
40
 
38
- /** `%` and `_` are wildcards to `LIKE`; a `contains` matches the text an operator typed. */
41
+ /** `%` and `_` are wildcards to `LIKE`; a declared `contains` matches the exact text it names. */
39
42
  const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\${wild}`);
40
43
 
41
44
  /**
@@ -65,9 +68,13 @@ const typedBy = (entity: AdminEntity): Typed => {
65
68
  const predicate =
66
69
  (typed: Typed) =>
67
70
  (read: AdminTableRead, filter: AdminFilter): AdminTableRead => {
68
- if (filter.op === 'contains') {
71
+ // An app-declared `contains` (a row scope is authorization) keeps its case-sensitive `like`.
72
+ if (filter.op === 'contains' && filter.caseSensitive === true) {
69
73
  return read.andWhere(filter.field, 'like', `%${literal(String(filter.value))}%`);
70
74
  }
75
+ if (filter.op === 'contains') {
76
+ return read.andWhere(filter.field, 'contains', String(filter.value));
77
+ }
71
78
  // One admin operator, two of the handle's: `false` asks for the rows that HAVE a value.
72
79
  if (filter.op === 'is-null') {
73
80
  return read.andWhere(filter.field, filter.value === false ? 'is-not-null' : 'is-null');
@@ -162,6 +169,7 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
162
169
  update: async (id, patch) => table.update(idOf(id), patch),
163
170
  destroy: async (id) => table.delete(idOf(id)),
164
171
  count: async (where) => filtered(table, where ?? [], typed).count(),
172
+ approximateCount: async () => (await table.approximateCount?.()) ?? null,
165
173
  };
166
174
  }
167
175
 
@@ -132,14 +132,14 @@ export function scopesOf(
132
132
  );
133
133
  }
134
134
  const where = scope.where;
135
- if (typeof where !== 'function') declaredFilters(resource, where, declaredAs);
135
+ const fixed = typeof where === 'function' ? [] : declaredFilters(resource, where, declaredAs);
136
136
  return {
137
137
  name,
138
138
  labelKey: scope.labelKey ?? `admin.${resource.name}.scope.${name}`,
139
139
  default: scope.default === true,
140
140
  count: scope.count === true,
141
141
  where: (actor) =>
142
- typeof where === 'function' ? declaredFilters(resource, where(actor), declaredAs) : where,
142
+ typeof where === 'function' ? declaredFilters(resource, where(actor), declaredAs) : fixed,
143
143
  };
144
144
  });
145
145
  }
package/src/resource.ts CHANGED
@@ -74,9 +74,11 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
74
74
  * Show this resource's row count on the `/admin` home. Opt-in, as a scope tab's `count` is: the
75
75
  * figure is one full `count()` over the actor's rows on EVERY visit to the front page, so on a
76
76
  * big table any operator reloading it is a load generator. Absent: no tile, the resource is still
77
- * listed in the permission matrix.
77
+ * listed in the permission matrix. `'estimate'`: the planner's figure (`approximateCount()`,
78
+ * constant time) where the row scope is the whole predicate and the estimate is at least
79
+ * `ESTIMATE_COUNT_FLOOR` — the tile then reads `~` — and the exact count everywhere else.
78
80
  */
79
- readonly count?: boolean;
81
+ readonly count?: boolean | 'estimate';
80
82
  /**
81
83
  * The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
82
84
  * detail, lookup, labels, MCP — so it is the one place a second audience is declared.
@@ -142,8 +144,10 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
142
144
  readonly columns: readonly AdminComputedColumn[];
143
145
  /** Declared scopes, in tab order. Empty for a resource that declares none. */
144
146
  readonly scopes: readonly AdminScope[];
145
- /** The `/admin` home counts this resource — `count: true` declared. */
147
+ /** The `/admin` home counts this resource — `count: true` or `count: 'estimate'` declared. */
146
148
  readonly count: boolean;
149
+ /** `count: 'estimate'`: the home may show the planner's figure. Absent or `false`: exact. */
150
+ readonly countEstimate?: boolean;
147
151
  /** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
148
152
  readonly rowScope?: AdminRowScope;
149
153
  /** The detail page's groups. Always at least one: undeclared fields have a default section. */
@@ -319,7 +323,8 @@ export function adminResource<Row extends AdminRow = AdminRow>(
319
323
  searchFields: fields.filter((field) => field.searchable),
320
324
  columns: computedColumnsOf(declared, opts.columns),
321
325
  scopes: scopesOf(declared, opts.scopes, opts.repo),
322
- count: opts.count === true,
326
+ count: opts.count === true || opts.count === 'estimate',
327
+ countEstimate: opts.count === 'estimate',
323
328
  ...(rowScope === undefined ? {} : { rowScope }),
324
329
  sections: layoutOf(
325
330
  {
@@ -66,7 +66,8 @@ function satisfies(row: AdminRow, filter: AdminFilter): boolean | undefined {
66
66
  case 'in':
67
67
  return Array.isArray(filter.value) && filter.value.some((one) => equal(held, one));
68
68
  case 'contains':
69
- // `LIKE` is case-sensitive and collation-free for a literal substring.
69
+ // A row scope is authorization: its `contains` is the exact, case-sensitive substring the
70
+ // read runs as `like` (`repo-entity.ts`, `caseSensitive`) — folding case here would widen it.
70
71
  return typeof held === 'string' ? held.includes(String(filter.value)) : undefined;
71
72
  default: {
72
73
  const order = ordered(held, filter.value);
@@ -1,20 +1,53 @@
1
1
  // The `/admin` home's figures: how many rows of each resource this actor could open. One
2
2
  // `AdminRepo.count` per resource that declared `count: true`, over its row scope and nothing else
3
3
  // — `rowWhere`, the predicate every list read starts from — so a tile never counts a hidden row.
4
+ // `count: 'estimate'` reads the planner's figure instead, where that is still the same question.
4
5
 
5
6
  import type { AdminActor } from './authz';
6
7
  import { rowWhere } from './list-scope';
8
+ import type { AdminFilter } from './registry';
7
9
  import type { AdminResource } from './resource';
8
10
 
11
+ /**
12
+ * Below this many rows an exact `COUNT(*)` is cheap and the planner's figure is at its least
13
+ * reliable (a table analysed when it was empty), so the exact count is the one shown.
14
+ */
15
+ export const ESTIMATE_COUNT_FLOOR = 10_000;
16
+
9
17
  export interface ResourceCount {
10
18
  readonly resource: AdminResource;
11
19
  readonly count: number;
20
+ /** The planner's estimate (`count: 'estimate'`), drawn `~`. Absent or `false`: exact. */
21
+ readonly approximate?: boolean;
22
+ }
23
+
24
+ type Counted = { readonly count: number; readonly approximate: boolean };
25
+
26
+ /**
27
+ * The estimate is a WHOLE-TABLE figure, so it answers the tile's question only when the row scope
28
+ * adds no predicate; `null` (never analysed), a refusal (a tenant-scoped table) or a small table
29
+ * all fall through to the exact count — never a guess shown as a number.
30
+ */
31
+ async function countOne(
32
+ resource: AdminResource,
33
+ count: (where: readonly AdminFilter[]) => Promise<number>,
34
+ actor: AdminActor,
35
+ ): Promise<Counted> {
36
+ const where = rowWhere(resource, actor);
37
+ const estimate = resource.repo?.approximateCount;
38
+ if (resource.countEstimate === true && where.length === 0 && estimate !== undefined) {
39
+ const figure = await estimate.call(resource.repo).catch(() => null);
40
+ if (figure !== null && figure >= ESTIMATE_COUNT_FLOOR) {
41
+ return { count: figure, approximate: true };
42
+ }
43
+ }
44
+ return { count: await count(where), approximate: false };
12
45
  }
13
46
 
14
47
  /**
15
- * The count of every resource handed in that declared `count: true`, in the order handed in. The caller hands in only what the
16
- * actor may LIST (`canOperate(…, 'list', …)`): a size is a fact about a table, and a resource the
17
- * actor may not open must not leak one. A repo with no `count()` — a hand-written one may leave it
48
+ * The count of every resource handed in that declared `count: true` (or `'estimate'`), in the order
49
+ * handed in. The caller hands in only what the actor may LIST (`canOperate(…, 'list', …)`): a size
50
+ * is a fact about a table, and a resource the actor may not open must not leak one. A repo with no `count()` — a hand-written one may leave it
18
51
  * out — has no figure, so it is left out rather than shown as a zero it never measured.
19
52
  *
20
53
  * In parallel: these are independent reads, and the home page is the front door. A count the
@@ -33,10 +66,10 @@ export async function resourceCounts(
33
66
  return count === undefined ? [] : [{ resource, count: count.bind(resource.repo) }];
34
67
  });
35
68
  const totals = await Promise.allSettled(
36
- countable.map(({ resource, count }) => count(rowWhere(resource, actor))),
69
+ countable.map(({ resource, count }) => countOne(resource, count, actor)),
37
70
  );
38
71
  return countable.flatMap(({ resource }, index) => {
39
72
  const total = totals[index];
40
- return total?.status === 'fulfilled' ? [{ resource, count: total.value }] : [];
73
+ return total?.status === 'fulfilled' ? [{ resource, ...total.value }] : [];
41
74
  });
42
75
  }
@@ -30,12 +30,18 @@ export function HomeKpis(props: HomeKpisProps): JSX.Element {
30
30
  {t('admin.dashboard.records.title')}
31
31
  </h2>
32
32
  <ul class={styles['kpis']}>
33
- {props.counts.map(({ resource, count: total }) => (
33
+ {props.counts.map(({ resource, count: total, approximate }) => (
34
34
  <li>
35
35
  <a class={styles['kpi']} href={`${props.basePath}${resource.path}`}>
36
36
  <StatTile
37
37
  label={t(resource.titleKey)}
38
- value={count.format(total)}
38
+ value={
39
+ // The planner's figure (`count: 'estimate'`) says so: `~1,200,000`, never a
40
+ // number that reads as exact.
41
+ approximate === true
42
+ ? t('admin.dashboard.records.approximate', { figure: count.format(total) })
43
+ : count.format(total)
44
+ }
39
45
  stat={countStat(resource.name)}
40
46
  />
41
47
  </a>
package/src/search.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // search config to drift from the entities, and no result an actor could not have opened —
3
3
  // the same `admin:read` + `<entity>:read` pair gates the hit and the detail page it links to.
4
4
 
5
- import { finiteCount } from '@ultimat3/core';
5
+ import { finiteCount, mapBounded } from '@ultimat3/core';
6
6
  import { expectedQueryLoop } from '@ultimat3/db';
7
7
  import { type AuditEntry, deniedDraft } from './audit';
8
8
  import type { AdminActor } from './authz';
@@ -18,6 +18,11 @@ import { type AdminResource, repoOf } from './resource';
18
18
  /** Per resource, and per field within it. Search is a jump box, not a report. */
19
19
  const MAX_FIELDS_PER_RESOURCE = 3;
20
20
  const DEFAULT_LIMIT_PER_RESOURCE = 5;
21
+ /**
22
+ * Resources read at once. Independent reads, so their latencies overlap rather than add — and a
23
+ * bound, so an admin of fifty resources never holds fifty pool connections for one keystroke.
24
+ */
25
+ const SEARCH_WIDTH = 4;
21
26
 
22
27
  export interface AdminSearchHit {
23
28
  readonly entity: string;
@@ -53,9 +58,10 @@ export interface AdminSearchInput {
53
58
  }
54
59
 
55
60
  /**
56
- * One query per searchable field rather than one query with an OR: the admin's query IR is
57
- * a conjunction by design (each filter maps to an indexed predicate), and three small
58
- * indexed lookups beat one unindexed disjunction.
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.
59
65
  *
60
66
  * That argument is declared to the runtime and not only to the reader — `expectedQueryLoop`
61
67
  * carries it onto every statement the loop issues, so a statement diagnostic reports the loops
@@ -129,6 +135,10 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
129
135
  // reaches a repo.
130
136
  if (term === '') return { term, hits: [], searched: [], skipped: [], audit: [] };
131
137
 
138
+ // Decided and audited in resource order, every entry appended BEFORE any read — so a repo that
139
+ // throws still leaves the record that the rows were asked for (`audit.ts`: if it isn't logged,
140
+ // it didn't happen) — and only then read, side by side.
141
+ const readable: AdminResource[] = [];
132
142
  for (const resource of input.resources) {
133
143
  const offered = resource.operations.includes('search');
134
144
  const decision = decideOperation(resource, 'search', ctx);
@@ -161,8 +171,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
161
171
  skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
162
172
  continue;
163
173
  }
164
- // Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
165
- // asked for — `audit.ts`: if it isn't logged, it didn't happen.
166
174
  audit.push(
167
175
  await ctx.audit.append({
168
176
  requestId: ctx.requestId,
@@ -176,15 +184,31 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
176
184
  reason: decision.reason,
177
185
  }),
178
186
  );
179
- try {
180
- hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
181
- searched.push(resource.name);
182
- } catch {
183
- // One resource whose repo fails is a resource this search could not read — listed, so the
184
- // operator is never silently shown a subset — never a 500 for every other one. Nothing is
185
- // read off the thrown value: a reason is a key.
187
+ readable.push(resource);
188
+ }
189
+
190
+ const answers = await mapBounded(
191
+ readable,
192
+ async (resource) => {
193
+ try {
194
+ return await searchResource(resource, ctx.actor, term, limit);
195
+ } catch {
196
+ // One resource whose repo fails is a resource this search could not read — listed, so the
197
+ // operator is never silently shown a subset — never a 500 for every other one. Nothing is
198
+ // read off the thrown value: a reason is a key.
199
+ return undefined;
200
+ }
201
+ },
202
+ { width: SEARCH_WIDTH },
203
+ );
204
+ for (const [index, resource] of readable.entries()) {
205
+ const found = answers[index];
206
+ if (found === undefined) {
186
207
  skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
208
+ continue;
187
209
  }
210
+ hits.push(...found);
211
+ searched.push(resource.name);
188
212
  }
189
213
 
190
214
  return { term, hits, searched, skipped, audit };