@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
@@ -1,13 +1,14 @@
1
- // The wrapper that makes a custom page's authz unskippable. `routes.ts` never hands the router
2
- // the author's component — it hands this one, which asks the SAME `decideAll` every CRUD call
3
- // and every nav item asks, audits the refusal, and only then calls the author's code.
1
+ // What a refused screen renders and the audit entry it leaves. The wrapper that DECIDES is
2
+ // `guardedScreen()` in `screen-frame.tsx`: `routes.ts` never hands a host the author's component
3
+ // — it hands that screen, which asks the SAME `decideAll` every CRUD call and every nav item
4
+ // asks, audits the refusal through this file, and only then calls the author's code.
4
5
 
5
6
  import { t } from '@ultimat3/i18n';
6
7
  import type { JSX } from 'solid-js';
7
- import type { AdminRoute } from './admin';
8
+ import styles from './admin.module.scss';
8
9
  import { deniedDraft } from './audit';
9
- import { type AdminDecision, decideAll } from './authz';
10
- import type { AdminPageComponent, AdminPageProps } from './pages';
10
+ import type { AdminDecision } from './authz';
11
+ import type { CrudCtx } from './crud';
11
12
 
12
13
  /**
13
14
  * The refusal, rendered as the page. Not a redirect and not a 404: an operator who is missing a
@@ -21,7 +22,7 @@ export function AdminPageDenied(props: {
21
22
  return (
22
23
  <section class="x-admin-denied" role="alert">
23
24
  <h1>{t(props.titleKey)}</h1>
24
- <p>
25
+ <p class={styles['refusal']}>
25
26
  {t('admin.denied.body', {
26
27
  permission: props.decision.permission,
27
28
  reason: props.decision.reason,
@@ -32,27 +33,23 @@ export function AdminPageDenied(props: {
32
33
  }
33
34
 
34
35
  /**
35
- * Wrap once, at route-table build time. The author's component is never reachable from
36
- * `adminRoutes()`, so "the page that forgot its policy line" has nowhere left to exist.
36
+ * The audit entry of a refused SCREEN. The page is the subject, so its path is what the row
37
+ * names — there is no entity and no row id to key a refused screen by. One writer, so a custom
38
+ * page and a generated form's GET leave the same entry.
37
39
  */
