@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,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,194 @@
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
+ /**
98
+ * The list URL's spelling of "no scope, not even the default" — `?scope=*`, what a related card's
99
+ * "all" link opens. Reserved: a scope declared under it is refused, so the word means one thing.
100
+ */
101
+ export const NO_SCOPE = '*';
102
+
103
+ /** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
104
+ export function scopesOf(
105
+ resource: Declared,
106
+ scopes: Readonly<Record<string, AdminScopeOptions>> | undefined,
107
+ repo: AdminRepo<AdminRow> | undefined,
108
+ ): readonly AdminScope[] {
109
+ const entries = Object.entries(scopes ?? {});
110
+ const names = entries.map(([name]) => name);
111
+ const refuse = (asked: string, cause: string): never => {
112
+ throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
113
+ };
114
+ if (Object.hasOwn(scopes ?? {}, NO_SCOPE)) {
115
+ refuse(`scopes.${NO_SCOPE}`, `is the list URL's word for "no scope" and cannot name one`);
116
+ }
117
+ const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
118
+ if (defaults.length > 1) {
119
+ refuse(
120
+ `scopes: ${defaults.join(', ')}`,
121
+ 'marks more than one scope `default: true`; a bare list URL applies exactly one',
122
+ );
123
+ }
124
+ return entries.map(([name, scope]) => {
125
+ const declaredAs = `scopes.${name}.where`;
126
+ // A tab's count is `AdminRepo.count`, which a hand-written repo may leave out: refused where
127
+ // the scope is declared, rather than drawn as a tab whose number silently never appears.
128
+ if (scope.count === true && repo !== undefined && repo.count === undefined) {
129
+ refuse(
130
+ `scopes.${name}.count`,
131
+ 'asks for a count, and the repo this resource reads through has no count()',
132
+ );
133
+ }
134
+ const where = scope.where;
135
+ if (typeof where !== 'function') declaredFilters(resource, where, declaredAs);
136
+ return {
137
+ name,
138
+ labelKey: scope.labelKey ?? `admin.${resource.name}.scope.${name}`,
139
+ default: scope.default === true,
140
+ count: scope.count === true,
141
+ where: (actor) =>
142
+ typeof where === 'function' ? declaredFilters(resource, where(actor), declaredAs) : where,
143
+ };
144
+ });
145
+ }
146
+
147
+ /** `rows`, checked on every call. Absent stays absent: an unscoped resource asks no predicate. */
148
+ export function rowScopeOf(
149
+ resource: Declared,
150
+ rows: AdminRowScope | undefined,
151
+ ): AdminRowScope | undefined {
152
+ if (rows === undefined) return undefined;
153
+ return (actor) => declaredFilters(resource, rows(actor), 'rows');
154
+ }
155
+
156
+ const isRenderer = (value: unknown): value is AdminRenderer =>
157
+ (ADMIN_RENDERERS as readonly unknown[]).includes(value);
158
+
159
+ /** Declaration order, after the entity's own list fields. */
160
+ export function computedColumnsOf<Row extends AdminRow>(
161
+ resource: Declared,
162
+ columns: Readonly<Record<string, AdminComputedColumnOptions<Row>>> | undefined,
163
+ ): readonly AdminComputedColumn[] {
164
+ return Object.entries(columns ?? {}).map(([name, column]) => {
165
+ if (Object.hasOwn(resource.entity.$columns, name)) {
166
+ throw new AdminFieldUnsupportedError({
167
+ entity: resource.name,
168
+ field: name,
169
+ cause: 'is declared in `columns:` and is also a column of the entity',
170
+ 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`,
171
+ });
172
+ }
173
+ if (typeof column.render !== 'function' && !isRenderer(column.render)) {
174
+ throw new AdminFieldUnsupportedError({
175
+ entity: resource.name,
176
+ field: name,
177
+ cause: `names a renderer the admin does not have (it has: ${ADMIN_RENDERERS.join(', ')})`,
178
+ fix: `set resources.${resource.name}.columns.${name}.render to one of those, or pass a component: render: (props) => <YourCell {...props} />`,
179
+ });
180
+ }
181
+ const render = column.render;
182
+ return {
183
+ name,
184
+ labelKey: column.labelKey ?? `admin.${resource.name}.column.${name}`,
185
+ // The rows a resource's list hands these ARE its `Row`s: the declaration's own type is
186
+ // restored here, at the one place the two meet.
187
+ value: (row) => column.value(row as Row),
188
+ render:
189
+ typeof render === 'function'
190
+ ? (props) => render({ ...props, row: props.row as Row })
191
+ : render,
192
+ };
193
+ });
194
+ }
package/src/resource.ts CHANGED
@@ -4,25 +4,33 @@
4
4
  // field, but the zero-config result is the one the generator emits and the one the docs show.
5
5
 
6
6
  import { finiteCount } from '@ultimat3/core';
7
- import { type AdminColumnFacts, adminColumnsOf } from './entity-columns';
7
+ import { type AdminColumnFacts, adminColumnsOf, adminSealedColumnsOf } from './entity-columns';
8
8
  import {
9
9
  AdminEntityUnknownError,
10
10
  AdminFieldUnsupportedError,
11
11
  AdminPolicyMissingError,
12
+ AdminRepoUnboundError,
12
13
  } from './errors';
13
- import {
14
- type AdminField,
15
- type AdminFieldType,
16
- type AdminWidget,
17
- fieldTypeFromColumn,
18
- filterable,
19
- listable,
20
- searchable,
21
- sortable,
22
- widgetFor,
23
- } from './fields';
14
+ import type { AdminField } from './fields';
24
15
  import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
25
16
  import type { AdminAction, AdminEntity, AdminRepo, AdminRow, AdminSort } from './registry';
17
+ import { type AdminFieldOverride, deriveField, secretField } from './resource-fields';
18
+ import {
19
+ type AdminFormGroupOptions,
20
+ type AdminSection,
21
+ type AdminSectionOptions,
22
+ layoutOf,
23
+ } from './resource-layout';
24
+ import {
25
+ type AdminComputedColumn,
26
+ type AdminComputedColumnOptions,
27
+ type AdminRowScope,
28
+ type AdminScope,
29
+ type AdminScopeOptions,
30
+ computedColumnsOf,
31
+ rowScopeOf,
32
+ scopesOf,
33
+ } from './resource-list';
26
34
 
27
35
  /** Six columns is what fits a laptop viewport without horizontal scroll. */
28
36
  const MAX_LIST_FIELDS = 6;
@@ -30,27 +38,15 @@ const DEFAULT_PAGE_SIZE = 25;
30
38
  const LABEL_CANDIDATES = ['name', 'title', 'slug', 'label', 'email'] as const;
31
39
  const SORT_CANDIDATES = ['createdAt', 'created_at', 'updatedAt', 'updated_at'] as const;
32
40
 
33
- export interface AdminFieldOverride {
34
- readonly type?: AdminFieldType;
35
- readonly widget?: AdminWidget;
36
- readonly labelKey?: string;
37
- /** Out of every surface: list, detail, form, MCP schema. */
38
- readonly hidden?: boolean;
39
- readonly inList?: boolean;
40
- readonly readOnly?: boolean;
41
- readonly required?: boolean;
42
- readonly sensitive?: boolean;
43
- readonly filterable?: boolean;
44
- readonly sortable?: boolean;
45
- readonly searchable?: boolean;
46
- readonly currency?: string;
47
- readonly values?: readonly string[];
48
- readonly relation?: { readonly entity: string; readonly labelField?: string };
49
- }
50
-
51
41
  export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
52
42
  readonly repo?: AdminRepo<Row>;
53
43
  readonly path?: string;
44
+ /**
45
+ * The noun this resource's permissions are named after — `<permission>:read|write|delete`.
46
+ * Default: the entity's name. Several resources that are one subject to an operator share one
47
+ * (the jobs screens are all `job:read`), so a role map grants a subject once, not per table.
48
+ */
49
+ readonly permission?: string;
54
50
  readonly titleKey?: string;
55
51
  readonly group?: string;
56
52
  /**
@@ -62,6 +58,35 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
62
58
  readonly fields?: Readonly<Record<string, AdminFieldOverride>>;
63
59
  /** Explicit list columns, in order. Omit to let the derivation pick. */
64
60
  readonly listFields?: readonly string[];
61
+ /**
62
+ * Computed list columns, drawn after `listFields` in declaration order:
63
+ * `age: { value: (row) => row.createdAt, render: 'relative-time' }`. `render` is one of the six
64
+ * built-in renderers or a component. A name that is also an entity column is refused.
65
+ */
66
+ readonly columns?: Readonly<Record<string, AdminComputedColumnOptions<Row>>>;
67
+ /**
68
+ * Named predicates, drawn as tabs: `open: { where: [...] }`, `mine: { where: (actor) => [...] }`.
69
+ * At most one `default: true`; `count: true` is one extra query per tab that asks for it.
70
+ */
71
+ readonly scopes?: Readonly<Record<string, AdminScopeOptions>>;
72
+ /**
73
+ * The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
74
+ * detail, lookup, labels, MCP — so it is the one place a second audience is declared.
75
+ */
76
+ readonly rows?: AdminRowScope;
77
+ /**
78
+ * The detail page, as titled groups of fields. A field named in none falls into a default
79
+ * section drawn last, so a column added to the entity is never hidden by this list.
80
+ */
81
+ readonly sections?: readonly AdminSectionOptions[];
82
+ /** The same arrangement for the create and edit form. Independent of `sections`. */
83
+ readonly formGroups?: readonly AdminFormGroupOptions[];
84
+ /**
85
+ * `hasMany` relations of the entity, by name — `['comments']` — drawn on the detail page as the
86
+ * RELATED resource's own list (its columns, its row scope, its policy) filtered to this row.
87
+ * The names are `@ultimat3/entity`'s: the one a preload names.
88
+ */
89
+ readonly related?: readonly string[];
65
90
  readonly defaultSort?: AdminSort;
66
91
  readonly pageSize?: number;
67
92
  readonly operations?: readonly AdminOperation[];
@@ -81,6 +106,8 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
81
106
  * the framework can own (axiom 8) — `path:` is how an app spells its own.
82
107
  */
83
108
  readonly path: string;
109
+ /** The permission noun — `<permission>:read` lists it. The entity's name unless declared. */
110
+ readonly permission: string;
84
111
  readonly titleKey: string;
85
112
  readonly group: string;
86
113
  readonly idField: string;
@@ -90,8 +117,31 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
90
117
  readonly fields: readonly AdminField[];
91
118
  readonly listFields: readonly AdminField[];
92
119
  readonly formFields: readonly AdminField[];
120
+ /**
121
+ * The entity's `.sealed()` columns, as WRITE-ONLY inputs: a form posts them, nothing reads them.
122
+ * Deliberately a list of its own and in none of the others — `fields`, `listFields`,
123
+ * `formFields`, `filters` and `searchFields` are all read back by column name somewhere, and a
124
+ * sealed property answers that read with its plaintext.
125
+ */
126
+ readonly secretFields: readonly AdminField[];
127
+ /**
128
+ * The label field first — a list's search box is its `contains` filter — then every field the
129
+ * derivation offers. What a URL's `f.<field>` may name, and nothing else.
130
+ */
93
131
  readonly filters: readonly AdminField[];
94
132
  readonly searchFields: readonly AdminField[];
133
+ /** Computed list columns, in declaration order. */
134
+ readonly columns: readonly AdminComputedColumn[];
135
+ /** Declared scopes, in tab order. Empty for a resource that declares none. */
136
+ readonly scopes: readonly AdminScope[];
137
+ /** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
138
+ readonly rowScope?: AdminRowScope;
139
+ /** The detail page's groups. Always at least one: undeclared fields have a default section. */
140
+ readonly sections: readonly AdminSection[];
141
+ /** The form's groups, over `formFields` and `secretFields` both. */
142
+ readonly formGroups: readonly AdminSection[];
143
+ /** Declared `related` relation names, in order. `related.ts` resolves each against the admin. */
144
+ readonly related: readonly string[];
95
145
  readonly defaultSort: AdminSort;
96
146
  readonly pageSize: number;
97
147
  readonly operations: readonly AdminOperation[];
@@ -100,48 +150,6 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
100
150
  field(name: string): AdminField;
101
151
  }
102
152
 
103
- function deriveField(
104
- entity: AdminEntity,
105
- column: AdminColumnFacts,
106
- override: AdminFieldOverride | undefined,
107
- ): AdminField {
108
- const name = column.name;
109
- const type = override?.type ?? fieldTypeFromColumn(entity.$name, name, column);
110
- const widget = override?.widget ?? widgetFor(type);
111
- const generated = column.generated || column.primaryKey;
112
- // An entity has no notion of a secret column, and a currency belongs to the value rather
113
- // than the column, so both are admin-side declarations or they are absent. Never guessed.
114
- const sensitive = override?.sensitive ?? false;
115
- const currency = override?.currency;
116
- const values = override?.values ?? column.values;
117
- const relation =
118
- override?.relation ??
119
- // The FK's value IS the target column's value, so that column is the honest default label.
120
- (column.references === undefined
121
- ? undefined
122
- : { entity: column.references.entity, labelField: column.references.column });
123
-
124
- return {
125
- entity: entity.$name,
126
- name,
127
- type,
128
- widget,
129
- labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
130
- required: override?.required ?? (!column.nullable && !generated),
131
- readOnly: override?.readOnly ?? generated,
132
- sensitive,
133
- inList: override?.inList ?? (listable(type) && !sensitive),
134
- filterable: override?.filterable ?? filterable(type, column),
135
- sortable: override?.sortable ?? sortable(type, column),
136
- // Generated columns are excluded from search: an id is found by exact lookup, and a
137
- // `contains` over a uuid column is a scan that returns nothing useful.
138
- searchable: override?.searchable ?? (searchable(type, column) && !sensitive && !generated),
139
- ...(values === undefined ? {} : { values }),
140
- ...(currency === undefined ? {} : { currency }),
141
- ...(relation === undefined ? {} : { relation }),
142
- };
143
- }
144
-
145
153
  /**
146
154
  * The declared key wins — but a composite one is refused outright, never silently reduced to
147
155
  * its first member: a route id and an `AdminRepo` id both carry exactly one string, so two rows
@@ -230,6 +238,18 @@ function pickListFields(
230
238
  return [...label, ...rest].slice(0, MAX_LIST_FIELDS);
231
239
  }
232
240
 
241
+ /**
242
+ * The label first, whether or not it is indexed: a list's search box IS the label's `contains`
243
+ * filter, and a list an operator cannot search by name is the one they leave for a SQL console.
244
+ * Only a text label qualifies — a `contains` over a uuid is a database error, not an empty result.
245
+ */
246
+ function filtersOf(fields: readonly AdminField[], labelField: string): readonly AdminField[] {
247
+ const label = fields.filter(
248
+ (field) => field.name === labelField && field.filterKind === 'text' && !field.sensitive,
249
+ );
250
+ return [...label, ...fields.filter((field) => field.filterable && !label.includes(field))];
251
+ }
252
+
233
253
  function assertActionsHavePolicies(actions: readonly AdminAction[]): void {
234
254
  for (const action of actions) {
235
255
  // Widened because the registry is JSON at the boundary: a hand-written action object
@@ -264,10 +284,18 @@ export function adminResource<Row extends AdminRow = AdminRow>(
264
284
  const labelField = labelFieldOf(entity, fields, idField, opts.labelField);
265
285
  const actions = opts.actions ?? [];
266
286
  assertActionsHavePolicies(actions);
287
+ const declared = { name: entity.$name, entity };
288
+ const rowScope = rowScopeOf(declared, opts.rows);
289
+
290
+ const formFields = fields.filter((field) => !field.readOnly && !field.sensitive);
291
+ const secretFields = adminSealedColumnsOf(entity)
292
+ .filter((column) => overrides[column.name]?.hidden !== true)
293
+ .map((column) => secretField(entity, column, overrides[column.name]));
267
294
 
268
295
  const resource: AdminResource<Row> = {
269
296
  name: entity.$name,
270
297
  path: opts.path ?? `/${entity.$name}`,
298
+ permission: opts.permission ?? entity.$name,
271
299
  titleKey: opts.titleKey ?? `admin.${entity.$name}.title`,
272
300
  group: opts.group ?? 'admin.group.data',
273
301
  idField,
@@ -275,9 +303,35 @@ export function adminResource<Row extends AdminRow = AdminRow>(
275
303
  entity,
276
304
  fields,
277
305
  listFields: pickListFields(fields, labelField, opts.listFields, entity.$name),
278
- formFields: fields.filter((field) => !field.readOnly && !field.sensitive),
279
- filters: fields.filter((field) => field.filterable),
306
+ formFields,
307
+ secretFields,
308
+ filters: filtersOf(fields, labelField),
280
309
  searchFields: fields.filter((field) => field.searchable),
310
+ columns: computedColumnsOf(declared, opts.columns),
311
+ scopes: scopesOf(declared, opts.scopes, opts.repo),
312
+ ...(rowScope === undefined ? {} : { rowScope }),
313
+ sections: layoutOf(
314
+ {
315
+ entity: entity.$name,
316
+ option: 'sections',
317
+ // What the detail page draws: every field but a `sensitive` one. A sealed column is in no
318
+ // list a reader iterates, so it cannot be named here either.
319
+ fields: fields.filter((field) => !field.sensitive),
320
+ excluded:
321
+ 'is not a field the detail page draws (hidden, sensitive, sealed, or not a column)',
322
+ },
323
+ opts.sections,
324
+ ),
325
+ formGroups: layoutOf(
326
+ {
327
+ entity: entity.$name,
328
+ option: 'formGroups',
329
+ fields: [...formFields, ...secretFields],
330
+ excluded: 'is not a form input (read-only, generated, sensitive, hidden, or not a column)',
331
+ },
332
+ opts.formGroups,
333
+ ),
334
+ related: opts.related ?? [],
281
335
  defaultSort: opts.defaultSort ?? defaultSortOf(fields, idField),
282
336
  // Refused here rather than at the first listing, because `pagination.ts` clamps with
283
337
  // `Math.max(1, Math.min(x, 200))` and neither of those validates: a `NaN` survives both, so
@@ -306,11 +360,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
306
360
  /** The bound repo, or the one error that says which resource forgot it. */
307
361
  export function repoOf<Row extends AdminRow>(resource: AdminResource<Row>): AdminRepo<Row> {
308
362
  if (resource.repo === undefined) {
309
- throw new AdminEntityUnknownError({
310
- entity: resource.name,
311
- known: [],
312
- cause: `resource "${resource.name}" has no repo bound, so the admin cannot read or write rows`,
313
- });
363
+ throw new AdminRepoUnboundError({ entity: resource.name, handles: [] });
314
364
  }
315
365
  return resource.repo;
316
366
  }
@@ -0,0 +1,71 @@
1
+ // The one bridge from the admin's route table to @ultimat3/render's: the `defineRoute` config
2
+ // every admin route is served under, and the declaration that puts those routes in the framework's
3
+ // one route list. No screen is reached from here, so `defineAdmin()` can declare its routes
4
+ // without loading a view — `routes.ts` binds each route to its screen.
5
+
6
+ import { t } from '@ultimat3/i18n';
7
+ import {
8
+ defineRoute,
9
+ type RouteConfig,
10
+ type RouteGuard,
11
+ registerMountedRoutes,
12
+ } from '@ultimat3/render';
13
+ import type { AdminRoute } from './admin';
14
+ import { AdminPageUnguardedError } from './errors';
15
+
16
+ /** What `RouteDescriptor.file` reads for an admin route: the package, since no app file is one. */
17
+ export const ADMIN_ROUTE_FILE = '@ultimat3/admin';
18
+
19
+ export interface AdminRouteDefinition {
20
+ /** The coarse gate — the SAME object `config.policy` carries. */
21
+ readonly policy: RouteGuard;
22
+ readonly config: RouteConfig;
23
+ }
24
+
25
+ /**
26
+ * The author never writes this `defineRoute` call, so the author cannot omit its `policy` —
27
+ * which is the whole mechanism. The coarse gate is `permissions[0]` (always `admin:read`, put
28
+ * there by `permissionsForOperation`/`pagePermissions`); the rest of the pair is decided per
29
+ * request, in `crud.ts` for a resource screen and in `guardedScreen()` for every other one.
30
+ * An empty list is refused here too: `render: 'ssr'` with no policy is a public dashboard.
31
+ */
32
+ export function adminRouteDefinition(route: AdminRoute): AdminRouteDefinition {
33
+ const permission = route.permissions[0];
34
+ if (permission === undefined) throw new AdminPageUnguardedError({ path: route.path });
35
+ const policy: RouteGuard = { permission };
36
+ return {
37
+ policy,
38
+ config: defineRoute({
39
+ // One mode for every admin route, never an author's choice. `ssr` renders the rows behind
40
+ // the guard, once per request, and `hydrate: 'never'` means the screen ships no JS at all:
41
+ // every control on an admin screen is a link or a native form, so there is nothing to
42
+ // hydrate. `never` is a REFUSAL and not just a default — an `island()` rendered on this
43
+ // route throws `X_ISLAND_NOT_HYDRATED` (`@ultimat3/render`'s `island-collector.ts`), and no
44
+ // admin module declares one.
45
+ render: 'ssr',
46
+ offline: 'network-only',
47
+ hydrate: 'never',
48
+ policy,
49
+ // Never indexed and never followed, whatever the screen answers — a list, a refusal, a 404:
50
+ // an admin URL in a search result is a row id handed to a crawler.
51
+ meta: () => ({ title: t(route.titleKey), robots: { index: false, follow: false } }),
52
+ }),
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Declare an admin's routes to the framework's route table — as MOUNTED routes, each with every
58
+ * permission that gates it — so `x routes`, the `/_x` routes panel, the manifest and `sw.js` list
59
+ * them from the one projection everything else is listed from. Keyed by base path: re-declaring
60
+ * the admin (a save under `x dev`) replaces its set.
61
+ */
62
+ export function mountAdminRoutes(basePath: string, routes: readonly AdminRoute[]): void {
63
+ registerMountedRoutes(
64
+ { key: basePath, by: 'defineAdmin', file: ADMIN_ROUTE_FILE, surface: 'app' },
65
+ routes.map((route) => ({
66
+ path: route.path,
67
+ config: adminRouteDefinition(route).config,
68
+ permissions: route.permissions,
69
+ })),
70
+ );
71
+ }