@ultimat3/admin 22.15.0 → 24.0.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.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
@@ -0,0 +1,242 @@
1
+ // What a resource's filters ARE. Per filter shape: the operators a caller may name and the
2
+ // control the filter bar draws. One validator — the URL parser, the MCP list tool and a `scopes:`
3
+ // or `rows:` declaration all pass through it — so a predicate the admin sends a repo is one the
4
+ // resource derived, on a column that is not sealed, with a value of the column's own type.
5
+
6
+ import { adminColumnsOf, adminSealedColumnsOf } from './entity-columns';
7
+ import { AdminFilterInvalidError } from './errors';
8
+ import type { AdminField, AdminFilterKind } from './fields';
9
+ import { type AdminFilter, FILTER_OPS, type FilterOp } from './registry';
10
+ import type { AdminResource } from './resource';
11
+
12
+ /** The URL prefix of a filter parameter: `f.<field>` or `f.<field>.<op>`. */
13
+ export const FILTER_PREFIX = 'f.';
14
+
15
+ /** Per shape, the operators it answers. The FIRST is what a bare `f.<field>=` means. */
16
+ const OPS_BY_KIND: Readonly<Record<AdminFilterKind, readonly FilterOp[]>> = {
17
+ text: ['contains', 'eq', 'neq', 'is-null'],
18
+ exact: ['eq', 'neq', 'in', 'is-null'],
19
+ choice: ['eq', 'neq', 'in', 'is-null'],
20
+ boolean: ['eq'],
21
+ range: ['eq', 'neq', 'gt', 'gte', 'lt', 'lte', 'is-null'],
22
+ reference: ['eq', 'neq', 'in', 'is-null'],
23
+ };
24
+
25
+ /** The same table as a Map: a shape that is not one of the six has no operators, not a prototype's. */
26
+ const OPS: ReadonlyMap<string, readonly FilterOp[]> = new Map(Object.entries(OPS_BY_KIND));
27
+
28
+ /** The operators one field's filter answers; empty for a field with no filter shape. */
29
+ export function filterOpsOf(field: AdminField): readonly FilterOp[] {
30
+ return field.filterKind === undefined ? [] : (OPS.get(field.filterKind) ?? []);
31
+ }
32
+
33
+ /** The parameter one filter is spelled as: the default operator is left off. */
34
+ export function filterParam(field: AdminField, op: FilterOp): string {
35
+ return op === filterOpsOf(field)[0]
36
+ ? `${FILTER_PREFIX}${field.name}`
37
+ : `${FILTER_PREFIX}${field.name}.${op}`;
38
+ }
39
+
40
+ /** Every parameter spelling a resource answers — what a refusal names. */
41
+ export function knownFilterParams(resource: AdminResource): readonly string[] {
42
+ return resource.filters.map((field) => `${FILTER_PREFIX}${field.name}`);
43
+ }
44
+
45
+ const isOp = (value: string): value is FilterOp =>
46
+ (FILTER_OPS as readonly string[]).includes(value);
47
+
48
+ /** A value for a cause line: what was typed when it is text, its type when it is not. */
49
+ const shown = (value: unknown): string =>
50
+ typeof value === 'string' ? `"${value}"` : `a ${value === null ? 'null' : typeof value}`;
51
+
52
+ const TRUTH: ReadonlyMap<unknown, boolean> = new Map<unknown, boolean>([
53
+ ['true', true],
54
+ ['false', false],
55
+ [true, true],
56
+ [false, false],
57
+ ]);
58
+
59
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
60
+ const LOCAL_MINUTE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/;
61
+ /** An instant names its offset. A bare local time has no zone, and the admin never invents one. */
62
+ const ZONED = /(?:Z|[+-]\d{2}:\d{2})$/;
63
+
64
+ /**
65
+ * A number as an operator types one: decimal digits, a sign, a point. `Number()` alone read a
66
+ * blank as `0`, `0x10` as 16 and `1e3` as 1000 — a filter nobody asked for, answered as a list.
67
+ */
68
+ const DECIMAL = /^-?\d+(?:\.\d+)?$/;
69
+
70
+ /**
71
+ * The `YYYY-MM-DD` a value opens with names a day the calendar HAS. `Date` rolls `2026-02-30`
72
+ * over to March 2nd without a word, so the shape test passed and the driver was handed a day
73
+ * nobody typed.
74
+ */
75
+ const onCalendar = (raw: string): boolean => {
76
+ const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(raw);
77
+ if (match === null) return false;
78
+ const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])];
79
+ if (month < 1 || month > 12 || day < 1) return false;
80
+ return day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
81
+ };
82
+
83
+ /**
84
+ * A typed instant → the ISO string a driver compares. `datetime-local` posts `YYYY-MM-DDTHH:mm`
85
+ * and the control says UTC beside the box — the same contract the edit form states — and a bare
86
+ * date is that day's first instant in UTC.
87
+ */
88
+ const instantOf = (raw: string): string | undefined => {
89
+ if (!onCalendar(raw)) return undefined;
90
+ const text = DATE.test(raw)
91
+ ? `${raw}T00:00:00.000Z`
92
+ : LOCAL_MINUTE.test(raw)
93
+ ? `${raw}:00.000Z`
94
+ : raw;
95
+ if (!ZONED.test(text)) return undefined;
96
+ const at = new Date(text);
97
+ return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
98
+ };
99
+
100
+ interface Refusal {
101
+ readonly cause: string;
102
+ }
103
+
104
+ type Scalar = string | number | boolean;
105
+
106
+ /** One value, as the column's own type — or why it is not one. */
107
+ function scalarOf(field: AdminField, raw: unknown): Scalar | Refusal {
108
+ switch (field.filterKind) {
109
+ case 'boolean': {
110
+ const truth = TRUTH.get(raw);
111
+ return truth ?? { cause: `takes true or false, got ${shown(raw)}` };
112
+ }
113
+ case 'range': {
114
+ if (field.type === 'number') {
115
+ const parsed =
116
+ typeof raw === 'number'
117
+ ? raw
118
+ : typeof raw === 'string' && DECIMAL.test(raw)
119
+ ? Number(raw)
120
+ : NaN;
121
+ return Number.isFinite(parsed) ? parsed : { cause: `takes a number, got ${shown(raw)}` };
122
+ }
123
+ if (typeof raw !== 'string') return { cause: `takes a date as text, got ${shown(raw)}` };
124
+ if (field.type === 'date') {
125
+ return DATE.test(raw) && onCalendar(raw)
126
+ ? raw
127
+ : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
128
+ }
129
+ return (
130
+ instantOf(raw) ?? {
131
+ cause: `takes a UTC instant (YYYY-MM-DDTHH:mm, or ISO-8601 with an offset), got ${shown(raw)}`,
132
+ }
133
+ );
134
+ }
135
+ case 'choice': {
136
+ if (typeof raw !== 'string') return { cause: `takes one of its values, got ${shown(raw)}` };
137
+ // An enum names its values; a locale or a zone column has no list to hold one against.
138
+ if (field.values !== undefined && !field.values.includes(raw)) {
139
+ return { cause: `takes one of ${field.values.join(', ')} — got ${shown(raw)}` };
140
+ }
141
+ return raw;
142
+ }
143
+ default:
144
+ if (typeof raw === 'string') return raw;
145
+ return typeof raw === 'number' && Number.isFinite(raw)
146
+ ? String(raw)
147
+ : { cause: `takes text, got ${shown(raw)}` };
148
+ }
149
+ }
150
+
151
+ const isRefusal = (value: unknown): value is Refusal =>
152
+ typeof value === 'object' && value !== null && 'cause' in value;
153
+
154
+ const filterValueOf = (
155
+ field: AdminField,
156
+ op: FilterOp,
157
+ raw: unknown,
158
+ ): AdminFilter['value'] | Refusal => {
159
+ if (op === 'in') {
160
+ const list: readonly unknown[] = Array.isArray(raw) ? raw : [raw];
161
+ const out: string[] = [];
162
+ for (const one of list) {
163
+ const scalar = scalarOf(field, one);
164
+ if (isRefusal(scalar)) return scalar;
165
+ out.push(String(scalar));
166
+ }
167
+ return out;
168
+ }
169
+ // A scalar operator given a repeated parameter reads the LAST one, as a form field does.
170
+ const last: unknown = Array.isArray(raw) ? raw[raw.length - 1] : raw;
171
+ if (op === 'is-null') {
172
+ return TRUTH.get(last) ?? { cause: `takes true or false, got ${shown(last)}` };
173
+ }
174
+ return scalarOf(field, last);
175
+ };
176
+
177
+ export interface AskedFilter {
178
+ readonly field: string;
179
+ /** Absent is the field's default operator — `contains` for text, `eq` for everything else. */
180
+ readonly op?: string;
181
+ readonly value: unknown;
182
+ }
183
+
184
+ /**
185
+ * One filter a CALLER asked for — a URL parameter, an MCP argument — checked against what the
186
+ * resource derives. Refused by name: the field when it is not a filter, the operator when the
187
+ * field's shape has no meaning for it, the value when it is not the column's type.
188
+ */
189
+ export function checkedFilter(resource: AdminResource, asked: AskedFilter): AdminFilter {
190
+ const spelled = `${FILTER_PREFIX}${asked.field}${asked.op === undefined ? '' : `.${asked.op}`}`;
191
+ const refuse = (cause: string, known: readonly string[]): never => {
192
+ throw new AdminFilterInvalidError({ entity: resource.name, asked: spelled, cause, known });
193
+ };
194
+ const field = resource.filters.find((known) => known.name === asked.field);
195
+ if (field === undefined) {
196
+ return refuse('is not a filter of this resource', knownFilterParams(resource));
197
+ }
198
+ const ops = filterOpsOf(field);
199
+ const op = asked.op ?? ops[0];
200
+ if (op === undefined || !isOp(op) || !ops.includes(op)) {
201
+ return refuse(
202
+ `names an operator "${field.name}" does not answer`,
203
+ ops.map((known) => filterParam(field, known)),
204
+ );
205
+ }
206
+ const value = filterValueOf(field, op, asked.value);
207
+ if (isRefusal(value)) return refuse(value.cause, [filterParam(field, op)]);
208
+ return { field: field.name, op, value };
209
+ }
210
+
211
+ /**
212
+ * Predicates the APP declared — a scope's `where`, a resource's `rows` — held to a narrower rule
213
+ * than a caller's: any column of the entity may be named (the app knows which of its columns are
214
+ * indexed), but never one that is not a column and never a sealed one, whose ciphertext no
215
+ * predicate can be written against.
216
+ */
217
+ export function declaredFilters(
218
+ resource: Pick<AdminResource, 'name' | 'entity'>,
219
+ where: readonly AdminFilter[],
220
+ declaredAs: string,
221
+ ): readonly AdminFilter[] {
222
+ // Through the one flattener: it is the only reader of a column's declaration, and its two lists
223
+ // are exactly the two answers this needs — what may be named, and what is sealed.
224
+ const open = adminColumnsOf(resource.entity).map((column) => column.name);
225
+ const sealed = adminSealedColumnsOf(resource.entity).map((column) => column.name);
226
+ for (const filter of where) {
227
+ const refuse = (cause: string, known: readonly string[]): never => {
228
+ throw new AdminFilterInvalidError({
229
+ entity: resource.name,
230
+ asked: `${declaredAs}: ${filter.field} ${filter.op}`,
231
+ cause,
232
+ known,
233
+ });
234
+ };
235
+ if (sealed.includes(filter.field)) {
236
+ refuse('names a sealed column, which no predicate can read', open);
237
+ }
238
+ if (!open.includes(filter.field)) refuse('names a field that is not a column', open);
239
+ if (!isOp(filter.op)) refuse('names an operator the admin does not have', FILTER_OPS);
240
+ }
241
+ return where;
242
+ }
@@ -0,0 +1,129 @@
1
+ // URL ⇄ the list a resource is asked for. A list page's whole state lives in its query string —
2
+ // cursor, sort, scope and filters — so paging, sorting, scoping and filtering are links and one
3
+ // GET form, and a list needs no script. One parser and one builder, so the two cannot disagree
4
+ // about a parameter's name. A parameter the resource does not derive is refused, never ignored:
5
+ // a typo that silently showed every row is how an operator acts on the wrong list.
6
+ //
7
+ // ?cursor=<signed> keyset position (pagination.ts)
8
+ // ?sort=<field>:<asc|desc> a sortable field; the direction defaults to desc
9
+ // ?scope=<name> a declared scope; absent is the resource's default, `*` is none
10
+ // ?f.<field>=<value> a filter, by the field's default operator
11
+ // ?f.<field>.<op>=<value> a filter, by a named operator; repeat the parameter for `in`
12
+ //
13
+ // An EMPTY value is no parameter at all: a GET form submits every input it holds.
14
+
15
+ import { AdminFilterInvalidError } from './errors';
16
+ import { checkedFilter, FILTER_PREFIX, filterParam, knownFilterParams } from './list-filters';
17
+ import type { AdminListRequest } from './list-scope';
18
+ import { scopeNamed } from './list-scope';
19
+ import type { AdminFilter, AdminSort } from './registry';
20
+ import type { AdminResource } from './resource';
21
+ import { NO_SCOPE } from './resource-list';
22
+
23
+ export const CURSOR_PARAM = 'cursor';
24
+ export const SORT_PARAM = 'sort';
25
+ export const SCOPE_PARAM = 'scope';
26
+
27
+ const sortable = (resource: AdminResource): readonly string[] =>
28
+ resource.fields.filter((field) => field.sortable).map((field) => `${SORT_PARAM}=${field.name}`);
29
+
30
+ /** `createdAt:desc`. A bare field sorts descending — the default everywhere. */
31
+ const sortOf = (resource: AdminResource, raw: string): AdminSort => {
32
+ const at = raw.lastIndexOf(':');
33
+ const name = at < 0 ? raw : raw.slice(0, at);
34
+ const direction = at < 0 ? 'desc' : raw.slice(at + 1);
35
+ const refuse = (cause: string): never => {
36
+ throw new AdminFilterInvalidError({
37
+ entity: resource.name,
38
+ asked: `${SORT_PARAM}=${raw}`,
39
+ cause,
40
+ known: sortable(resource),
41
+ });
42
+ };
43
+ // A column the resource derives as sortable: an arbitrary name here would be an `order by` on a
44
+ // column with no index, chosen by whoever typed the URL.
45
+ const field = resource.fields.find((known) => known.name === name && known.sortable);
46
+ if (field === undefined) return refuse('names a field this resource cannot sort by');
47
+ if (direction !== 'asc' && direction !== 'desc') {
48
+ return refuse('names a direction that is neither asc nor desc');
49
+ }
50
+ return { field: field.name, direction };
51
+ };
52
+
53
+ /** `f.title.eq` → `{ field: 'title', op: 'eq' }`. A field name never holds a dot. */
54
+ const filterKey = (key: string): { readonly field: string; readonly op?: string } => {
55
+ const rest = key.slice(FILTER_PREFIX.length);
56
+ const dot = rest.indexOf('.');
57
+ return dot < 0 ? { field: rest } : { field: rest.slice(0, dot), op: rest.slice(dot + 1) };
58
+ };
59
+
60
+ /** The list a URL asks for. Absent parameters are the resource's own defaults. */
61
+ export function pageRequestOf(resource: AdminResource, url: URL): AdminListRequest {
62
+ const filters: AdminFilter[] = [];
63
+ let cursor: string | undefined;
64
+ let sort: AdminSort | undefined;
65
+ let scope: string | null | undefined;
66
+
67
+ for (const key of new Set(url.searchParams.keys())) {
68
+ const values = url.searchParams.getAll(key).filter((value) => value !== '');
69
+ const last = values[values.length - 1];
70
+ if (last === undefined) continue;
71
+ if (key === CURSOR_PARAM) cursor = last;
72
+ else if (key === SORT_PARAM) sort = sortOf(resource, last);
73
+ // Resolved here for its refusal alone: the name is what travels, the actor decides its rows.
74
+ else if (key === SCOPE_PARAM)
75
+ scope = last === NO_SCOPE ? null : scopeNamed(resource, last)?.name;
76
+ else if (key.startsWith(FILTER_PREFIX)) {
77
+ filters.push(checkedFilter(resource, { ...filterKey(key), value: values }));
78
+ } else {
79
+ throw new AdminFilterInvalidError({
80
+ entity: resource.name,
81
+ asked: key,
82
+ cause: 'is not a parameter a list reads',
83
+ known: [CURSOR_PARAM, SORT_PARAM, SCOPE_PARAM, ...knownFilterParams(resource)],
84
+ });
85
+ }
86
+ }
87
+
88
+ return {
89
+ ...(cursor === undefined ? {} : { cursor }),
90
+ ...(sort === undefined ? {} : { sort }),
91
+ ...(scope === undefined ? {} : { scope }),
92
+ ...(filters.length === 0 ? {} : { filters }),
93
+ };
94
+ }
95
+
96
+ export interface ListLocation {
97
+ readonly cursor?: string | null;
98
+ readonly sort?: AdminSort;
99
+ readonly scope?: string | null;
100
+ readonly filters?: readonly AdminFilter[];
101
+ }
102
+
103
+ const written = (value: AdminFilter['value']): readonly string[] =>
104
+ value === null ? [] : typeof value === 'object' ? value : [String(value)];
105
+
106
+ /** The URL of one list state, rooted at the admin's own base path. */
107
+ export function listHref(
108
+ basePath: string,
109
+ resource: Pick<AdminResource, 'path' | 'filters'>,
110
+ location: ListLocation = {},
111
+ ): string {
112
+ const query = new URLSearchParams();
113
+ // `null` is "no scope, not even the default" — the request's own meaning of it — so it is
114
+ // written, as `*`; only an absent scope is left to the resource's default.
115
+ if (location.scope !== undefined) query.set(SCOPE_PARAM, location.scope ?? NO_SCOPE);
116
+ for (const filter of location.filters ?? []) {
117
+ const field = resource.filters.find((known) => known.name === filter.field);
118
+ if (field === undefined) continue;
119
+ for (const value of written(filter.value)) query.append(filterParam(field, filter.op), value);
120
+ }
121
+ if (location.sort !== undefined) {
122
+ query.set(SORT_PARAM, `${location.sort.field}:${location.sort.direction}`);
123
+ }
124
+ if (location.cursor !== undefined && location.cursor !== null) {
125
+ query.set(CURSOR_PARAM, location.cursor);
126
+ }
127
+ const search = query.toString();
128
+ return `${basePath}${resource.path}${search === '' ? '' : `?${search}`}`;
129
+ }
@@ -0,0 +1,60 @@
1
+ // Scope tabs, as links: each tab is the list's URL under that scope, with the filters and the
2
+ // sort kept. A count is drawn only on the tabs that asked for one — the page read exactly those.
3
+
4
+ import { t } from '@ultimat3/i18n';
5
+ import { Badge } from '@ultimat3/ui';
6
+ import type { JSX } from 'solid-js';
7
+ import styles from './admin.module.scss';
8
+ import type { AdminResource } from './resource';
9
+ import type { AdminScope } from './resource-list';
10
+
11
+ export interface AdminScopeTabsProps {
12
+ readonly resource: AdminResource;
13
+ /** The scope the page was read under, or `null` for the unscoped list. */
14
+ readonly active: AdminScope | null;
15
+ readonly counts: ReadonlyMap<string, number>;
16
+ /** The list's URL under a scope — `null` is the unscoped list. */
17
+ readonly hrefFor: (scope: string | null) => string;
18
+ readonly locale: string;
19
+ }
20
+
21
+ export function AdminScopeTabs(props: AdminScopeTabsProps): JSX.Element | null {
22
+ const scopes = props.resource.scopes;
23
+ if (scopes.length === 0) return null;
24
+
25
+ const tab = (name: string | null, label: string, current: boolean): JSX.Element => {
26
+ const count = name === null ? undefined : props.counts.get(name);
27
+ return (
28
+ <li>
29
+ <a
30
+ class={styles['navLink']}
31
+ href={props.hrefFor(name)}
32
+ aria-current={current ? 'page' : undefined}
33
+ >
34
+ {label}
35
+ {count === undefined ? null : (
36
+ <>
37
+ {' '}
38
+ <Badge size="sm">{new Intl.NumberFormat(props.locale).format(count)}</Badge>
39
+ </>
40
+ )}
41
+ </a>
42
+ </li>
43
+ );
44
+ };
45
+
46
+ return (
47
+ <nav aria-label={t('admin.scope.label')}>
48
+ <ul class={styles['scopes']}>
49
+ {/* With no default scope the bare list is every row, and it needs a tab to return to.
50
+ With one, "all rows" is a scope the app declares or does not offer. */}
51
+ {scopes.some((scope) => scope.default)
52
+ ? null
53
+ : tab(null, t('admin.scope.all'), props.active === null)}
54
+ {scopes.map((scope) =>
55
+ tab(scope.name, t(scope.labelKey), props.active?.name === scope.name),
56
+ )}
57
+ </ul>
58
+ </nav>
59
+ );
60
+ }
@@ -0,0 +1,113 @@
1
+ // The predicates one read of a resource carries, composed in one place: the resource's row scope
2
+ // (who may see which rows at all), then the list's scope, then the caller's filters. Every read
3
+ // the admin issues — list, search, detail, lookup, labels, the MCP tools — builds its `where`
4
+ // here, so "this actor never sees that row" is one function and not five call sites agreeing.
5
+
6
+ import type { AdminActor } from './authz';
7
+ import { AdminFilterInvalidError } from './errors';
8
+ import type { AdminFilter, AdminRow, AdminSort } from './registry';
9
+ import { type AdminResource, repoOf } from './resource';
10
+ import type { AdminScope } from './resource-list';
11
+
12
+ /** One list, as a caller asks for it. `scope` and `filters` are names the resource must derive. */
13
+ export interface AdminListRequest {
14
+ readonly cursor?: string | null;
15
+ readonly limit?: number;
16
+ readonly sort?: AdminSort;
17
+ /**
18
+ * A declared scope's name. Absent is the resource's default scope, or none. `null` is "no scope,
19
+ * not even the default" — what a lookup asks, because a label is owed for a row in ANY scope.
20
+ */
21
+ readonly scope?: string | null;
22
+ /** Already checked — `checkedFilter` is the only thing that builds one from a caller's input. */
23
+ readonly filters?: readonly AdminFilter[];
24
+ }
25
+
26
+ /** The rows of `resource` this actor may see at all — empty when the resource declares no `rows`. */
27
+ export function rowWhere(resource: AdminResource, actor: AdminActor): readonly AdminFilter[] {
28
+ return resource.rowScope?.(actor) ?? [];
29
+ }
30
+
31
+ /**
32
+ * The scope a request names; the default one when it names none; `undefined` when the resource
33
+ * has no default. A name nobody declared is refused — a typo must not read as "every row".
34
+ */
35
+ export function scopeNamed(
36
+ resource: AdminResource,
37
+ name: string | null | undefined,
38
+ ): AdminScope | undefined {
39
+ if (name === null) return undefined;
40
+ if (name === undefined || name === '') return resource.scopes.find((scope) => scope.default);
41
+ const found = resource.scopes.find((scope) => scope.name === name);
42
+ if (found !== undefined) return found;
43
+ throw new AdminFilterInvalidError({
44
+ entity: resource.name,
45
+ asked: `scope=${name}`,
46
+ cause: 'is not a scope of this resource',
47
+ known: resource.scopes.map((scope) => `scope=${scope.name}`),
48
+ });
49
+ }
50
+
51
+ export interface ListWhere {
52
+ readonly scope: AdminScope | undefined;
53
+ readonly where: readonly AdminFilter[];
54
+ }
55
+
56
+ /** Row scope, then scope, then filters — a conjunction, in the order they narrow. */
57
+ export function listWhere(
58
+ resource: AdminResource,
59
+ actor: AdminActor,
60
+ request: Pick<AdminListRequest, 'scope' | 'filters'>,
61
+ ): ListWhere {
62
+ const scope = scopeNamed(resource, request.scope);
63
+ return {
64
+ scope,
65
+ where: [
66
+ ...rowWhere(resource, actor),
67
+ ...(scope?.where(actor) ?? []),
68
+ ...(request.filters ?? []),
69
+ ],
70
+ };
71
+ }
72
+
73
+ /**
74
+ * One row by id, or `null` — for a row that does not exist AND for one this actor's row scope
75
+ * leaves out, which are the same answer on purpose: a 404 that distinguished them would confirm
76
+ * the row exists.
77
+ *
78
+ * The membership question is put to the repo, never re-evaluated here: a second implementation of
79
+ * `contains` or `in` in this file would be a second opinion about what a predicate means. It costs
80
+ * one extra read, and only for a resource that declares `rows`.
81
+ */
82
+ export async function findRow<Row extends AdminRow>(
83
+ resource: AdminResource<Row>,
84
+ actor: AdminActor,
85
+ id: string,
86
+ ): Promise<Row | null> {
87
+ const repo = repoOf(resource);
88
+ const row = await repo.find(id);
89
+ if (row === null || resource.rowScope === undefined) return row;
90
+ const visible = await repo.list({
91
+ where: [...resource.rowScope(actor), { field: resource.idField, op: 'eq', value: id }],
92
+ sort: resource.defaultSort,
93
+ limit: 1,
94
+ });
95
+ return visible.length === 0 ? null : row;
96
+ }
97
+
98
+ /**
99
+ * The count behind each tab that asked for one — `count: true` — and no other. One query per
100
+ * counted scope: the row scope and the scope's own predicates, never the caller's filters, so a
101
+ * tab's number is the size of the scope and does not move as the operator types.
102
+ */
103
+ export async function scopeCounts(
104
+ resource: AdminResource,
105
+ actor: AdminActor,
106
+ ): Promise<ReadonlyMap<string, number>> {
107
+ const counted = resource.scopes.filter((scope) => scope.count);
108
+ const count = repoOf(resource).count;
109
+ if (counted.length === 0 || count === undefined) return new Map();
110
+ const rows = rowWhere(resource, actor);
111
+ const totals = await Promise.all(counted.map((scope) => count([...rows, ...scope.where(actor)])));
112
+ return new Map(counted.map((scope, index) => [scope.name, totals[index] ?? 0]));
113
+ }