38
- export function guardedPage(route: AdminRoute, component: AdminPageComponent): AdminPageComponent {
39
- return async (props: AdminPageProps): Promise<JSX.Element> => {
40
- const decision = decideAll(props.ctx.authz, route.permissions, props.ctx.actor);
41
- if (!decision.allowed) {
42
- // The page IS the subject here, so its path is what the audit row names — there is no
43
- // entity and no row id to key a refused screen by.
44
- await props.ctx.audit.append(
45
- deniedDraft({
46
- requestId: props.ctx.requestId,
47
- actor: props.ctx.actor,
48
- operation: 'page',
49
- kind: 'operation',
50
- entity: route.path,
51
- decision,
52
- }),
53
- );
54
- return <AdminPageDenied titleKey={route.titleKey} decision={decision} />;
55
- }
56
- return component(props);
57
- };
40
+ export async function auditRefusal(
41
+ ctx: CrudCtx,
42
+ path: string,
43
+ decision: AdminDecision,
44
+ ): Promise<void> {
45
+ await ctx.audit.append(
46
+ deniedDraft({
47
+ requestId: ctx.requestId,
48
+ actor: ctx.actor,
49
+ operation: 'page',
50
+ kind: 'operation',
51
+ entity: path,
52
+ decision,
53
+ }),
54
+ );
58
55
  }
package/src/pagination.ts CHANGED
@@ -10,7 +10,8 @@ import { type AdminResource, repoOf } from './resource';
10
10
 
11
11
  export interface AdminCursor {
12
12
  readonly field: string;
13
- readonly value: string;
13
+ /** The boundary row's sort value as text, or `null` when it holds none — a position too. */
14
+ readonly value: string | null;
14
15
  readonly id: string;
15
16
  readonly direction: 'after' | 'before';
16
17
  }
@@ -50,7 +51,7 @@ export function decodeAdminCursor(
50
51
  if (payload.key.length !== 3) return null;
51
52
  const [direction, field, value] = payload.key;
52
53
  if (direction !== 'after' && direction !== 'before') return null;
53
- if (typeof field !== 'string' || typeof value !== 'string') return null;
54
+ if (typeof field !== 'string' || (typeof value !== 'string' && value !== null)) return null;
54
55
  return { direction, field, value, id: payload.id };
55
56
  } catch (error) {
56
57
  if (isUltimateError(error) && error.code === 'X_CURSOR_INVALID') return null;
@@ -98,10 +99,15 @@ export function listQuery<Row extends AdminRow>(
98
99
  };
99
100
  }
100
101
 
101
- const cursorValue = (row: AdminRow, field: string): string => {
102
+ /**
103
+ * The boundary's sort value as the cursor carries it. A NULL stays `null` — JSON holds one — and
104
+ * is never `''`: an empty string is a text value of its own, and the seek past a NULL boundary
105
+ * reads the NULL rows, which a seek past `''` never reaches.
106
+ */
107
+ const cursorValue = (row: AdminRow, field: string): string | null => {
102
108
  const value = row[field];
103
109
  if (value instanceof Date) return value.toISOString();
104
- return value === null || value === undefined ? '' : String(value);
110
+ return value === null || value === undefined ? null : String(value);
105
111
  };
106
112
 
107
113
  /**
@@ -3,13 +3,26 @@
3
3
  // policies and the dashboard — and no second authz implementation can grow next to it.
4
4
 
5
5
  import { type Actor, userActor } from '@ultimat3/core';
6
- import { definePermissions, evaluate, type Policy } from '@ultimat3/policy';
7
- import { type AdminActor, type AdminAuthz, type AdminDecision, allowed, denied } from './authz';
6
+ import {
7
+ can,
8
+ definePermissions,
9
+ evaluate,
10
+ isKnownPermission,
11
+ type KnownPermission,
12
+ type Permission,
13
+ type Policy,
14
+ } from '@ultimat3/policy';
15
+ import {
16
+ type AdminActor,
17
+ type AdminAuthz,
18
+ type AdminDecision,
19
+ type AdminSubject,
20
+ allowed,
21
+ denied,
22
+ impliersOf,
23
+ } from './authz';
8
24
  import { ADMIN_PERMISSIONS } from './permissions';
9
25
 
10
- /** The admin's own permission set, registered with the policy layer at import time. */
11
- export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
12
-
13
26
  /**
14
27
  * The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
15
28
  * mapping lives here because this file is the only one allowed to speak to the policy layer.
@@ -27,15 +40,109 @@ const policyActor = (actor: AdminActor): Actor =>
27
40
  function readDecision(permission: string, result: unknown): AdminDecision {
28
41
  const bag = (typeof result === 'object' && result !== null ? result : {}) as {
29
42
  allowed?: unknown;
30
- reason?: unknown;
43
+ decision?: unknown;
31
44
  trace?: unknown;
32
45
  };
46
+ // The reason lives on the evaluation's `decision`, not beside `allowed`. It was read off the top
47
+ // level, where no evaluation has ever carried one, so every refusal an operator read said
48
+ // `admin.policy.evaluated` — the same string whichever rule refused them.
49
+ const inner = (typeof bag.decision === 'object' && bag.decision !== null ? bag.decision : {}) as {
50
+ reason?: unknown;
51
+ };
33
52
  const verdict = result === true || bag.allowed === true;
34
- const reason = typeof bag.reason === 'string' ? bag.reason : 'admin.policy.evaluated';
53
+ const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
35
54
  const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
36
55
  return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
37
56
  }
38
57
 
58
+ /** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
59
+ const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
60
+
61
+ /**
62
+ * Declare the admin's OWN permissions (`ADMIN_PERMISSIONS`) and the ones a mount DERIVES —
63
+ * `<entity>:read|write|delete`, a page's, an action's — so `can()` knows them. Granting them stays
64
+ * the app's: a role map that names none of these refuses every screen. Idempotent, and this file's
65
+ * because it is the one that speaks to `@ultimat3/policy`.
66
+ *
67
+ * Called by `defineAdmin()`, never at module scope. The permission registry is permissive while
68
+ * EMPTY and closed once it holds one name, so registering `admin:*` on import closed the set for
69
+ * every module that merely shared a process with this package — an app that imported the admin
70
+ * for a type, and every test file that ran after an admin one.
71
+ */
72
+ export function declareAdminPermissions(permissions: readonly string[]): void {
73
+ definePermissions([...ADMIN_PERMISSIONS, ...permissions.filter(isPermission)]);
74
+ }
75
+
76
+ const evaluated = (
77
+ permission: string,
78
+ policy: Policy,
79
+ actor: AdminActor,
80
+ subject: AdminSubject | undefined,
81
+ ): AdminDecision => {
82
+ // `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
83
+ // that payload: a row-level rule has nothing but the entity and id to decide on.
84
+ const payload = subject?.input ?? subject;
85
+ // `row` is passed through only when the surface LOADED one. Omitting it and passing
86
+ // `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
87
+ // `null` — "no row was loaded" — rather than a value the admin invented from `input`.
88
+ const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
89
+ return readDecision(
90
+ permission,
91
+ evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
92
+ );
93
+ };
94
+
95
+ /**
96
+ * The default authz: the app's ROLE MAP decides. Every permission the admin asks about is
97
+ * `can(permission)` — "does a role this actor holds grant it" — so an app with a `defineRoles()`
98
+ * call has a working, closed admin with no permission → policy table to keep in step. An app
99
+ * whose rules read the row or the tenant passes `policyAuthz({ policies })` instead.
100
+ */
101
+ export function roleAuthz(): AdminAuthz {
102
+ return {
103
+ decide({ permission, actor, subject }): AdminDecision {
104
+ // Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
105
+ // and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
106
+ if (!isPermission(permission) || !isKnownPermission(permission)) {
107
+ return denied(permission, 'admin.policy.missing', [
108
+ `"${permission}" is not a declared resource:verb permission`,
109
+ `fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
110
+ ]);
111
+ }
112
+ // `can()` asserts the name against the registry `declareAdminPermissions` filled. The cast
113
+ // is the same one a route guard's bare string takes: the registry, not the type, is the check.
114
+ const own = evaluated(permission, can(permission as KnownPermission), actor, subject);
115
+ if (own.allowed) return own;
116
+ // The admin's implications, as `staticAuthz` applies them: a role granting `admin:destroy`
117
+ // holds `admin:write` and `admin:read` too. Without this the same grants answered two ways.
118
+ for (const implier of impliersOf(permission)) {
119
+ const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
120
+ if (carried.allowed) {
121
+ return allowed(permission, carried.reason, [
122
+ ...carried.trace,
123
+ `${permission} is implied by ${implier}`,
124
+ ]);
125
+ }
126
+ }
127
+ return own;
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * ONE policy answers every gate the admin asks — "whoever may run the app may run its admin".
134
+ * The shape a small app starts in: a single grant its role map already has, with no table of
135
+ * per-entity permissions to keep in step as entities are added. The pair is still asked
136
+ * (`admin:write` then `posts:write`), each is still audited under its own name, and the day a
137
+ * view-only operator is needed this becomes `roleAuthz()` or `policyAuthz({ policies })`.
138
+ */
139
+ export function singlePolicyAuthz(policy: Policy): AdminAuthz {
140
+ return {
141
+ decide: ({ permission, actor, subject }): AdminDecision =>
142
+ evaluated(permission, policy, actor, subject),
143
+ };
144
+ }
145
+
39
146
  export interface PolicyAuthzInput {
40
147
  /** Permission name → the policy that decides it. `describeActions()` supplies these. */
41
148
  readonly policies: Readonly<Record<string, Policy>>;
@@ -55,17 +162,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
55
162
  `fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
56
163
  ]);
57
164
  }
58
- // `EvaluateArgs` carries exactly one payload. An admin subject with no action input IS
59
- // that payload: a row-level rule has nothing but the entity and id to decide on.
60
- const payload = subject?.input ?? subject;
61
- // `row` is passed through only when the surface LOADED one. Omitting it and passing
62
- // `row: undefined` are different facts to `evaluate`, and a rule that reads `row` must see
63
- // `null` — "no row was loaded" — rather than a value the admin invented from `input`.
64
- const row = subject === undefined || !('row' in subject) ? undefined : { row: subject.row };
65
- return readDecision(
66
- permission,
67
- evaluate(policy, { actor: policyActor(actor), input: payload, ...row }),
68
- );
165
+ return evaluated(permission, policy, actor, subject);
69
166
  },
70
167
  };
71
168
  }
package/src/registry.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // is the compile-time proof that a real `entity()` result satisfies it; it is checked by
7
7
  // `tsc`, not asserted in a comment, because the admin used to read fields no entity had.
8
8
 
9
- import type { Entity, Repo } from '@ultimat3/entity';
9
+ import type { Entity, Operator, Table } from '@ultimat3/entity';
10
10
 
11
11
  /**
12
12
  * What the author declared about one column — `@ultimat3/entity`'s `ColumnMeta`, narrowed to
@@ -37,11 +37,21 @@ export interface AdminColumnMeta {
37
37
  * hands back the resolved target — but its presence is what makes the column a foreign key.
38
38
  */
39
39
  readonly references?: () => unknown;
40
+ /**
41
+ * `.sealed()`: encrypted at rest, opened on read, absent from every schema that leaves the
42
+ * server. Present means the admin derives NO readable field for it — see `entity-columns.ts`.
43
+ */
44
+ readonly sealed?: { readonly lookup: boolean };
40
45
  }
41
46
 
42
47
  /** One column of a registered entity. An `@ultimat3/entity` `Column` satisfies this. */
43
48
  export interface AdminColumn {
44
49
  readonly $meta: AdminColumnMeta;
50
+ /**
51
+ * The column's own guard. Optional in this surface only because a hand-built fixture has none;
52
+ * every `entity()` column carries it, and `repo-entity.ts` parses a URL's id through it.
53
+ */
54
+ readonly $parse?: (value: unknown) => unknown;
45
55
  }
46
56
 
47
57
  /** One column of `$describe()` output. Money is the one property that becomes two of these. */
@@ -65,6 +75,12 @@ export interface AdminEntity {
65
75
  readonly $columns: Readonly<Record<string, AdminColumn>>;
66
76
  /** The Standard Schema the entity validates with; forms hand input straight to it. */
67
77
  readonly $schema: unknown;
78
+ /**
79
+ * The column the handle scopes every read and write by, or `null`. Optional in this surface only
80
+ * because a hand-built fixture has none; every `entity()` result carries it. The admin never
81
+ * renders it as an input — the acting actor's tenant is the only value it may hold.
82
+ */
83
+ readonly $tenantColumn?: string | null;
68
84
  /** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
69
85
  $describe(): AdminEntityDescription;
70
86
  }
@@ -80,14 +96,63 @@ type Satisfies<Surface, T extends Surface> = T;
80
96
  export type RegisteredEntity = Satisfies<AdminEntity, Entity<AdminRow>>;
81
97
 
82
98
  /**
83
- * A repo as `@ultimat3/entity` registers it — deliberately NOT claimed to be an `AdminRepo`:
84
- * the verbs differ (`findMany`/`insert` vs `list`/`create`) and its cursor is an opaque signed
85
- * string where the admin speaks a keyset bound, so the host binds an adapter over it.
99
+ * The slice of a typed handle's read chain the admin drives — `database()`'s `db.<entity>`. Named
100
+ * for the reason `AdminEntity` is: one file changes when the handle grows, and `RegisteredTable`
101
+ * is the `tsc`-checked proof a real one satisfies it.
86
102
  */
87
- export type RegisteredRepo = Repo;
103
+ export interface AdminTableRead {
104
+ where(filter: Readonly<Record<string, unknown>>): AdminTableRead;
105
+ andWhere(column: string, op: Operator, value?: unknown): AdminTableRead;
106
+ orderBy(column: string, direction?: 'asc' | 'desc'): AdminTableRead;
107
+ limit(rows: number): AdminTableRead;
108
+ all(): Promise<readonly AdminRow[]>;
109
+ one(): Promise<AdminRow | null>;
110
+ count(): Promise<number>;
111
+ /** Read for the entity NAME only: it is how a handle is matched to the entity it serves. */
112
+ plan(): { readonly entity: string };
113
+ }
114
+
115
+ /** One table of the app's typed handle. Tenancy, soft delete and sealing are its own. */
116
+ export interface AdminTable extends AdminTableRead {
117
+ insert(values: Readonly<Record<string, unknown>>): Promise<AdminRow>;
118
+ update(id: string, patch: Readonly<Record<string, unknown>>): Promise<AdminRow>;
119
+ delete(id: string): Promise<void>;
120
+ }
121
+
122
+ /** The app's `database()` result, as `defineAdmin({ db })` takes it. Keys are the app's spelling. */
123
+ export type AdminDb = Readonly<Record<string, AdminTable>>;
124
+
125
+ /** The claim, checked: `database()`'s table IS an `AdminTable`. */
126
+ export type RegisteredTable = Satisfies<AdminTable, Table<AdminRow>>;
127
+
128
+ export type FilterOp =
129
+ | 'eq'
130
+ | 'neq'
131
+ | 'contains'
132
+ | 'gt'
133
+ | 'gte'
134
+ | 'lt'
135
+ | 'lte'
136
+ | 'in'
137
+ /** `value: true` keeps the rows with no value, `value: false` the rows with one. */
138
+ | 'is-null';
88
139
 
89
- export type FilterOp = 'eq' | 'neq' | 'contains' | 'gt' | 'lt' | 'in' | 'is-null';
140
+ export const FILTER_OPS: readonly FilterOp[] = [
141
+ 'eq',
142
+ 'neq',
143
+ 'contains',
144
+ 'gt',
145
+ 'gte',
146
+ 'lt',
147
+ 'lte',
148
+ 'in',
149
+ 'is-null',
150
+ ];
90
151
 
152
+ /**
153
+ * One predicate of a list read. Every list the admin issues is a CONJUNCTION of these — the URL's
154
+ * filters, the scope's, and the resource's row scope — so each maps to one indexed comparison.
155
+ */
91
156
  export interface AdminFilter {
92
157
  readonly field: string;
93
158
  readonly op: FilterOp;
@@ -101,7 +166,8 @@ export interface AdminSort {
101
166
 
102
167
  export interface KeysetBound {
103
168
  readonly field: string;
104
- readonly value: string;
169
+ /** The boundary row's sort value as text; `null` when it holds none, which sorts LAST. */
170
+ readonly value: string | null;
105
171
  readonly id: string;
106
172
  }
107
173
 
@@ -147,10 +213,33 @@ export interface AdminActionCtx {
147
213
  readonly timeZone: string;
148
214
  }
149
215
 
216
+ /** How a batch action behaves past the size a request should run inline. */
217
+ export interface AdminBatchOptions {
218
+ /**
219
+ * The most rows one request runs INLINE. A selection larger than this is handed to the queue —
220
+ * one `admin.batch` job per chunk — and the answer says how many rows were queued, not done.
221
+ */
222
+ readonly threshold: number;
223
+ /** Rows per queued job. Omitted: `threshold`. */
224
+ readonly chunk?: number;
225
+ }
226
+
227
+ /** What a set-based "all matching" answers — the shape of a store's own bulk verb. */
228
+ export interface AdminMatchingResult {
229
+ /** Rows this call changed. */
230
+ readonly affected: number;
231
+ /** Rows still matching afterwards. Run again until it is zero. */
232
+ readonly remaining: number;
233
+ }
234
+
150
235
  /**
151
236
  * A registered `action` as the admin surfaces it. `permission` is not optional: an action
152
237
  * with no policy is an open door, and the admin refuses to render one
153
238
  * (X_ADMIN_POLICY_MISSING).
239
+ *
240
+ * One declaration, every projection: the button on the detail page and on each list row, the
241
+ * action's own form, the batch bar, the MCP tool, the audit entry. `when` and `batch` narrow WHERE
242
+ * it appears and never add a second handler.
154
243
  */
155
244
  export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output = unknown> {
156
245
  readonly name: string;
@@ -158,15 +247,47 @@ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output =
158
247
  /** The entity the button belongs to. Absent = a global action in the toolbar. */
159
248
  readonly entity?: string;
160
249
  readonly destructive?: boolean;
250
+ /** Absent: `admin.action.<name>` — `actionLabelKey`, the one spelling every screen reads. */
161
251
  readonly labelKey?: string;
162
252
  /** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
163
253
  readonly mcp?: { readonly expose?: boolean; readonly description?: string };
164
- /** The action's Standard Schema, handed to the form and to the MCP tool definition. */
254
+ /**
255
+ * The action's input schema — a `t.object({ … })`. Rendered as the action's form (one control
256
+ * per property), validated before the handler runs with each issue shown against its field,
257
+ * and handed to the MCP tool definition. Absent: the action takes no input, and its button is a
258
+ * confirm only. The row's `id` is the admin's own envelope and is never a property here.
259
+ */
165
260
  readonly input?: unknown;
261
+ /**
262
+ * Whether the action applies to THIS row. Decides the button on the detail page and on each
263
+ * list row, and is evaluated again on the server before the handler runs — a hidden button is
264
+ * not an authorization: `X_ADMIN_ACTION_NOT_APPLICABLE`. The row it reads carries no sealed
265
+ * column. Absent: the action applies to every row.
266
+ */
267
+ readonly when?: (row: AdminRow) => boolean;
268
+ /**
269
+ * In the list's batch bar: run once per selected row through the same gate as the button, each
270
+ * row audited, the answer counting done, refused and failed. `true` runs every batch inline;
271
+ * `{ threshold }` queues a selection larger than that as one job per chunk.
272
+ */
273
+ readonly batch?: true | AdminBatchOptions;
274
+ /**
275
+ * The batch bar's "all matching" as ONE set-based call instead of a walk of rows: handed the
276
+ * list's own `where` — row scope, then scope, then filters — it changes what matches and answers
277
+ * how many it changed and how many still match. For a store with a bounded bulk verb of its own
278
+ * (the jobs operator's `requeueMany`). Decided and audited once, for the set; the per-row gate is
279
+ * not asked per row, so the verb's own predicate is where `when` holds. A checked selection
280
+ * still runs row by row through `handle`. Requires `batch`.
281
+ */
282
+ readonly matching?: (args: {
283
+ readonly where: readonly AdminFilter[];
284
+ readonly input: Input;
285
+ readonly ctx: AdminActionCtx;
286
+ }) => Promise<AdminMatchingResult>;
166
287
  handle(args: { input: Input; ctx: AdminActionCtx }): Promise<Output>;
167
288
  }
168
289
 
169
- /** A registered `job` as the admin's Jobs page needs it — `describeJobs()` output. */
290
+ /** A registered `job` as an AI pane is told about it (`ai-panes.ts`) — `describeJobs()` output. */
170
291
  export interface AdminJobSummary {
171
292
  readonly name: string;
172
293
  readonly queue?: string;
@@ -185,3 +306,13 @@ export function rowId(row: AdminRow, idField: string): string {
185
306
  const value = row[idField];
186
307
  return typeof value === 'string' ? value : String(value ?? '');
187
308
  }
309
+
310
+ /**
311
+ * A row as something DRAWN or DECIDED on reads it: its enumerable values, minus every sealed
312
+ * column. A repository row keeps a sealed property off its enumerable set already; a hand-written
313
+ * `repo:` may not, so the names are dropped explicitly — a computed cell is rendered and an
314
+ * action's `when` is evaluated per row, and a sealed value reaches neither.
315
+ */
316
+ export function computedRow(row: AdminRow, sealed: readonly string[]): AdminRow {
317
+ return Object.fromEntries(Object.entries(row).filter(([key]) => !sealed.includes(key)));
318
+ }
package/src/related.ts ADDED
@@ -0,0 +1,105 @@
1
+ // A row's RELATED rows: the `hasMany` relations a resource names in `related`, each read as the
2
+ // related resource's own list — its gate, its row scope, its columns — narrowed to the rows that
3
+ // point at this one. No second table is declared anywhere: the related resource already is one.
4
+
5
+ import { relationsFor as entityRelationsFor } from '@ultimat3/entity';
6
+ import { adminList, type CrudCtx, canOperate } from './crud';
7
+ import { AdminFieldUnsupportedError } from './errors';
8
+ import type { AdminPage } from './pagination';
9
+ import type { AdminFilter, AdminRow } from './registry';
10
+ import type { AdminResource } from './resource';
11
+
12
+ /** How many related rows a detail page shows per relation. The rest are one link away. */
13
+ export const RELATED_PAGE_SIZE = 10;
14
+
15
+ /** One declared relation, resolved against the admin it is declared in. */
16
+ export interface AdminRelated {
17
+ /** The relation's name on the entity — what `related: [...]` spelled. */
18
+ readonly name: string;
19
+ /** The resource whose rows these are. */
20
+ readonly resource: AdminResource;
21
+ /** The property of THAT resource holding the foreign key. */
22
+ readonly field: string;
23
+ /** The property of the row in hand the foreign key points at. */
24
+ readonly localKey: string;
25
+ }
26
+
27
+ /**
28
+ * The `related` names of one resource, resolved. Every name is a `hasMany` of the entity whose
29
+ * target is a resource of this admin — anything else is refused here, where it is declared, never
30
+ * drawn as an empty card.
31
+ */
32
+ export function relatedOf(
33
+ resources: readonly AdminResource[],
34
+ resource: AdminResource,
35
+ ): readonly AdminRelated[] {
36
+ if (resource.related.length === 0) return [];
37
+ // The one read of `@ultimat3/entity`'s relations in this package: the FK already written IS
38
+ // the relation, so the admin derives nothing of its own and names nothing twice.
39
+ const relations = entityRelationsFor(resource.name);
40
+ const offered = Object.values(relations)
41
+ .filter((relation) => relation.kind === 'hasMany')
42
+ .map((relation) => relation.name);
43
+ const hint = `resources.${resource.name}.related may name: ${offered.length > 0 ? offered.join(', ') : 'nothing — no entity references this one'}`;
44
+
45
+ return resource.related.map((name) => {
46
+ const refuse = (cause: string, fix: string): never => {
47
+ throw new AdminFieldUnsupportedError({ entity: resource.name, field: name, cause, fix });
48
+ };
49
+ const relation = Object.hasOwn(relations, name) ? relations[name] : undefined;
50
+ if (relation === undefined) {
51
+ return refuse('named in related and is not a relation of the entity', hint);
52
+ }
53
+ if (relation.kind !== 'hasMany') {
54
+ return refuse(
55
+ `named in related and is a belongsTo: the "${relation.localKey}" field already links to its row`,
56
+ hint,
57
+ );
58
+ }
59
+ const target = resources.find((known) => known.name === relation.to);
60
+ if (target === undefined) {
61
+ return refuse(
62
+ `named in related, and its rows are "${relation.to}", which is not a resource of this admin`,
63
+ `add the ${relation.to} entity to defineAdmin({ entities }) — its own list is what related draws`,
64
+ );
65
+ }
66
+ return { name, resource: target, field: relation.remoteKey, localKey: relation.localKey };
67
+ });
68
+ }
69
+
70
+ /** One related list as the detail page draws it. */
71
+ export interface AdminRelatedList {
72
+ readonly related: AdminRelated;
73
+ readonly page: AdminPage<AdminRow>;
74
+ /** The one predicate that makes this list THIS row's. */
75
+ readonly filter: AdminFilter;
76
+ }
77
+
78
+ /**
79
+ * The related lists of one row — one statement each, read beside each other. A related resource
80
+ * this actor may not list is LEFT OUT, silently: they asked for this row, and the refusal they
81
+ * would get by opening that list is logged there. Everything else is `adminList`: the related
82
+ * resource's policy decides, its `rows` narrows, and the read is audited under its own name.
83
+ */
84
+ export async function relatedLists(
85
+ related: readonly AdminRelated[],
86
+ row: AdminRow,
87
+ ctx: CrudCtx,
88
+ ): Promise<readonly AdminRelatedList[]> {
89
+ const readable = related.filter((one) => canOperate(one.resource, 'list', ctx));
90
+ const read = await Promise.all(
91
+ readable.map(async (one): Promise<AdminRelatedList | null> => {
92
+ const key = row[one.localKey];
93
+ if (key === null || key === undefined) return null;
94
+ const filter: AdminFilter = { field: one.field, op: 'eq', value: String(key) };
95
+ const result = await adminList<AdminRow>(one.resource, ctx, {
96
+ // No scope, not even the default one: a related row counts whatever tab it sits under.
97
+ scope: null,
98
+ filters: [filter],
99
+ limit: RELATED_PAGE_SIZE,
100
+ });
101
+ return result.ok ? { related: one, page: result.page, filter } : null;
102
+ }),
103
+ );
104
+ return read.filter((one): one is AdminRelatedList => one !== null);
105
+ }