@ultimat3/admin 22.14.0 → 23.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 (89) hide show
  1. package/CLAUDE.md +73 -80
  2. package/README.md +276 -87
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +127 -28
  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 +92 -0
  20. package/src/batch.ts +431 -0
  21. package/src/catalog-keys.ts +55 -0
  22. package/src/crud-input.ts +104 -0
  23. package/src/crud-outcome.ts +166 -0
  24. package/src/crud.ts +129 -177
  25. package/src/describe.ts +131 -0
  26. package/src/detail.tsx +115 -43
  27. package/src/dev/data.ts +24 -6
  28. package/src/dev/facts.ts +27 -0
  29. package/src/dev/panel-db.ts +37 -5
  30. package/src/dev/panel-jobs.ts +8 -0
  31. package/src/dev/panel-routes.ts +10 -0
  32. package/src/dev/panel.ts +6 -0
  33. package/src/dev/server.ts +15 -7
  34. package/src/entity-columns.ts +24 -5
  35. package/src/errors.ts +83 -0
  36. package/src/fields.ts +63 -4
  37. package/src/form-decode.ts +91 -0
  38. package/src/form.tsx +78 -45
  39. package/src/index.ts +198 -9
  40. package/src/jobs/fleet-repo.ts +143 -0
  41. package/src/jobs/job-actions.ts +172 -0
  42. package/src/jobs/job-entities.ts +118 -0
  43. package/src/jobs/job-labels.ts +108 -0
  44. package/src/jobs/job-repo.ts +103 -0
  45. package/src/jobs/job-resources.ts +211 -0
  46. package/src/jobs/job-where.ts +170 -0
  47. package/src/jobs/operator.ts +34 -0
  48. package/src/jobs/overview-data.ts +129 -0
  49. package/src/jobs/overview-tab.tsx +61 -0
  50. package/src/jobs/overview.tsx +189 -0
  51. package/src/json-text.ts +52 -0
  52. package/src/layout.tsx +55 -43
  53. package/src/list-columns.tsx +53 -0
  54. package/src/list-filter-bar.tsx +207 -0
  55. package/src/list-filters.ts +217 -0
  56. package/src/list-request.ts +127 -0
  57. package/src/list-scope-tabs.tsx +60 -0
  58. package/src/list-scope.ts +113 -0
  59. package/src/list.tsx +111 -72
  60. package/src/mcp-tools.ts +57 -13
  61. package/src/mcp.ts +108 -38
  62. package/src/mounts.ts +44 -0
  63. package/src/page-guard.tsx +26 -29
  64. package/src/policy-bridge.ts +110 -15
  65. package/src/registry.ts +138 -8
  66. package/src/related.ts +105 -0
  67. package/src/relations.ts +224 -0
  68. package/src/renderers.tsx +89 -0
  69. package/src/repo-entity.ts +204 -0
  70. package/src/resource-fields.ts +116 -0
  71. package/src/resource-layout.ts +89 -0
  72. package/src/resource-list.ts +185 -0
  73. package/src/resource.ts +129 -79
  74. package/src/route-config.ts +71 -0
  75. package/src/routes.ts +55 -54
  76. package/src/row-scope-write.ts +130 -0
  77. package/src/screen-action.tsx +333 -0
  78. package/src/screen-frame.tsx +167 -0
  79. package/src/screen-home.tsx +201 -0
  80. package/src/screen-lookup.tsx +157 -0
  81. package/src/screen-resource.tsx +393 -0
  82. package/src/screen-system.tsx +69 -0
  83. package/src/screens.ts +56 -0
  84. package/src/search.ts +13 -9
  85. package/src/stylesheet-scope.ts +8 -0
  86. package/src/test-ctx.ts +31 -0
  87. package/src/validate.ts +3 -0
  88. package/src/widget-value.ts +36 -8
  89. package/src/widgets.tsx +84 -12
