@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.
- package/CLAUDE.md +32 -30
- package/README.md +51 -16
- package/package.json +17 -17
- package/src/action-gate.ts +33 -42
- package/src/admin.module.scss +118 -4
- package/src/audit-pg.ts +2 -2
- package/src/audit.ts +1 -3
- package/src/authz.ts +8 -8
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +11 -51
- package/src/crud-input.ts +2 -1
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/describe.ts +6 -0
- package/src/dev/data.ts +5 -8
- package/src/dev/dev-t.ts +9 -0
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/jobs-state-chart.tsx +38 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-jobs.ts +10 -5
- package/src/dev/panel-timeline.ts +16 -3
- package/src/dev/panel.ts +10 -5
- package/src/dev/server.ts +43 -17
- package/src/dev/sql-runner.ts +31 -0
- package/src/dev/timeline-style.ts +47 -0
- package/src/dev/timeline-waterfall.ts +96 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +55 -5
- package/src/form.tsx +19 -5
- package/src/index.ts +11 -9
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +5 -5
- package/src/jobs/overview-tab.tsx +2 -2
- package/src/keyset-seek.ts +189 -0
- package/src/layout.tsx +34 -8
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/list.tsx +1 -1
- package/src/mcp-scopes.ts +36 -0
- package/src/mcp-tools.ts +16 -2
- package/src/mcp.ts +27 -11
- package/src/pagination.ts +32 -22
- package/src/policy-bridge.ts +16 -14
- package/src/registry.ts +9 -1
- package/src/related.ts +2 -2
- package/src/relations.ts +26 -10
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/resource.ts +10 -0
- package/src/row-version.ts +20 -0
- package/src/screen-home-access.tsx +117 -0
- package/src/screen-home-counts.ts +42 -0
- package/src/screen-home-kpis.tsx +64 -0
- package/src/screen-home.module.scss +169 -0
- package/src/screen-home.tsx +21 -82
- package/src/screen-lookup.tsx +1 -1
- package/src/screen-resource.tsx +60 -12
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +12 -4
- package/src/widgets.tsx +2 -6
- 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<
|
|
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(
|
|
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:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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, {
|
package/src/policy-bridge.ts
CHANGED
|
@@ -17,15 +17,12 @@ import {
|
|
|
17
17
|
type AdminAuthz,
|
|
18
18
|
type AdminDecision,
|
|
19
19
|
type AdminSubject,
|
|
20
|
-
|
|
21
|
-
|
|
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 ?
|
|
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
|
|
66
|
-
* so `can()` knows them. Granting them stays
|
|
67
|
-
* refuses every screen. Idempotent, and this file's
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
/**
|
|
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]
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
199
|
+
export async function relationDataFor(
|
|
184
200
|
resources: readonly AdminResource[],
|
|
185
201
|
ctx: CrudCtx,
|
|
186
202
|
needs: ReadonlyMap<string, RelationNeed>,
|
package/src/repo-entity.ts
CHANGED
|
@@ -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
|
|
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'
|
|
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
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
package/src/resource-list.ts
CHANGED
|
@@ -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');
|