@ultimat3/admin 31.17.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 +10 -1
- package/package.json +17 -17
- package/src/fields.ts +2 -2
- package/src/jobs/overview-data.ts +31 -12
- package/src/list-filters.ts +6 -2
- package/src/registry.ts +15 -0
- package/src/repo-entity.ts +13 -5
- package/src/resource-list.ts +2 -2
- package/src/resource.ts +9 -4
- package/src/row-scope-write.ts +2 -1
- package/src/screen-home-counts.ts +38 -5
- package/src/screen-home-kpis.tsx +8 -2
- package/src/search.ts +37 -13
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 `
|
|
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.
|
|
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.
|
|
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.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>
|
|
236
|
-
* (`packages/entity/src/
|
|
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 {
|
|
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
|
-
*
|
|
103
|
-
* buckets at all,
|
|
104
|
-
*
|
|
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
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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,
|
package/src/list-filters.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/repo-entity.ts
CHANGED
|
@@ -20,13 +20,16 @@ import type {
|
|
|
20
20
|
type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
|
|
21
21
|
|
|
22
22
|
/**
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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', '
|
|
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
|
|
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
|
-
|
|
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
|
|
package/src/resource-list.ts
CHANGED
|
@@ -132,14 +132,14 @@ export function scopesOf(
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
const where = scope.where;
|
|
135
|
-
|
|
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) :
|
|
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
|
{
|
package/src/row-scope-write.ts
CHANGED
|
@@ -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
|
-
// `
|
|
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
|
|
16
|
-
* actor may LIST (`canOperate(…, 'list', …)`): a size
|
|
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 }) =>
|
|
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,
|
|
73
|
+
return total?.status === 'fulfilled' ? [{ resource, ...total.value }] : [];
|
|
41
74
|
});
|
|
42
75
|
}
|
package/src/screen-home-kpis.tsx
CHANGED
|
@@ -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={
|
|
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
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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 };
|