@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,240 @@
1
+ // A resource read AS A TARGET: the label of each row another resource references, and the lookup
2
+ // a picker searches. Both are reads of the target — decided by its `list` gate, narrowed by its
3
+ // row scope, audited under its name — and both are batched: one statement per target resource per
4
+ // page, never one per row and never one per cell.
5
+
6
+ import { finiteCount } from '@ultimat3/core';
7
+ import { expectedQueryLoop } from '@ultimat3/db';
8
+ import type { AuditEntry } from './audit';
9
+ import type { AdminDecision } from './authz';
10
+ import { type CrudCtx, decideOperation } from './crud';
11
+ import { checkedFilter } from './list-filters';
12
+ import { rowWhere } from './list-scope';
13
+ import { type AdminPage, fetchPage } from './pagination';
14
+ import type { AdminRow } from './registry';
15
+ import { rowId } from './registry';
16
+ import { type AdminResource, repoOf } from './resource';
17
+
18
+ /** One row of a target, as a picker offers it and a reference cell shows it. */
19
+ export interface AdminOption {
20
+ readonly id: string;
21
+ readonly label: string;
22
+ }
23
+
24
+ /**
25
+ * A target with at most this many visible rows is offered whole, as a `<select>`. One more and
26
+ * the picker is a search: an option list nobody can scan is not a control.
27
+ */
28
+ export const LOOKUP_SELECT_MAX = 50;
29
+
30
+ /**
31
+ * The most ids one label read names — the list's own page-size ceiling. A page naming more is read
32
+ * in chunks of this many, never cut at it: a 200-row list with two columns on one target names up
33
+ * to 400 ids, and the ids past the cut were drawn as raw ids with no error anywhere.
34
+ */
35
+ const MAX_LABEL_IDS = 200;
36
+
37
+ /** The row's label-field value when it is text, else its id — never a blank cell. */
38
+ export function labelOf(
39
+ row: AdminRow,
40
+ resource: Pick<AdminResource, 'labelField' | 'idField'>,
41
+ ): string {
42
+ const value = row[resource.labelField];
43
+ if (typeof value === 'string' && value !== '') return value;
44
+ return rowId(row, resource.idField);
45
+ }
46
+
47
+ const optionOf = (resource: AdminResource, row: AdminRow): AdminOption => ({
48
+ id: rowId(row, resource.idField),
49
+ label: labelOf(row, resource),
50
+ });
51
+
52
+ const readEntry = (resource: AdminResource, ctx: CrudCtx, decision: AdminDecision) =>
53
+ ctx.audit.append({
54
+ requestId: ctx.requestId,
55
+ actor: ctx.actor,
56
+ operation: 'list',
57
+ kind: 'operation',
58
+ entity: resource.name,
59
+ entityId: null,
60
+ permission: decision.permission,
61
+ outcome: 'allowed',
62
+ reason: decision.reason,
63
+ });
64
+
65
+ export interface AdminLookupRequest {
66
+ /** Matched against the target's label, by its label filter's default operator. */
67
+ readonly term?: string;
68
+ readonly cursor?: string | null;
69
+ readonly limit?: number;
70
+ }
71
+
72
+ export type AdminLookupResult =
73
+ | {
74
+ readonly ok: true;
75
+ readonly options: readonly AdminOption[];
76
+ readonly page: AdminPage<AdminRow>;
77
+ readonly audit: AuditEntry;
78
+ }
79
+ | { readonly ok: false; readonly decision: AdminDecision };
80
+
81
+ /**
82
+ * The one lookup of a target resource: `contains` on its label, keyset-paged in the resource's own
83
+ * default order, behind the same `list` gate and the same row scope as its list screen. Every
84
+ * filter and every input that references the resource asks this — an app writes no endpoint.
85
+ */
86
+ export async function adminLookup(
87
+ resource: AdminResource,
88
+ ctx: CrudCtx,
89
+ request: AdminLookupRequest = {},
90
+ ): Promise<AdminLookupResult> {
91
+ const decision = decideOperation(resource, 'list', ctx);
92
+ if (!decision.allowed) return { ok: false, decision };
93
+ const term = request.term?.trim() ?? '';
94
+ const where = [
95
+ ...rowWhere(resource, ctx.actor),
96
+ // Through the validator, like any caller's filter: a label that takes no `contains` (an id)
97
+ // is matched exactly, and one that is no filter at all is refused by name.
98
+ ...(term === '' ? [] : [checkedFilter(resource, { field: resource.labelField, value: term })]),
99
+ ];
100
+ const page = await fetchPage(resource, {
101
+ ...(request.cursor === undefined ? {} : { cursor: request.cursor }),
102
+ // At least 1, and finite: a `NaN` here is a page that is never trimmed and never ends.
103
+ limit: finiteCount('adminLookup', 'limit', request.limit ?? LOOKUP_SELECT_MAX, 1),
104
+ ...(where.length === 0 ? {} : { where }),
105
+ });
106
+ return {
107
+ ok: true,
108
+ options: page.rows.map((row) => optionOf(resource, row)),
109
+ page,
110
+ audit: await readEntry(resource, ctx, decision),
111
+ };
112
+ }
113
+
114
+ /** What one page needs of one target: the ids its cells show, and whether a control picks from it. */
115
+ export interface RelationNeed {
116
+ readonly ids: ReadonlySet<string>;
117
+ /** A filter or an input on the page picks a row of this target. */
118
+ readonly pick: boolean;
119
+ }
120
+
121
+ export interface RelationData {
122
+ /** id → label, for every id the page shows that this actor may see. */
123
+ readonly labels: ReadonlyMap<string, string>;
124
+ /**
125
+ * Every row of the target, when there are at most `LOOKUP_SELECT_MAX` — the `<select>`. `null`
126
+ * when there are more (the picker is the lookup screen) or when nothing on the page picks.
127
+ */
128
+ readonly options: readonly AdminOption[] | null;
129
+ }
130
+
131
+ const NOTHING: RelationData = { labels: new Map(), options: null };
132
+
133
+ async function relationOf(
134
+ resource: AdminResource,
135
+ ctx: CrudCtx,
136
+ need: RelationNeed,
137
+ ): Promise<RelationData> {
138
+ if (!need.pick && need.ids.size === 0) return NOTHING;
139
+ const decision = decideOperation(resource, 'list', ctx);
140
+ // Not an event: the actor asked for another resource's page, and a label they may not read is
141
+ // an id shown as an id. The refusal they would get by opening the target is logged there.
142
+ if (!decision.allowed) return NOTHING;
143
+
144
+ const repo = repoOf(resource);
145
+ const visible = rowWhere(resource, ctx.actor);
146
+ const labels = new Map<string, string>();
147
+ let options: readonly AdminOption[] | null = null;
148
+ let wanted = [...need.ids];
149
+
150
+ if (need.pick) {
151
+ // One over the ceiling: the extra row is how "small enough for a select" is learned without
152
+ // a count, which on a large table is the slow query this probe exists to avoid.
153
+ const rows = await repo.list({
154
+ ...(visible.length === 0 ? {} : { where: visible }),
155
+ sort: resource.defaultSort,
156
+ limit: LOOKUP_SELECT_MAX + 1,
157
+ });
158
+ for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
159
+ if (rows.length <= LOOKUP_SELECT_MAX) {
160
+ // The whole target is in hand, so every label the page needs is too: no second read.
161
+ options = rows
162
+ .map((row) => optionOf(resource, row))
163
+ .sort((a, b) => (a.label < b.label ? -1 : a.label > b.label ? 1 : 0));
164
+ wanted = [];
165
+ } else {
166
+ wanted = wanted.filter((id) => !labels.has(id));
167
+ }
168
+ }
169
+
170
+ const chunks: string[][] = [];
171
+ for (let at = 0; at < wanted.length; at += MAX_LABEL_IDS) {
172
+ chunks.push(wanted.slice(at, at + MAX_LABEL_IDS));
173
+ }
174
+ // One statement per chunk, and only past the ceiling: a page's ids are known up front, so this
175
+ // is a bounded batch, not a read per row — and declared as one.
176
+ await expectedQueryLoop(
177
+ 'admin labels read a page’s referenced ids in chunks under the `in` ceiling',
178
+ async () => {
179
+ for (const chunk of chunks) {
180
+ const rows = await repo.list({
181
+ where: [...visible, { field: resource.idField, op: 'in', value: chunk }],
182
+ sort: resource.defaultSort,
183
+ limit: chunk.length,
184
+ });
185
+ for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
186
+ }
187
+ },
188
+ );
189
+
190
+ await readEntry(resource, ctx, decision);
191
+ return { labels, options };
192
+ }
193
+
194
+ /**
195
+ * Everything one page needs from the resources it references, keyed by target name. One statement
196
+ * per target — two for a picked target too large for a `<select>` — whatever the page's row count
197
+ * and however many columns point at the same target.
198
+ */
199
+ export async function relationsFor(
200
+ resources: readonly AdminResource[],
201
+ ctx: CrudCtx,
202
+ needs: ReadonlyMap<string, RelationNeed>,
203
+ ): Promise<ReadonlyMap<string, RelationData>> {
204
+ const targets = resources.filter((resource) => needs.has(resource.name));
205
+ const read = await Promise.all(
206
+ targets.map((resource) => relationOf(resource, ctx, needs.get(resource.name) ?? NO_NEED)),
207
+ );
208
+ return new Map(targets.map((resource, index) => [resource.name, read[index] ?? NOTHING]));
209
+ }
210
+
211
+ const NO_NEED: RelationNeed = { ids: new Set(), pick: false };
212
+
213
+ /**
214
+ * What a set of rows and fields needs: per target resource, the ids the rows hold in the fields
215
+ * that reference it, and whether any of `picked` — a filter, a form input — picks from it.
216
+ */
217
+ export function relationNeeds(
218
+ rows: readonly AdminRow[],
219
+ shown: readonly { readonly name: string; readonly relation?: { readonly entity: string } }[],
220
+ picked: readonly { readonly relation?: { readonly entity: string } }[],
221
+ ): ReadonlyMap<string, RelationNeed> {
222
+ const needs = new Map<string, { ids: Set<string>; pick: boolean }>();
223
+ const need = (entity: string): { ids: Set<string>; pick: boolean } => {
224
+ const held = needs.get(entity) ?? { ids: new Set<string>(), pick: false };
225
+ needs.set(entity, held);
226
+ return held;
227
+ };
228
+ for (const field of shown) {
229
+ if (field.relation === undefined) continue;
230
+ const held = need(field.relation.entity);
231
+ for (const row of rows) {
232
+ const value = row[field.name];
233
+ if (value !== null && value !== undefined && value !== '') held.ids.add(String(value));
234
+ }
235
+ }
236
+ for (const field of picked) {
237
+ if (field.relation !== undefined) need(field.relation.entity).pick = true;
238
+ }
239
+ return needs;
240
+ }
@@ -0,0 +1,89 @@
1
+ // The six renderers a computed column names, and the one dispatch that draws a computed cell. A
2
+ // closed set on purpose: each maps a VALUE SHAPE to an `@ultimat3/ui` component, so a column says
3
+ // what its value is and never how it is drawn. A seventh shape is a component the app passes.
4
+
5
+ import { safeUrl } from '@ultimat3/core';
6
+ import { t } from '@ultimat3/i18n';
7
+ import { Badge, Money, RelativeTime, TONES, type Tone } from '@ultimat3/ui';
8
+ import type { JSX } from 'solid-js';
9
+ import { jsonText } from './json-text';
10
+ import type { AdminRow } from './registry';
11
+ import type { AdminComputedColumn, AdminRenderer } from './resource-list';
12
+ import { assertMoney, type WidgetContext } from './widget-value';
13
+
14
+ /** What `truncate` keeps. One line of a laptop-width table cell. */
15
+ export const TRUNCATE_AT = 80;
16
+
17
+ const isTone = (value: unknown): value is Tone => (TONES as readonly unknown[]).includes(value);
18
+
19
+ const bag = (value: unknown): Readonly<Record<string, unknown>> =>
20
+ typeof value === 'object' && value !== null ? (value as Readonly<Record<string, unknown>>) : {};
21
+
22
+ const text = (value: unknown): string =>
23
+ typeof value === 'string' ? value : typeof value === 'number' ? String(value) : '';
24
+
25
+ const empty = (): JSX.Element => <span>{t('admin.value.empty')}</span>;
26
+
27
+ type Render = (value: unknown, column: AdminComputedColumn, ctx: WidgetContext) => JSX.Element;
28
+
29
+ /**
30
+ * | renderer | value |
31
+ * |---|---|
32
+ * | `badge` | text, or `{ label, tone }` |
33
+ * | `relative-time` | a `Date` or an ISO instant |
34
+ * | `money` | `{ minor, currency }` — a float is refused, as everywhere |
35
+ * | `truncate` | text, cut at `TRUNCATE_AT` with the whole value on `title` |
36
+ * | `link` | `{ href, label }` — the href through `safeUrl` |
37
+ * | `json` | anything — keys sorted, a bigint as its digits (`json-text.ts`) |
38
+ */
39
+ const RENDERERS: Readonly<Record<AdminRenderer, Render>> = {
40
+ badge: (value) => {
41
+ const tone = bag(value)['tone'];
42
+ const label = typeof value === 'object' ? text(bag(value)['label']) : text(value);
43
+ return label === '' ? empty() : <Badge tone={isTone(tone) ? tone : 'neutral'}>{label}</Badge>;
44
+ },
45
+ 'relative-time': (value, _column, ctx) =>
46
+ value instanceof Date || typeof value === 'string' ? (
47
+ <RelativeTime value={value} locale={ctx.locale} timeZone={ctx.timeZone} />
48
+ ) : (
49
+ empty()
50
+ ),
51
+ money: (value, column, ctx) => {
52
+ // The guard names what it refuses; a computed column has no entity field to name.
53
+ const money = assertMoney({ entity: 'columns', name: column.name }, value);
54
+ return money === null ? empty() : <Money value={money} locale={ctx.locale} />;
55
+ },
56
+ truncate: (value) => {
57
+ const whole = text(value);
58
+ if (whole === '') return empty();
59
+ return whole.length <= TRUNCATE_AT ? (
60
+ <span>{whole}</span>
61
+ ) : (
62
+ <span title={whole}>{`${whole.slice(0, TRUNCATE_AT)}…`}</span>
63
+ );
64
+ },
65
+ link: (value) => {
66
+ const href = safeUrl(text(bag(value)['href']), 'href');
67
+ const label = text(bag(value)['label']);
68
+ if (label === '') return empty();
69
+ return href === null ? <span>{label}</span> : <a href={href}>{label}</a>;
70
+ },
71
+ json: (value) =>
72
+ value === undefined ? empty() : <pre class="x-admin-json">{jsonText(value)}</pre>,
73
+ };
74
+
75
+ /** The same table as a Map: a name that is not one of the six draws an empty cell. */
76
+ const RENDER: ReadonlyMap<string, Render> = new Map(Object.entries(RENDERERS));
77
+
78
+ /** One computed cell. */
79
+ export function ComputedCell(props: {
80
+ readonly column: AdminComputedColumn;
81
+ readonly row: AdminRow;
82
+ readonly ctx: WidgetContext;
83
+ }): JSX.Element {
84
+ const value = props.column.value(props.row);
85
+ const render = props.column.render;
86
+ return typeof render === 'function'
87
+ ? render({ value, row: props.row, ctx: props.ctx })
88
+ : (RENDER.get(render)?.(value, props.column, props.ctx) ?? empty());
89
+ }
@@ -0,0 +1,195 @@
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 { seekPage } from './keyset-seek';
9
+ import type {
10
+ AdminDb,
11
+ AdminEntity,
12
+ AdminFilter,
13
+ AdminListQuery,
14
+ AdminRepo,
15
+ AdminRow,
16
+ AdminTable,
17
+ AdminTableRead,
18
+ } from './registry';
19
+
20
+ type TableOperator = Parameters<AdminTableRead['andWhere']>[1];
21
+
22
+ /**
23
+ * `contains` is the admin's word for it; `like` with both wildcards is what a driver runs. A Map,
24
+ * so an operator that is not one of the seven answers `undefined` rather than a prototype member.
25
+ */
26
+ const OPERATOR_OF: ReadonlyMap<AdminFilter['op'], TableOperator> = new Map([
27
+ ['eq', 'eq'],
28
+ ['neq', 'neq'],
29
+ ['contains', 'like'],
30
+ ['gt', 'gt'],
31
+ ['gte', 'gte'],
32
+ ['lt', 'lt'],
33
+ ['lte', 'lte'],
34
+ ['in', 'in'],
35
+ ['is-null', 'is-null'],
36
+ ]);
37
+
38
+ /** `%` and `_` are wildcards to `LIKE`; a `contains` matches the text an operator typed. */
39
+ const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\${wild}`);
40
+
41
+ /**
42
+ * A comparison value, in the shape the column holds. An instant travels as ISO text — in a URL, in
43
+ * a cursor — and the handle is given the `Date`: Postgres would cast the text, the memory driver
44
+ * compares a `Date` to a string by its characters, and a keyset over `createdAt` (the admin's
45
+ * default sort) then answers page one for every page. An `integer` travels as text in a cursor
46
+ * too, and the memory driver's `eq` holds `'7'` and `7` to be two values where Postgres binds one.
47
+ */
48
+ type Typed = (field: string, value: unknown) => unknown;
49
+
50
+ const typedBy = (entity: AdminEntity): Typed => {
51
+ const kinds = new Map(adminColumnsOf(entity).map((column) => [column.name, column.kind]));
52
+ return (field, value) => {
53
+ if (typeof value !== 'string') return value;
54
+ const kind = kinds.get(field);
55
+ if (kind === 'integer') {
56
+ const count = Number(value);
57
+ return value.trim() !== '' && Number.isSafeInteger(count) ? count : value;
58
+ }
59
+ if (kind !== 'timestamptz') return value;
60
+ const at = new Date(value);
61
+ return Number.isNaN(at.getTime()) ? value : at;
62
+ };
63
+ };
64
+
65
+ const predicate =
66
+ (typed: Typed) =>
67
+ (read: AdminTableRead, filter: AdminFilter): AdminTableRead => {
68
+ if (filter.op === 'contains') {
69
+ return read.andWhere(filter.field, 'like', `%${literal(String(filter.value))}%`);
70
+ }
71
+ // One admin operator, two of the handle's: `false` asks for the rows that HAVE a value.
72
+ if (filter.op === 'is-null') {
73
+ return read.andWhere(filter.field, filter.value === false ? 'is-not-null' : 'is-null');
74
+ }
75
+ return read.andWhere(
76
+ filter.field,
77
+ OPERATOR_OF.get(filter.op) ?? 'eq',
78
+ typed(filter.field, filter.value),
79
+ );
80
+ };
81
+
82
+ const filtered = (
83
+ read: AdminTableRead,
84
+ where: readonly AdminFilter[],
85
+ typed: Typed,
86
+ ): AdminTableRead => where.reduce(predicate(typed), read);
87
+
88
+ /**
89
+ * One entity + its table → one `AdminRepo`.
90
+ *
91
+ * A row is never spread and never re-parsed here. The handle parses an insert against the entity
92
+ * and asserts every invariant over the stored row merged under a patch, so a second parse would be
93
+ * a second opinion — and a row's sealed properties are non-enumerable, so a spread would drop the
94
+ * very values a whole-row write then needs.
95
+ */
96
+ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<AdminRow> {
97
+ const idField = entity.$primaryKey[0] ?? 'id';
98
+ const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
99
+ const typed = typedBy(entity);
100
+ const columns = adminColumnsOf(entity);
101
+ /**
102
+ * `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
103
+ * OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
104
+ * of reaching the driver as a comparison Postgres answers with a cast error.
105
+ */
106
+ const idOf = (id: string): string => {
107
+ const parsed = idColumn?.$parse === undefined ? id : idColumn.$parse(id);
108
+ if (typeof parsed !== 'string') {
109
+ throw invariantViolated(entity.$name, idField, `expected a string id, got ${typeof parsed}`);
110
+ }
111
+ return parsed;
112
+ };
113
+ /**
114
+ * The same crossing for a READ, where an id that is not one is simply a row that does not exist:
115
+ * `/posts/nope` is the admin's 404, not `X_INVARIANT_VIOLATED` — the operator mistyped a URL and
116
+ * violated nothing. A write keeps the throw: it is reached only after a read found the row.
117
+ */
118
+ const readableId = (id: string): string | undefined => {
119
+ try {
120
+ return idOf(id);
121
+ } catch (error) {
122
+ if (isUltimateError(error) && error.code === 'X_INVARIANT_VIOLATED') return undefined;
123
+ throw error;
124
+ }
125
+ };
126
+
127
+ return {
128
+ async list(query: AdminListQuery): Promise<readonly AdminRow[]> {
129
+ const base = filtered(table, query.where ?? [], typed);
130
+ const sortField = query.sort.field;
131
+ const facts = columns.find((column) => column.name === sortField);
132
+ return seekPage(
133
+ query,
134
+ {
135
+ field: sortField,
136
+ idField,
137
+ nullable: facts?.nullable ?? true,
138
+ instant: facts?.kind === 'timestamptz',
139
+ typed: (value) => typed(sortField, value),
140
+ },
141
+ async (where, order, limit) => {
142
+ const read = where.reduce(
143
+ (chain, term) =>
144
+ term.value === undefined
145
+ ? chain.andWhere(term.field, term.op)
146
+ : chain.andWhere(term.field, term.op, term.value),
147
+ base,
148
+ );
149
+ // The tie-break, always: a page boundary on a partial order repeats or drops a row.
150
+ const sorted = order.by === 'pair' ? read.orderBy(sortField, order.direction) : read;
151
+ return [...(await sorted.orderBy(idField, order.direction).limit(limit).all())];
152
+ },
153
+ );
154
+ },
155
+ // `async`, so a refused id arrives as a rejection: these return promises, and a caller that
156
+ // chains `.catch()` instead of `await`ing would never see a synchronous throw.
157
+ find: async (id) => {
158
+ const key = readableId(id);
159
+ return key === undefined ? null : table.where({ [idField]: key }).one();
160
+ },
161
+ create: async (input) => table.insert(input),
162
+ update: async (id, patch) => table.update(idOf(id), patch),
163
+ destroy: async (id) => table.delete(idOf(id)),
164
+ count: async (where) => filtered(table, where ?? [], typed).count(),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * The handle's tables, by the ENTITY they serve. `database({ posts })` keys a table by the app's
170
+ * own spelling, which need not be the entity's `$name` — so the name is read off the table's own
171
+ * plan rather than off the key it happens to sit under.
172
+ */
173
+ export function adminTablesOf(db: AdminDb): ReadonlyMap<string, AdminTable> {
174
+ return new Map(Object.values(db).map((table) => [table.plan().entity, table]));
175
+ }
176
+
177
+ /**
178
+ * Every entity the handle serves that the admin can address — what an app passes as `entities`
179
+ * when its admin is "all of my tables": `defineAdmin({ entities: adminEntitiesOf(db), db })`. The
180
+ * handle's set is the one `x g entity` adds to, so a new entity is a new admin resource with no
181
+ * second registration.
182
+ *
183
+ * An entity keyed on more than one column is left out: a route id and an `AdminRepo` id carry one
184
+ * string. Name it in `entities` yourself and `defineAdmin` refuses it out loud
185
+ * (`X_ADMIN_FIELD_UNSUPPORTED`) — this helper is "everything that can be a screen", not a claim
186
+ * that everything is. Handle order, which is the order the app declared.
187
+ */
188
+ export function adminEntitiesOf(db: AdminDb): readonly AdminEntity[] {
189
+ const found: AdminEntity[] = [];
190
+ for (const name of adminTablesOf(db).keys()) {
191
+ const declared = getEntity(name)?.core;
192
+ if (declared !== undefined && declared.$primaryKey.length === 1) found.push(declared);
193
+ }
194
+ return found;
195
+ }
@@ -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
+ }