@ultimat3/admin 23.0.0 → 25.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 (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
package/src/mcp.ts CHANGED
@@ -3,13 +3,7 @@
3
3
  // confirmation rules as the buttons — this file adds a transport, not a second back door.
4
4
 
5
5
  import type { Actor } from '@ultimat3/core';
6
- import {
7
- agentActor,
8
- createContext,
9
- hasContext,
10
- runWithContext,
11
- withChildContext,
12
- } from '@ultimat3/core';
6
+ import { agentActor, ctxOf, hasContext, runWithContext, withChildContext } from '@ultimat3/core';
13
7
  import {
14
8
  type AnyMcpTool,
15
9
  type AppMcp,
@@ -39,6 +33,7 @@ import {
39
33
  import { AdminFilterInvalidError } from './errors';
40
34
  import type { AdminFieldType } from './fields';
41
35
  import { type AskedFilter, checkedFilter } from './list-filters';
36
+ import { adminTokenScopes, adminToolScopes } from './mcp-scopes';
42
37
  import { type AdminMcpTool, adminMcpTools, adminToolCatalog } from './mcp-tools';
43
38
  import type { AdminAction, AdminRow } from './registry';
44
39
  import { adminSearch } from './search';
@@ -234,13 +229,29 @@ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
234
229
  if (result.kind === 'missing') {
235
230
  return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
236
231
  }
232
+ // Reached only by a caller that sent a version; the update tool sends none.
233
+ if (result.kind === 'stale') {
234
+ return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
235
+ }
237
236
  return invalidIssues(result.issues);
238
237
  }
239
238
 
239
+ /**
240
+ * What a session token resolves to: the actor, and optionally the admin scopes the TOKEN was
241
+ * issued for — narrower than the actor, never wider (the actor's policy still decides each tool).
242
+ * `['admin:read']` is a read-only token: list, read, search and `readonly` actions. Absent: the
243
+ * token is as strong as its actor (`adminTokenScopes`). Named `tokenScopes`, not `scopes`, because
244
+ * core's `Actor.scopes` is a different thing (framework capabilities) and an actor handed straight
245
+ * through must not have its `[]` read as "this token may do nothing".
246
+ */
247
+ export interface AdminMcpActor extends AdminActor {
248
+ readonly tokenScopes?: readonly string[];
249
+ }
250
+
240
251
  export interface AdminMcpOptions {
241
252
  readonly app: AdminApp;
242
253
  /** Resolve the MCP session's actor. The same hook the HTTP surface uses, never a bypass. */
243
- actor(session: { readonly token?: string }): Promise<AdminActor | null> | AdminActor | null;
254
+ actor(session: { readonly token?: string }): Promise<AdminMcpActor | null> | AdminMcpActor | null;
244
255
  readonly requestId?: () => string;
245
256
  }
246
257
 
@@ -370,7 +381,7 @@ function allowedToolNames(
370
381
  const asCaller = <T>(actor: Actor, requestId: string, run: () => Promise<T>): Promise<T> =>
371
382
  hasContext()
372
383
  ? withChildContext({ actor }, run)
373
- : runWithContext(createContext({ actor, requestId }), run);
384
+ : runWithContext(ctxOf({ actor, requestId }), run);
374
385
 
375
386
  function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMcpTool): AnyMcpTool {
376
387
  return {
@@ -421,10 +432,15 @@ function toMcpTool(opts: AdminMcpOptions, requestId: () => string, tool: AdminMc
421
432
  */
422
433
  export function adminMcp(opts: AdminMcpOptions): AppMcp {
423
434
  const requestId = opts.requestId ?? ((): string => crypto.randomUUID());
435
+ const catalog = adminToolCatalog(opts.app);
424
436
 
425
437
  return defineAppMcp({
426
438
  name: 'admin',
427
- tools: adminToolCatalog(opts.app).map((tool) => toMcpTool(opts, requestId, tool)),
439
+ tools: catalog.map((tool) => toMcpTool(opts, requestId, tool)),
440
+ // The connection gate: each tool needs its admin-level permission as a token scope, checked
441
+ // by the registry BEFORE the actor's policy. A stdio host builds its caller's scopes with
442
+ // `adminTokenScopes`, as `resolveToken` does below.
443
+ scopes: adminToolScopes(catalog),
428
444
  async resolveToken(token: string): Promise<ResolvedToken | null> {
429
445
  const actor = await opts.actor({ token });
430
446
  // `kind: 'agent'` — the same actor shape an agent gets everywhere else, so a policy
@@ -438,7 +454,7 @@ export function adminMcp(opts: AdminMcpOptions): AppMcp {
438
454
  ? null
439
455
  : {
440
456
  actor: agentActor({ id: actor.id, roles: actor.roles ?? [], orgId: actor.orgId }),
441
- scopes: new Set(),
457
+ scopes: adminTokenScopes(actor.tokenScopes),
442
458
  };
443
459
  },
444
460
  });
package/src/pagination.ts CHANGED
@@ -3,14 +3,16 @@
3
3
  // otherwise skip and repeat rows, and every page would re-scan everything before it.
4
4
  // The position itself is encoded by `@ultimat3/core` — one signed cursor format, framework-wide.
5
5
 
6
- import { decodeCursor, encodeCursor, isUltimateError } from '@ultimat3/core';
6
+ import type { Page } from '@ultimat3/core';
7
+ import { decodeCursor, encodeCursor, isUltimateError, pageOf } from '@ultimat3/core';
7
8
  import type { AdminFilter, AdminListQuery, AdminRow, AdminSort } from './registry';
8
9
  import { rowId } from './registry';
9
10
  import { type AdminResource, repoOf } from './resource';
10
11
 
11
12
  export interface AdminCursor {
12
13
  readonly field: string;
13
- readonly value: string;
14
+ /** The boundary row's sort value as text, or `null` when it holds none — a position too. */
15
+ readonly value: string | null;
14
16
  readonly id: string;
15
17
  readonly direction: 'after' | 'before';
16
18
  }
@@ -50,7 +52,7 @@ export function decodeAdminCursor(
50
52
  if (payload.key.length !== 3) return null;
51
53
  const [direction, field, value] = payload.key;
52
54
  if (direction !== 'after' && direction !== 'before') return null;
53
- if (typeof field !== 'string' || typeof value !== 'string') return null;
55
+ if (typeof field !== 'string' || (typeof value !== 'string' && value !== null)) return null;
54
56
  return { direction, field, value, id: payload.id };
55
57
  } catch (error) {
56
58
  if (isUltimateError(error) && error.code === 'X_CURSOR_INVALID') return null;
@@ -65,15 +67,16 @@ export interface PageRequest {
65
67
  readonly where?: readonly AdminFilter[];
66
68
  }
67
69
 
68
- export interface AdminPage<Row extends AdminRow> {
69
- readonly rows: readonly Row[];
70
+ /**
71
+ * `@ultimat3/core`'s `Page` — `hasMore` (a page exists AFTER this one, what the Next control is
72
+ * enabled by, in both directions) and `nextCursor` (`null` exactly when it is false) — plus what
73
+ * only a screen pages by: the sort, the size, and the cursor that walks BACK.
74
+ */
75
+ export type AdminPage<Row extends AdminRow> = Page<Row> & {
70
76
  readonly sort: AdminSort;
71
77
  readonly pageSize: number;
72
- readonly nextCursor: string | null;
73
78
  readonly prevCursor: string | null;
74
- /** A page exists AFTER this one — what the Next control is enabled by, in both directions. */
75
- readonly hasMore: boolean;
76
- }
79
+ };
77
80
 
78
81
  /** The repo query for one page. Asks for `limit + 1` to learn whether a next page exists. */
79
82
  export function listQuery<Row extends AdminRow>(
@@ -98,10 +101,15 @@ export function listQuery<Row extends AdminRow>(
98
101
  };
99
102
  }
100
103
 
101
- const cursorValue = (row: AdminRow, field: string): string => {
104
+ /**
105
+ * The boundary's sort value as the cursor carries it. A NULL stays `null` — JSON holds one — and
106
+ * is never `''`: an empty string is a text value of its own, and the seek past a NULL boundary
107
+ * reads the NULL rows, which a seek past `''` never reaches.
108
+ */
109
+ const cursorValue = (row: AdminRow, field: string): string | null => {
102
110
  const value = row[field];
103
111
  if (value instanceof Date) return value.toISOString();
104
- return value === null || value === undefined ? '' : String(value);
112
+ return value === null || value === undefined ? null : String(value);
105
113
  };
106
114
 
107
115
  /**
@@ -141,20 +149,22 @@ export function pageFrom<Row extends AdminRow>(
141
149
  const hasMore = backwards ? true : overflow;
142
150
  const hasPrevious = backwards ? overflow : incoming !== null;
143
151
 
144
- return {
152
+ // `pageOf` derives `hasMore` from the cursor, so Next is enabled exactly when it leads somewhere.
153
+ const next = pageOf(
145
154
  rows,
155
+ hasMore && last !== undefined
156
+ ? encodeAdminCursor(resource, {
157
+ direction: 'after',
158
+ field: sort.field,
159
+ value: cursorValue(last, sort.field),
160
+ id: rowId(last, resource.idField),
161
+ })
162
+ : null,
163
+ );
164
+ return {
165
+ ...next,
146
166
  sort,
147
167
  pageSize,
148
- hasMore,
149
- nextCursor:
150
- hasMore && last !== undefined
151
- ? encodeAdminCursor(resource, {
152
- direction: 'after',
153
- field: sort.field,
154
- value: cursorValue(last, sort.field),
155
- id: rowId(last, resource.idField),
156
- })
157
- : null,
158
168
  prevCursor:
159
169
  hasPrevious && first !== undefined
160
170
  ? encodeAdminCursor(resource, {
@@ -17,15 +17,12 @@ import {
17
17
  type AdminAuthz,
18
18
  type AdminDecision,
19
19
  type AdminSubject,
20
- allowed,
21
- denied,
20
+ adminAllowed,
21
+ adminDenied,
22
22
  impliersOf,
23
23
  } from './authz';
24
24
  import { ADMIN_PERMISSIONS } from './permissions';
25
25
 
26
- /** The admin's own permission set, registered with the policy layer at import time. */
27
- export const adminPermissions = definePermissions(ADMIN_PERMISSIONS);
28
-
29
26
  /**
30
27
  * The admin carries its own actor shape; @ultimat3/policy evaluates core's `Actor`. The
31
28
  * mapping lives here because this file is the only one allowed to speak to the policy layer.
@@ -55,20 +52,25 @@ function readDecision(permission: string, result: unknown): AdminDecision {
55
52
  const verdict = result === true || bag.allowed === true;
56
53
  const reason = typeof inner.reason === 'string' ? inner.reason : 'admin.policy.evaluated';
57
54
  const trace = Array.isArray(bag.trace) ? bag.trace.map((line) => String(line)) : [];
58
- return verdict ? allowed(permission, reason, trace) : denied(permission, reason, trace);
55
+ return verdict ? adminAllowed(permission, reason, trace) : adminDenied(permission, reason, trace);
59
56
  }
60
57
 
61
58
  /** `resource:verb` — the only shape `can()` takes. Anything else is denied, never thrown. */
62
59
  const isPermission = (value: string): value is Permission => /^[^:]+:[^:]+$/.test(value);
63
60
 
64
61
  /**
65
- * Declare the permissions an admin DERIVES — `<entity>:read|write|delete`, a page's, an action's —
66
- * so `can()` knows them. Granting them stays the app's: a role map that names none of these
67
- * refuses every screen. Idempotent, and this file's because it is the one that speaks to
68
- * `@ultimat3/policy`.
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.
69
71
  */
70
72
  export function declareAdminPermissions(permissions: readonly string[]): void {
71
- definePermissions(permissions.filter(isPermission));
73
+ definePermissions([...ADMIN_PERMISSIONS, ...permissions.filter(isPermission)]);
72
74
  }
73
75
 
74
76
  const evaluated = (
@@ -102,7 +104,7 @@ export function roleAuthz(): AdminAuthz {
102
104
  // Asked BEFORE `can()`, which throws on a name the registry lacks: a decision is an answer,
103
105
  // and an undeclared permission is a refusal with its fix, never a 500 out of a nav render.
104
106
  if (!isPermission(permission) || !isKnownPermission(permission)) {
105
- return denied(permission, 'admin.policy.missing', [
107
+ return adminDenied(permission, 'admin.policy.missing', [
106
108
  `"${permission}" is not a declared resource:verb permission`,
107
109
  `fix: definePermissions(['${permission}']), then grant it to a role in defineRoles()`,
108
110
  ]);
@@ -116,7 +118,7 @@ export function roleAuthz(): AdminAuthz {
116
118
  for (const implier of impliersOf(permission)) {
117
119
  const carried = evaluated(implier, can(implier as KnownPermission), actor, subject);
118
120
  if (carried.allowed) {
119
- return allowed(permission, carried.reason, [
121
+ return adminAllowed(permission, carried.reason, [
120
122
  ...carried.trace,
121
123
  `${permission} is implied by ${implier}`,
122
124
  ]);
@@ -155,7 +157,7 @@ export function policyAuthz(input: PolicyAuthzInput): AdminAuthz {
155
157
  decide({ permission, actor, subject }): AdminDecision {
156
158
  const policy = input.policies[permission];
157
159
  if (policy === undefined) {
158
- return denied(permission, 'admin.policy.missing', [
160
+ return adminDenied(permission, 'admin.policy.missing', [
159
161
  `no policy registered for "${permission}"`,
160
162
  `fix: definePermissions({ '${permission}': … }) or can('${permission}') on the action`,
161
163
  ]);
package/src/registry.ts CHANGED
@@ -166,7 +166,8 @@ export interface AdminSort {
166
166
 
167
167
  export interface KeysetBound {
168
168
  readonly field: string;
169
- 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;
170
171
  readonly id: string;
171
172
  }
172
173
 
@@ -246,6 +247,13 @@ export interface AdminAction<Input = Readonly<Record<string, unknown>>, Output =
246
247
  /** The entity the button belongs to. Absent = a global action in the toolbar. */
247
248
  readonly entity?: string;
248
249
  readonly destructive?: boolean;
250
+ /**
251
+ * The action changes nothing — an export, a verification, a recount — so its admin-level gate is
252
+ * `admin:read` rather than `admin:write`, and a read-only staff role (or an `admin:read` MCP
253
+ * token) may run it. `destructive` and `matching` (a set-based write) win over it: the stricter
254
+ * gate is the one that applies.
255
+ */
256
+ readonly readonly?: boolean;
249
257
  /** Absent: `admin.action.<name>` — `actionLabelKey`, the one spelling every screen reads. */
250
258
  readonly labelKey?: string;
251
259
  /** Mirrors the action's own `mcp` block; the admin MCP surface honours `expose`. */
package/src/related.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // related resource's own list — its gate, its row scope, its columns — narrowed to the rows that
3
3
  // point at this one. No second table is declared anywhere: the related resource already is one.
4
4
 
5
- import { relationsFor as entityRelationsFor } from '@ultimat3/entity';
5
+ import { relationsFor } from '@ultimat3/entity';
6
6
  import { adminList, type CrudCtx, canOperate } from './crud';
7
7
  import { AdminFieldUnsupportedError } from './errors';
8
8
  import type { AdminPage } from './pagination';
@@ -36,7 +36,7 @@ export function relatedOf(
36
36
  if (resource.related.length === 0) return [];
37
37
  // The one read of `@ultimat3/entity`'s relations in this package: the FK already written IS
38
38
  // the relation, so the admin derives nothing of its own and names nothing twice.
39
- const relations = entityRelationsFor(resource.name);
39
+ const relations = relationsFor(resource.name);
40
40
  const offered = Object.values(relations)
41
41
  .filter((relation) => relation.kind === 'hasMany')
42
42
  .map((relation) => relation.name);
package/src/relations.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  // page, never one per row and never one per cell.
5
5
 
6
6
  import { finiteCount } from '@ultimat3/core';
7
+ import { expectedQueryLoop } from '@ultimat3/db';
7
8
  import type { AuditEntry } from './audit';
8
9
  import type { AdminDecision } from './authz';
9
10
  import { type CrudCtx, decideOperation } from './crud';
@@ -26,7 +27,11 @@ export interface AdminOption {
26
27
  */
27
28
  export const LOOKUP_SELECT_MAX = 50;
28
29
 
29
- /** The most ids one label read names — the list's own page-size ceiling. */
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
+ */
30
35
  const MAX_LABEL_IDS = 200;
31
36
 
32
37
  /** The row's label-field value when it is text, else its id — never a blank cell. */
@@ -140,7 +145,7 @@ async function relationOf(
140
145
  const visible = rowWhere(resource, ctx.actor);
141
146
  const labels = new Map<string, string>();
142
147
  let options: readonly AdminOption[] | null = null;
143
- let wanted = [...need.ids].slice(0, MAX_LABEL_IDS);
148
+ let wanted = [...need.ids];
144
149
 
145
150
  if (need.pick) {
146
151
  // One over the ceiling: the extra row is how "small enough for a select" is learned without
@@ -162,14 +167,25 @@ async function relationOf(
162
167
  }
163
168
  }
164
169
 
165
- if (wanted.length > 0) {
166
- const rows = await repo.list({
167
- where: [...visible, { field: resource.idField, op: 'in', value: wanted }],
168
- sort: resource.defaultSort,
169
- limit: wanted.length,
170
- });
171
- for (const row of rows) labels.set(rowId(row, resource.idField), labelOf(row, resource));
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));
172
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
+ );
173
189
 
174
190
  await readEntry(resource, ctx, decision);
175
191
  return { labels, options };
@@ -180,7 +196,7 @@ async function relationOf(
180
196
  * per target — two for a picked target too large for a `<select>` — whatever the page's row count
181
197
  * and however many columns point at the same target.
182
198
  */
183
- export async function relationsFor(
199
+ export async function relationDataFor(
184
200
  resources: readonly AdminResource[],
185
201
  ctx: CrudCtx,
186
202
  needs: ReadonlyMap<string, RelationNeed>,
@@ -5,6 +5,7 @@
5
5
  import { isUltimateError } from '@ultimat3/core';
6
6
  import { getEntity, invariantViolated } from '@ultimat3/entity';
7
7
  import { adminColumnsOf } from './entity-columns';
8
+ import { seekPage } from './keyset-seek';
8
9
  import type {
9
10
  AdminDb,
10
11
  AdminEntity,
@@ -41,18 +42,21 @@ const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\$
41
42
  * A comparison value, in the shape the column holds. An instant travels as ISO text — in a URL, in
42
43
  * a cursor — and the handle is given the `Date`: Postgres would cast the text, the memory driver
43
44
  * compares a `Date` to a string by its characters, and a keyset over `createdAt` (the admin's
44
- * default sort) then answers page one for every page.
45
+ * 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.
45
47
  */
46
48
  type Typed = (field: string, value: unknown) => unknown;
47
49
 
48
50
  const typedBy = (entity: AdminEntity): Typed => {
49
- const instants = new Set(
50
- adminColumnsOf(entity)
51
- .filter((column) => column.kind === 'timestamptz')
52
- .map((column) => column.name),
53
- );
51
+ const kinds = new Map(adminColumnsOf(entity).map((column) => [column.name, column.kind]));
54
52
  return (field, value) => {
55
- if (typeof value !== 'string' || !instants.has(field)) return 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;
56
60
  const at = new Date(value);
57
61
  return Number.isNaN(at.getTime()) ? value : at;
58
62
  };
@@ -81,37 +85,6 @@ const filtered = (
81
85
  typed: Typed,
82
86
  ): AdminTableRead => where.reduce(predicate(typed), read);
83
87
 
84
- /**
85
- * `after`+asc and `before`+desc walk forward; the other two walk back. Written as one XOR rather
86
- * than a four-branch table, because the four branches are the same fact said twice.
87
- *
88
- * `gte`/`lte`, not `gt`/`lt`: rows sharing the boundary's sort value are still on the far side of
89
- * it, and `dropThroughTie` below is what removes the ones already served.
90
- */
91
- const sought = (read: AdminTableRead, query: AdminListQuery, typed: Typed): AdminTableRead => {
92
- const bound = query.after ?? query.before;
93
- if (bound === undefined) return read;
94
- const forward = (query.after !== undefined) !== (query.sort.direction === 'desc');
95
- // The bound is the STRING the cursor carried (`pagination.ts` writes a Date as ISO).
96
- return read.andWhere(bound.field, forward ? 'gte' : 'lte', typed(bound.field, bound.value));
97
- };
98
-
99
- /**
100
- * Drop everything up to and including the cursor row. A typed chain cannot express the row-value
101
- * comparison `(sort, id) > (value, id)` a keyset needs, and the alternative — a strict `gt` on the
102
- * sort column alone — silently skips every row that ties with the boundary.
103
- */
104
- const dropThroughTie = (
105
- rows: readonly AdminRow[],
106
- query: AdminListQuery,
107
- idField: string,
108
- ): readonly AdminRow[] => {
109
- const bound = query.after ?? query.before;
110
- if (bound === undefined) return rows;
111
- const at = rows.findIndex((row) => String(row[idField]) === bound.id);
112
- return at === -1 ? rows : rows.slice(at + 1);
113
- };
114
-
115
88
  /**
116
89
  * One entity + its table → one `AdminRepo`.
117
90
  *
@@ -124,6 +97,7 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
124
97
  const idField = entity.$primaryKey[0] ?? 'id';
125
98
  const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
126
99
  const typed = typedBy(entity);
100
+ const columns = adminColumnsOf(entity);
127
101
  /**
128
102
  * `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
129
103
  * OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
@@ -152,14 +126,31 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
152
126
 
153
127
  return {
154
128
  async list(query: AdminListQuery): Promise<readonly AdminRow[]> {
155
- const rows = await sought(filtered(table, query.where ?? [], typed), query, typed)
156
- .orderBy(query.sort.field, query.sort.direction)
157
- // The tie-break, always: a page boundary on a partial order repeats or drops a row.
158
- .orderBy(idField, query.sort.direction)
159
- // One over the requested page so the tie-drop cannot hand back a short page.
160
- .limit(query.limit + 1)
161
- .all();
162
- return dropThroughTie(rows, query, idField).slice(0, query.limit);
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
+ );
163
154
  },
164
155
  // `async`, so a refused id arrives as a rejection: these return promises, and a caller that
165
156
  // chains `.catch()` instead of `await`ing would never see a synchronous throw.
@@ -94,6 +94,12 @@ interface Declared {
94
94
  readonly entity: AdminEntity;
95
95
  }
96
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
+
97
103
  /** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
98
104
  export function scopesOf(
99
105
  resource: Declared,
@@ -105,6 +111,9 @@ export function scopesOf(
105
111
  const refuse = (asked: string, cause: string): never => {
106
112
  throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
107
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
+ }
108
117
  const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
109
118
  if (defaults.length > 1) {
110
119
  refuse(
package/src/resource.ts CHANGED
@@ -69,6 +69,13 @@ export interface AdminResourceOptions<Row extends AdminRow = AdminRow> {
69
69
  * At most one `default: true`; `count: true` is one extra query per tab that asks for it.
70
70
  */
71
71
  readonly scopes?: Readonly<Record<string, AdminScopeOptions>>;
72
+ /**
73
+ * Show this resource's row count on the `/admin` home. Opt-in, as a scope tab's `count` is: the
74
+ * figure is one full `count()` over the actor's rows on EVERY visit to the front page, so on a
75
+ * big table any operator reloading it is a load generator. Absent: no tile, the resource is still
76
+ * listed in the permission matrix.
77
+ */
78
+ readonly count?: boolean;
72
79
  /**
73
80
  * The rows this ACTOR may see at all. Applied to every read of the resource — list, search,
74
81
  * detail, lookup, labels, MCP — so it is the one place a second audience is declared.
@@ -134,6 +141,8 @@ export interface AdminResource<Row extends AdminRow = AdminRow> {
134
141
  readonly columns: readonly AdminComputedColumn[];
135
142
  /** Declared scopes, in tab order. Empty for a resource that declares none. */
136
143
  readonly scopes: readonly AdminScope[];
144
+ /** The `/admin` home counts this resource — `count: true` declared. */
145
+ readonly count: boolean;
137
146
  /** `rows`, validated per call. Absent for a resource every permitted actor sees whole. */
138
147
  readonly rowScope?: AdminRowScope;
139
148
  /** The detail page's groups. Always at least one: undeclared fields have a default section. */
@@ -309,6 +318,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
309
318
  searchFields: fields.filter((field) => field.searchable),
310
319
  columns: computedColumnsOf(declared, opts.columns),
311
320
  scopes: scopesOf(declared, opts.scopes, opts.repo),
321
+ count: opts.count === true,
312
322
  ...(rowScope === undefined ? {} : { rowScope }),
313
323
  sections: layoutOf(
314
324
  {
@@ -0,0 +1,20 @@
1
+ // The version an edit form is rendered against: a keyed fingerprint of the row as it was READ,
2
+ // posted back in a hidden field, so `adminUpdate` can tell "the row I was shown" from "the row as
3
+ // it is now" and refuse a write that would silently overwrite a concurrent edit.
4
+
5
+ import { keyedFingerprint } from '@ultimat3/core';
6
+ import type { AdminRow } from './registry';
7
+
8
+ /** The posted name of the version, beside `_operation`: never a column, never an input field. */
9
+ export const VERSION_FIELD = '_version';
10
+
11
+ /** The key a write against a stale version is refused and audited with. */
12
+ export const ROW_CHANGED_REASON = 'admin.error.row-changed';
13
+
14
+ /**
15
+ * Every value the row enumerates — a sealed one is non-enumerable and is not in it, so a password
16
+ * change is not a "concurrent edit" and no sealed value is ever folded into page text. Keyed: the
17
+ * page carries it, and an unkeyed hash of a row is an offline oracle for a short value in a column
18
+ * the form does not draw.
19
+ */
20
+ export const rowVersion = (row: AdminRow): string => keyedFingerprint(row, 'admin.row-version');