@@ -0,0 +1,204 @@
1
+ // The ONE adapter from an entity's typed handle — `database()`'s `db.<entity>` — to `AdminRepo`.
2
+ // Verbs, the keyset bound and filters are translated here and nowhere else; tenancy, soft delete,
3
+ // sealing and invariants stay the handle's, so this file adds no predicate of its own.
4
+
5
+ import { isUltimateError } from '@ultimat3/core';
6
+ import { getEntity, invariantViolated } from '@ultimat3/entity';
7
+ import { adminColumnsOf } from './entity-columns';
8
+ import type {
9
+ AdminDb,
10
+ AdminEntity,
11
+ AdminFilter,
12
+ AdminListQuery,
13
+ AdminRepo,
14
+ AdminRow,
15
+ AdminTable,
16
+ AdminTableRead,
17
+ } from './registry';
18
+
19
+ type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
20
+
21
+ /**
22
+ * `contains` is the admin's word for it; `like` with both wildcards is what a driver runs. A Map,
23
+ * so an operator that is not one of the seven answers `undefined` rather than a prototype member.
24
+ */
25
+ const OPERATOR_OF: ReadonlyMap<AdminFilter['op'], TableOperator> = new Map([
26
+ ['eq', 'eq'],
27
+ ['neq', 'neq'],
28
+ ['contains', 'like'],
29
+ ['gt', 'gt'],
30
+ ['gte', 'gte'],
31
+ ['lt', 'lt'],
32
+ ['lte', 'lte'],
33
+ ['in', 'in'],
34
+ ['is-null', 'is-null'],
35
+ ]);
36
+
37
+ /** `%` and `_` are wildcards to `LIKE`; a `contains` matches the text an operator typed. */
38
+ const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\${wild}`);
39
+
40
+ /**
41
+ * A comparison value, in the shape the column holds. An instant travels as ISO text — in a URL, in
42
+ * a cursor — and the handle is given the `Date`: Postgres would cast the text, the memory driver
43
+ * compares a `Date` to a string by its characters, and a keyset over `createdAt` (the admin's
44
+ * default sort) then answers page one for every page.
45
+ */
46
+ type Typed = (field: string, value: unknown) => unknown;
47
+
48
+ const typedBy = (entity: AdminEntity): Typed => {
49
+ const instants = new Set(
50
+ adminColumnsOf(entity)
51
+ .filter((column) => column.kind === 'timestamptz')
52
+ .map((column) => column.name),
53
+ );
54
+ return (field, value) => {
55
+ if (typeof value !== 'string' || !instants.has(field)) return value;
56
+ const at = new Date(value);
57
+ return Number.isNaN(at.getTime()) ? value : at;
58
+ };
59
+ };
60
+
61
+ const predicate =
62
+ (typed: Typed) =>
63
+ (read: AdminTableRead, filter: AdminFilter): AdminTableRead => {
64
+ if (filter.op === 'contains') {
65
+ return read.andWhere(filter.field, 'like', `%${literal(String(filter.value))}%`);
66
+ }
67
+ // One admin operator, two of the handle's: `false` asks for the rows that HAVE a value.
68
+ if (filter.op === 'is-null') {
69
+ return read.andWhere(filter.field, filter.value === false ? 'is-not-null' : 'is-null');
70
+ }
71
+ return read.andWhere(
72
+ filter.field,
73
+ OPERATOR_OF.get(filter.op) ?? 'eq',
74
+ typed(filter.field, filter.value),
75
+ );
76
+ };
77
+
78
+ const filtered = (
79
+ read: AdminTableRead,
80
+ where: readonly AdminFilter[],
81
+ typed: Typed,
82
+ ): AdminTableRead => where.reduce(predicate(typed), read);
83
+
84
+ /**
85
+ * `after`+asc and `before`+desc walk forward; the other two walk back. Written as one XOR rather
86
+ * than a four-branch table, because the four branches are the same fact said twice.
87
+ *
88
+ * `gte`/`lte`, not `gt`/`lt`: rows sharing the boundary's sort value are still on the far side of
89
+ * it, and `dropThroughTie` below is what removes the ones already served.
90
+ */
91
+ const sought = (read: AdminTableRead, query: AdminListQuery, typed: Typed): AdminTableRead => {
92
+ const bound = query.after ?? query.before;
93
+ if (bound === undefined) return read;
94
+ const forward = (query.after !== undefined) !== (query.sort.direction === 'desc');
95
+ // The bound is the STRING the cursor carried (`pagination.ts` writes a Date as ISO).
96
+ return read.andWhere(bound.field, forward ? 'gte' : 'lte', typed(bound.field, bound.value));
97
+ };
98
+
99
+ /**
100
+ * Drop everything up to and including the cursor row. A typed chain cannot express the row-value
101
+ * comparison `(sort, id) > (value, id)` a keyset needs, and the alternative — a strict `gt` on the
102
+ * sort column alone — silently skips every row that ties with the boundary.
103
+ */
104
+ const dropThroughTie = (
105
+ rows: readonly AdminRow[],
106
+ query: AdminListQuery,
107
+ idField: string,
108
+ ): readonly AdminRow[] => {
109
+ const bound = query.after ?? query.before;
110
+ if (bound === undefined) return rows;
111
+ const at = rows.findIndex((row) => String(row[idField]) === bound.id);
112
+ return at === -1 ? rows : rows.slice(at + 1);
113
+ };
114
+
115
+ /**
116
+ * One entity + its table → one `AdminRepo`.
117
+ *
118
+ * A row is never spread and never re-parsed here. The handle parses an insert against the entity
119
+ * and asserts every invariant over the stored row merged under a patch, so a second parse would be
120
+ * a second opinion — and a row's sealed properties are non-enumerable, so a spread would drop the
121
+ * very values a whole-row write then needs.
122
+ */
123
+ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<AdminRow> {
124
+ const idField = entity.$primaryKey[0] ?? 'id';
125
+ const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
126
+ const typed = typedBy(entity);
127
+ /**
128
+ * `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
129
+ * OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
130
+ * of reaching the driver as a comparison Postgres answers with a cast error.
131
+ */
132
+ const idOf = (id: string): string => {
133
+ const parsed = idColumn?.$parse === undefined ? id : idColumn.$parse(id);
134
+ if (typeof parsed !== 'string') {
135
+ throw invariantViolated(entity.$name, idField, `expected a string id, got ${typeof parsed}`);
136
+ }
137
+ return parsed;
138
+ };
139
+ /**
140
+ * The same crossing for a READ, where an id that is not one is simply a row that does not exist:
141
+ * `/posts/nope` is the admin's 404, not `X_INVARIANT_VIOLATED` — the operator mistyped a URL and
142
+ * violated nothing. A write keeps the throw: it is reached only after a read found the row.
143
+ */
144
+ const readableId = (id: string): string | undefined => {
145
+ try {
146
+ return idOf(id);
147
+ } catch (error) {
148
+ if (isUltimateError(error) && error.code === 'X_INVARIANT_VIOLATED') return undefined;
149
+ throw error;
150
+ }
151
+ };
152
+
153
+ return {
154
+ async list(query: AdminListQuery): Promise<readonly AdminRow[]> {
155
+ const rows = await sought(filtered(table, query.where ?? [], typed), query, typed)
156
+ .orderBy(query.sort.field, query.sort.direction)
157
+ // The tie-break, always: a page boundary on a partial order repeats or drops a row.
158
+ .orderBy(idField, query.sort.direction)
159
+ // One over the requested page so the tie-drop cannot hand back a short page.
160
+ .limit(query.limit + 1)
161
+ .all();
162
+ return dropThroughTie(rows, query, idField).slice(0, query.limit);
163
+ },
164
+ // `async`, so a refused id arrives as a rejection: these return promises, and a caller that
165
+ // chains `.catch()` instead of `await`ing would never see a synchronous throw.
166
+ find: async (id) => {
167
+ const key = readableId(id);
168
+ return key === undefined ? null : table.where({ [idField]: key }).one();
169
+ },
170
+ create: async (input) => table.insert(input),
171
+ update: async (id, patch) => table.update(idOf(id), patch),
172
+ destroy: async (id) => table.delete(idOf(id)),
173
+ count: async (where) => filtered(table, where ?? [], typed).count(),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * The handle's tables, by the ENTITY they serve. `database({ posts })` keys a table by the app's
179
+ * own spelling, which need not be the entity's `$name` — so the name is read off the table's own
180
+ * plan rather than off the key it happens to sit under.
181
+ */
182
+ export function adminTablesOf(db: AdminDb): ReadonlyMap<string, AdminTable> {
183
+ return new Map(Object.values(db).map((table) => [table.plan().entity, table]));
184
+ }
185
+
186
+ /**
187
+ * Every entity the handle serves that the admin can address — what an app passes as `entities`
188
+ * when its admin is "all of my tables": `defineAdmin({ entities: adminEntitiesOf(db), db })`. The
189
+ * handle's set is the one `x g entity` adds to, so a new entity is a new admin resource with no
190
+ * second registration.
191
+ *
192
+ * An entity keyed on more than one column is left out: a route id and an `AdminRepo` id carry one
193
+ * string. Name it in `entities` yourself and `defineAdmin` refuses it out loud
194
+ * (`X_ADMIN_FIELD_UNSUPPORTED`) — this helper is "everything that can be a screen", not a claim
195
+ * that everything is. Handle order, which is the order the app declared.
196
+ */
197
+ export function adminEntitiesOf(db: AdminDb): readonly AdminEntity[] {
198
+ const found: AdminEntity[] = [];
199
+ for (const name of adminTablesOf(db).keys()) {
200
+ const declared = getEntity(name)?.core;
201
+ if (declared !== undefined && declared.$primaryKey.length === 1) found.push(declared);
202
+ }
203
+ return found;
204
+ }
@@ -0,0 +1,116 @@
1
+ // One column → one admin field. Everything a list cell, a detail row, a form input, a filter and
2
+ // an MCP tool schema need is decided here, once, from the column's facts and the app's override —
3
+ // and a sealed column is derived by a function of its own, whose every read flag is false.
4
+
5
+ import type { AdminColumnFacts } from './entity-columns';
6
+ import {
7
+ type AdminField,
8
+ type AdminFieldType,
9
+ type AdminWidget,
10
+ fieldTypeFromColumn,
11
+ filterable,
12
+ filterKindFor,
13
+ listable,
14
+ searchable,
15
+ sortable,
16
+ widgetFor,
17
+ } from './fields';
18
+ import type { AdminEntity } from './registry';
19
+
20
+ export interface AdminFieldOverride {
21
+ readonly type?: AdminFieldType;
22
+ readonly widget?: AdminWidget;
23
+ readonly labelKey?: string;
24
+ /** Out of every surface: list, detail, form, MCP schema. */
25
+ readonly hidden?: boolean;
26
+ readonly inList?: boolean;
27
+ readonly readOnly?: boolean;
28
+ readonly required?: boolean;
29
+ readonly sensitive?: boolean;
30
+ readonly filterable?: boolean;
31
+ readonly sortable?: boolean;
32
+ readonly searchable?: boolean;
33
+ readonly currency?: string;
34
+ readonly values?: readonly string[];
35
+ /** The resource a foreign key points at, when the entity's own `references()` is not it. */
36
+ readonly relation?: { readonly entity: string };
37
+ /** i18n key of the hint under the form control, rendered through `t()`. */
38
+ readonly hintKey?: string;
39
+ /** The field is a form input on one side only: set at `create`, or editable from `update`. */
40
+ readonly on?: 'create' | 'update';
41
+ }
42
+
43
+ export function deriveField(
44
+ entity: AdminEntity,
45
+ column: AdminColumnFacts,
46
+ override: AdminFieldOverride | undefined,
47
+ ): AdminField {
48
+ const name = column.name;
49
+ const type = override?.type ?? fieldTypeFromColumn(entity.$name, name, column);
50
+ const widget = override?.widget ?? widgetFor(type);
51
+ const generated = column.generated || column.primaryKey;
52
+ // `sensitive` is the ADMIN's word for "keep this out of the list, the form and every audit
53
+ // diff" (an email, a phone number) and it has no entity source, so it is declared here or it is
54
+ // absent. A column the entity itself seals (`.sealed()`) never reaches this function at all —
55
+ // `secretField` below derives it. A currency belongs to the value rather than the column.
56
+ const sensitive = override?.sensitive ?? false;
57
+ const currency = override?.currency;
58
+ const values = override?.values ?? column.values;
59
+ const relation =
60
+ override?.relation ??
61
+ (column.references === undefined ? undefined : { entity: column.references.entity });
62
+ // The handle scopes every read and write by this column, so the acting actor's tenant is the
63
+ // only value it can hold: it is not an input, not a list column, and not a filter.
64
+ const tenant = entity.$tenantColumn === name;
65
+ const filterKind = tenant ? undefined : filterKindFor(type, column);
66
+
67
+ return {
68
+ entity: entity.$name,
69
+ name,
70
+ type,
71
+ widget,
72
+ labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
73
+ required: override?.required ?? (!column.nullable && !generated && !tenant),
74
+ readOnly: override?.readOnly ?? (generated || tenant),
75
+ sensitive,
76
+ inList: override?.inList ?? (listable(type) && !sensitive && !tenant),
77
+ filterable: filterKind !== undefined && (override?.filterable ?? filterable(type, column)),
78
+ ...(filterKind === undefined ? {} : { filterKind }),
79
+ sortable: override?.sortable ?? sortable(type, column),
80
+ // Generated columns are excluded from search: an id is found by exact lookup, and a
81
+ // `contains` over a uuid column is a scan that returns nothing useful.
82
+ searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
83
+ ...(values === undefined ? {} : { values }),
84
+ ...(currency === undefined ? {} : { currency }),
85
+ ...(relation === undefined ? {} : { relation }),
86
+ ...(override?.hintKey === undefined ? {} : { hintKey: override.hintKey }),
87
+ ...(override?.on === undefined ? {} : { on: override.on }),
88
+ };
89
+ }
90
+
91
+ /**
92
+ * A sealed column as the form sees it: a password-style input, required only on create, and
93
+ * nothing else. Every read flag is false by construction, not by an override an app could flip.
94
+ */
95
+ export function secretField(
96
+ entity: AdminEntity,
97
+ column: AdminColumnFacts,
98
+ override: AdminFieldOverride | undefined,
99
+ ): AdminField {
100
+ return {
101
+ entity: entity.$name,
102
+ name: column.name,
103
+ type: 'secret',
104
+ widget: 'secret-input',
105
+ labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${column.name}`,
106
+ required: !column.nullable,
107
+ readOnly: false,
108
+ sensitive: true,
109
+ inList: false,
110
+ filterable: false,
111
+ sortable: false,
112
+ searchable: false,
113
+ ...(override?.hintKey === undefined ? {} : { hintKey: override.hintKey }),
114
+ ...(override?.on === undefined ? {} : { on: override.on }),
115
+ };
116
+ }
@@ -0,0 +1,89 @@
1
+ // How a resource's fields are ARRANGED: the detail page's sections and the form's groups. One
2
+ // normaliser for both, and one rule that makes it safe to declare either — a field named in no
3
+ // group falls into a default one, so adding a column to the entity never hides it from a screen.
4
+
5
+ import { AdminFieldUnsupportedError } from './errors';
6
+ import type { AdminField } from './fields';
7
+
8
+ /** One declared group: a titled run of fields, in the order they are named. */
9
+ export interface AdminSectionOptions {
10
+ /** i18n key of the heading. */
11
+ readonly titleKey: string;
12
+ readonly fields: readonly string[];
13
+ }
14
+
15
+ /** The form's spelling of the same thing. One shape, so a detail and its form can share a list. */
16
+ export type AdminFormGroupOptions = AdminSectionOptions;
17
+
18
+ export interface AdminSection {
19
+ /** `null` only for the single group of a resource that declared none: it has no heading. */
20
+ readonly titleKey: string | null;
21
+ readonly fields: readonly AdminField[];
22
+ /** The group the undeclared fields fell into. Drawn last. */
23
+ readonly default: boolean;
24
+ }
25
+
26
+ /** The heading of the default group, when declared groups sit above it. */
27
+ export const DEFAULT_SECTION_KEY = 'admin.section.other';
28
+
29
+ interface Layout {
30
+ readonly entity: string;
31
+ /** `sections` or `formGroups` — what a refusal names. */
32
+ readonly option: 'sections' | 'formGroups';
33
+ /** The fields this layout may arrange, in derived order. */
34
+ readonly fields: readonly AdminField[];
35
+ /** Why a named field that is a column is still not one of `fields`. */
36
+ readonly excluded: string;
37
+ }
38
+
39
+ /**
40
+ * Declared groups in declaration order, then the default group holding every field nobody named —
41
+ * in derived order — when there is one. A name that is not a field of this layout, or that two
42
+ * groups both claim, is refused where it is written.
43
+ */
44
+ export function layoutOf(
45
+ layout: Layout,
46
+ declared: readonly AdminSectionOptions[] | undefined,
47
+ ): readonly AdminSection[] {
48
+ if (declared === undefined || declared.length === 0) {
49
+ return [{ titleKey: null, fields: layout.fields, default: true }];
50
+ }
51
+ const claimed = new Map<string, string>();
52
+ const groups = declared.map((group): AdminSection => {
53
+ const fields = group.fields.map((name) => {
54
+ const field = layout.fields.find((known) => known.name === name);
55
+ const refuse = (cause: string, fix: string): never => {
56
+ throw new AdminFieldUnsupportedError({ entity: layout.entity, field: name, cause, fix });
57
+ };
58
+ if (field === undefined) {
59
+ return refuse(
60
+ `named in ${layout.option} ("${group.titleKey}") and ${layout.excluded}`,
61
+ `remove "${name}" from resources.${layout.entity}.${layout.option} — it may name: ${layout.fields.map((known) => known.name).join(', ')}`,
62
+ );
63
+ }
64
+ const owner = claimed.get(name);
65
+ if (owner !== undefined) {
66
+ return refuse(
67
+ `named in two ${layout.option} groups ("${owner}" and "${group.titleKey}")`,
68
+ `keep "${name}" in one group of resources.${layout.entity}.${layout.option}`,
69
+ );
70
+ }
71
+ claimed.set(name, group.titleKey);
72
+ return field;
73
+ });
74
+ return { titleKey: group.titleKey, fields, default: false };
75
+ });
76
+ const rest = layout.fields.filter((field) => !claimed.has(field.name));
77
+ return rest.length === 0
78
+ ? groups
79
+ : [...groups, { titleKey: DEFAULT_SECTION_KEY, fields: rest, default: true }];
80
+ }
81
+
82
+ /** The fields of one group a form in `mode` draws: a field declared for the other side is left out. */
83
+ export function fieldsFor(
84
+ fields: readonly AdminField[],
85
+ mode: 'create' | 'edit',
86
+ ): readonly AdminField[] {
87
+ const side = mode === 'create' ? 'create' : 'update';
88
+ return fields.filter((field) => field.on === undefined || field.on === side);
89
+ }
@@ -0,0 +1,185 @@
1
+ // The list half of a resource declaration: scopes, the row scope and computed columns — each
2
+ // normalised once and validated where it is written, so a list screen, the search, the lookup and
3
+ // the MCP tool all read the same three values and none of them re-reads the options.
4
+
5
+ import type { JSX } from 'solid-js';
6
+ import type { AdminActor } from './authz';
7
+ import { AdminFieldUnsupportedError, AdminFilterInvalidError } from './errors';
8
+ import { declaredFilters } from './list-filters';
9
+ import type { AdminEntity, AdminFilter, AdminRepo, AdminRow } from './registry';
10
+ import type { WidgetContext } from './widget-value';
11
+
12
+ /**
13
+ * The path segment a resource's lookup is mounted under: `/admin/<resource>/lookup`. A literal
14
+ * segment outranks `:id`, exactly as `new` does, so no row can be shadowed by it.
15
+ */
16
+ export const LOOKUP_SEGMENT = 'lookup';
17
+
18
+ /** Predicates, or predicates of the acting actor (`mine`, `my team's`). */
19
+ export type AdminScopeWhere =
20
+ | readonly AdminFilter[]
21
+ | ((actor: AdminActor) => readonly AdminFilter[]);
22
+
23
+ export interface AdminScopeOptions {
24
+ readonly where: AdminScopeWhere;
25
+ /** The scope a bare list URL applies. At most one; with none, the bare list is unscoped. */
26
+ readonly default?: boolean;
27
+ /** Show the row count on the tab. Opt-in: a count is a query, on a big table a slow one. */
28
+ readonly count?: boolean;
29
+ /** Omitted: `admin.<entity>.scope.<name>`. */
30
+ readonly labelKey?: string;
31
+ }
32
+
33
+ /** A declared scope, normalised. `where` is checked on every call — it may depend on the actor. */
34
+ export interface AdminScope {
35
+ readonly name: string;
36
+ readonly labelKey: string;
37
+ readonly default: boolean;
38
+ readonly count: boolean;
39
+ where(actor: AdminActor): readonly AdminFilter[];
40
+ }
41
+
42
+ /**
43
+ * Which rows of a resource one actor may see at all. One declaration, applied by every read the
44
+ * admin issues for the resource — list, search, detail, lookup, label, the MCP tools — so a second
45
+ * audience is a function of the actor and never a second copy of the resource.
46
+ */
47
+ export type AdminRowScope = (actor: AdminActor) => readonly AdminFilter[];
48
+
49
+ /** The closed set. An app needing a seventh passes a component for that column. */
50
+ export const ADMIN_RENDERERS = [
51
+ 'badge',
52
+ 'relative-time',
53
+ 'money',
54
+ 'truncate',
55
+ 'link',
56
+ 'json',
57
+ ] as const;
58
+
59
+ export type AdminRenderer = (typeof ADMIN_RENDERERS)[number];
60
+
61
+ export interface AdminColumnRenderProps<Row extends AdminRow = AdminRow> {
62
+ readonly value: unknown;
63
+ readonly row: Row;
64
+ readonly ctx: WidgetContext;
65
+ }
66
+
67
+ export type AdminColumnComponent<Row extends AdminRow = AdminRow> = (
68
+ props: AdminColumnRenderProps<Row>,
69
+ ) => JSX.Element;
70
+
71
+ export interface AdminComputedColumnOptions<Row extends AdminRow = AdminRow> {
72
+ /**
73
+ * The cell's value, from the row. The row it is handed carries no sealed column: a computed
74
+ * column is rendered, and a sealed value is never rendered.
75
+ */
76
+ readonly value: (row: Row) => unknown;
77
+ readonly render: AdminRenderer | AdminColumnComponent<Row>;
78
+ /** Omitted: `admin.<entity>.column.<name>`. */
79
+ readonly labelKey?: string;
80
+ }
81
+
82
+ /**
83
+ * A declared column, normalised — and typed over `AdminRow`, never the app's own `Row`: a resource
84
+ * that carried `(row: Row) => …` would be invariant in `Row`, and every function taking "any
85
+ * resource" would stop accepting one. The narrowing back happens once, in `computedColumnsOf`.
86
+ */
87
+ export interface AdminComputedColumn extends AdminComputedColumnOptions<AdminRow> {
88
+ readonly name: string;
89
+ readonly labelKey: string;
90
+ }
91
+
92
+ interface Declared {
93
+ readonly name: string;
94
+ readonly entity: AdminEntity;
95
+ }
96
+
97
+ /** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
98
+ export function scopesOf(
99
+ resource: Declared,
100
+ scopes: Readonly<Record<string, AdminScopeOptions>> | undefined,
101
+ repo: AdminRepo<AdminRow> | undefined,
102
+ ): readonly AdminScope[] {
103
+ const entries = Object.entries(scopes ?? {});
104
+ const names = entries.map(([name]) => name);
105
+ const refuse = (asked: string, cause: string): never => {
106
+ throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
107
+ };
108
+ const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
109
+ if (defaults.length > 1) {
110
+ refuse(
111
+ `scopes: ${defaults.join(', ')}`,
112
+ 'marks more than one scope `default: true`; a bare list URL applies exactly one',
113
+ );
114
+ }
115
+ return entries.map(([name, scope]) => {
116
+ const declaredAs = `scopes.${name}.where`;
117
+ // A tab's count is `AdminRepo.count`, which a hand-written repo may leave out: refused where
118
+ // the scope is declared, rather than drawn as a tab whose number silently never appears.
119
+ if (scope.count === true && repo !== undefined && repo.count === undefined) {
120
+ refuse(
121
+ `scopes.${name}.count`,
122
+ 'asks for a count, and the repo this resource reads through has no count()',
123
+ );
124
+ }
125
+ const where = scope.where;
126
+ if (typeof where !== 'function') declaredFilters(resource, where, declaredAs);
127
+ return {
128
+ name,
129
+ labelKey: scope.labelKey ?? `admin.${resource.name}.scope.${name}`,
130
+ default: scope.default === true,
131
+ count: scope.count === true,
132
+ where: (actor) =>
133
+ typeof where === 'function' ? declaredFilters(resource, where(actor), declaredAs) : where,
134
+ };
135
+ });
136
+ }
137
+
138
+ /** `rows`, checked on every call. Absent stays absent: an unscoped resource asks no predicate. */
139
+ export function rowScopeOf(
140
+ resource: Declared,
141
+ rows: AdminRowScope | undefined,
142
+ ): AdminRowScope | undefined {
143
+ if (rows === undefined) return undefined;
144
+ return (actor) => declaredFilters(resource, rows(actor), 'rows');
145
+ }
146
+
147
+ const isRenderer = (value: unknown): value is AdminRenderer =>
148
+ (ADMIN_RENDERERS as readonly unknown[]).includes(value);
149
+
150
+ /** Declaration order, after the entity's own list fields. */
151
+ export function computedColumnsOf<Row extends AdminRow>(
152
+ resource: Declared,
153
+ columns: Readonly<Record<string, AdminComputedColumnOptions<Row>>> | undefined,
154
+ ): readonly AdminComputedColumn[] {
155
+ return Object.entries(columns ?? {}).map(([name, column]) => {
156
+ if (Object.hasOwn(resource.entity.$columns, name)) {
157
+ throw new AdminFieldUnsupportedError({
158
+ entity: resource.name,
159
+ field: name,
160
+ cause: 'is declared in `columns:` and is also a column of the entity',
161
+ fix: `rename the computed column in resources.${resource.name}.columns — e.g. ${name}Label — or drop it and let the admin render the column itself`,
162
+ });
163
+ }
164
+ if (typeof column.render !== 'function' && !isRenderer(column.render)) {
165
+ throw new AdminFieldUnsupportedError({
166
+ entity: resource.name,
167
+ field: name,
168
+ cause: `names a renderer the admin does not have (it has: ${ADMIN_RENDERERS.join(', ')})`,
169
+ fix: `set resources.${resource.name}.columns.${name}.render to one of those, or pass a component: render: (props) => <YourCell {...props} />`,
170
+ });
171
+ }
172
+ const render = column.render;
173
+ return {
174
+ name,
175
+ labelKey: column.labelKey ?? `admin.${resource.name}.column.${name}`,
176
+ // The rows a resource's list hands these ARE its `Row`s: the declaration's own type is
177
+ // restored here, at the one place the two meet.
178
+ value: (row) => column.value(row as Row),
179
+ render:
180
+ typeof render === 'function'
181
+ ? (props) => render({ ...props, row: props.row as Row })
182
+ : render,
183
+ };
184
+ });
185
+ }