@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/admin.module.scss
CHANGED
|
@@ -12,27 +12,125 @@
|
|
|
12
12
|
background: t.role('bg');
|
|
13
13
|
color: t.role('fg');
|
|
14
14
|
|
|
15
|
-
// One column under the `md` rung: the
|
|
15
|
+
// One column under the `md` rung: the menu bar, then the screen; the sidebar is the panel the
|
|
16
|
+
// bar opens — or, in an engine with no popovers, a block above the screen it navigates.
|
|
16
17
|
@include t.respond-down('md') {
|
|
17
18
|
grid-template-columns: 1fr;
|
|
19
|
+
gap: 0;
|
|
18
20
|
}
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// Off-screen until focused, then pinned at the inline-start corner. It used to stay visually
|
|
24
|
+
// hidden even WITH focus — the focus ring drew around a 1px box nobody could see.
|
|
21
25
|
.skip {
|
|
22
|
-
@include t.
|
|
26
|
+
@include t.focus-ring;
|
|
27
|
+
|
|
28
|
+
position: fixed;
|
|
29
|
+
z-index: t.z(skip-nav);
|
|
30
|
+
inset-block-start: t.space(2);
|
|
31
|
+
inset-inline-start: t.space(2);
|
|
32
|
+
padding-block: t.space(2);
|
|
33
|
+
padding-inline: t.space(4);
|
|
34
|
+
border-radius: t.radius('md');
|
|
35
|
+
background: t.role('surface-raised');
|
|
36
|
+
color: t.role('fg-strong');
|
|
37
|
+
box-shadow: t.shadow(md);
|
|
38
|
+
text-decoration: none;
|
|
23
39
|
|
|
24
|
-
&:focus-visible {
|
|
25
|
-
@include t.
|
|
40
|
+
&:not(:focus-visible) {
|
|
41
|
+
@include t.visually-hidden;
|
|
26
42
|
}
|
|
27
43
|
}
|
|
28
44
|
|
|
45
|
+
// Hidden unless the engine can open the panel: a menu button that does nothing is worse than the
|
|
46
|
+
// block an engine without popovers still shows.
|
|
47
|
+
.bar {
|
|
48
|
+
display: none;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// A touch target: this bar exists for a phone.
|
|
52
|
+
.menuButton,
|
|
53
|
+
.close {
|
|
54
|
+
@include t.focus-ring;
|
|
55
|
+
@include t.touch-target;
|
|
56
|
+
|
|
57
|
+
display: inline-flex;
|
|
58
|
+
align-items: center;
|
|
59
|
+
gap: t.space(2);
|
|
60
|
+
padding-inline: t.space(3);
|
|
61
|
+
border: 0;
|
|
62
|
+
border-radius: t.radius('md');
|
|
63
|
+
background: none;
|
|
64
|
+
color: t.role('fg-strong');
|
|
65
|
+
font: inherit;
|
|
66
|
+
cursor: pointer;
|
|
67
|
+
|
|
68
|
+
&:hover {
|
|
69
|
+
background: t.role('bg-soft');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.close {
|
|
74
|
+
display: none;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
align-self: flex-end;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Every `[popover]` gets a UA style — fixed, centred, hidden while closed — so the column undoes
|
|
80
|
+
// each; this box is the sidebar at `md` and up, and at every width where there is no popover.
|
|
29
81
|
.side {
|
|
30
82
|
@include t.surface('surface', 'sm');
|
|
31
83
|
@include t.padding-block(t.space(6));
|
|
32
84
|
@include t.padding-inline(t.space(4));
|
|
33
85
|
@include t.column(t.space(4));
|
|
34
86
|
|
|
87
|
+
position: static;
|
|
88
|
+
inset: auto;
|
|
89
|
+
inline-size: auto;
|
|
90
|
+
block-size: auto;
|
|
91
|
+
margin: 0;
|
|
92
|
+
color: t.role('fg');
|
|
35
93
|
border-inline-end: t.stroke(hairline) solid t.role('line');
|
|
94
|
+
overflow: auto;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@include t.respond-down('md') {
|
|
98
|
+
@supports selector(:popover-open) {
|
|
99
|
+
.bar {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
padding-block: t.space(1);
|
|
103
|
+
padding-inline: t.space(2);
|
|
104
|
+
border-block-end: t.stroke(hairline) solid t.role('line');
|
|
105
|
+
background: t.role('surface');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.side:not(:popover-open) {
|
|
109
|
+
display: none;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The same edge sheet as `Drawer`'s inline-start side: raised surface, line edge, scrim.
|
|
113
|
+
.side:popover-open {
|
|
114
|
+
@include t.scrim-backdrop;
|
|
115
|
+
|
|
116
|
+
position: fixed;
|
|
117
|
+
inset-block: 0;
|
|
118
|
+
inset-inline-start: 0;
|
|
119
|
+
inset-inline-end: auto;
|
|
120
|
+
inline-size: min(t.rem(320), calc(100vw - #{t.space(12)}));
|
|
121
|
+
block-size: 100dvh;
|
|
122
|
+
max-block-size: 100dvh;
|
|
123
|
+
border: 0;
|
|
124
|
+
border-inline-end: t.stroke(hairline) solid t.role('line');
|
|
125
|
+
border-radius: 0;
|
|
126
|
+
background: t.role('surface-raised');
|
|
127
|
+
box-shadow: t.shadow(xl);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.close {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
36
134
|
}
|
|
37
135
|
|
|
38
136
|
.brand {
|
|
@@ -50,8 +148,17 @@
|
|
|
50
148
|
color: t.role('fg-muted');
|
|
51
149
|
}
|
|
52
150
|
|
|
151
|
+
// One track that may shrink to the column: an `auto` track is at least its content's min-content
|
|
152
|
+
// width, and a text input's is its `size` — wider than a 256px sidebar's content box, so the box
|
|
153
|
+
// stuck out past the sidebar's edge at every width.
|
|
53
154
|
.search {
|
|
54
155
|
display: grid;
|
|
156
|
+
grid-template-columns: minmax(0, 1fr);
|
|
157
|
+
min-inline-size: 0;
|
|
158
|
+
|
|
159
|
+
> form {
|
|
160
|
+
min-inline-size: 0;
|
|
161
|
+
}
|
|
55
162
|
}
|
|
56
163
|
|
|
57
164
|
.hidden {
|
|
@@ -96,6 +203,13 @@
|
|
|
96
203
|
&:focus-visible {
|
|
97
204
|
@include t.focus-ring;
|
|
98
205
|
}
|
|
206
|
+
|
|
207
|
+
// A touch-target row on a narrow screen, where the link is tapped rather than clicked.
|
|
208
|
+
@include t.respond-down('md') {
|
|
209
|
+
display: flex;
|
|
210
|
+
align-items: center;
|
|
211
|
+
min-block-size: t.touch(target);
|
|
212
|
+
}
|
|
99
213
|
}
|
|
100
214
|
|
|
101
215
|
.main {
|
package/src/audit-pg.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// `defineAdmin({ audit: postgresAuditLog() })` names — the memory log is a ring that forgets at
|
|
3
3
|
// every restart, and a row's history that ends at the last deploy is not a history.
|
|
4
4
|
|
|
5
|
-
import { isUltimateError,
|
|
5
|
+
import { isUltimateError, uuidV7 } from '@ultimat3/core';
|
|
6
6
|
import { type DbClient, db, withTransaction } from '@ultimat3/db';
|
|
7
7
|
import {
|
|
8
8
|
AUDIT_READ_OPERATIONS,
|
|
@@ -149,7 +149,7 @@ export interface PostgresAuditLogOptions {
|
|
|
149
149
|
*/
|
|
150
150
|
export function postgresAuditLog(options: PostgresAuditLogOptions = {}): AuditLog {
|
|
151
151
|
const now = options.now ?? ((): Date => new Date());
|
|
152
|
-
const nextId = options.nextId ??
|
|
152
|
+
const nextId = options.nextId ?? uuidV7;
|
|
153
153
|
const sinks = options.sinks ?? [];
|
|
154
154
|
const reads = options.reads === true;
|
|
155
155
|
// Resolved per statement, never captured: inside `atomic` it is the open transaction.
|
package/src/audit.ts
CHANGED
|
@@ -2,12 +2,10 @@
|
|
|
2
2
|
// If it isn't logged, it didn't happen — so denied and failed attempts are logged too, and
|
|
3
3
|
// there is deliberately no update or delete on this interface.
|
|
4
4
|
|
|
5
|
-
import { canonicalJson, finiteCount } from '@ultimat3/core';
|
|
5
|
+
import { canonicalJson, finiteCount, REDACTED } from '@ultimat3/core';
|
|
6
6
|
import type { AdminActor, AdminDecision } from './authz';
|
|
7
7
|
import type { AdminRow } from './registry';
|
|
8
8
|
|
|
9
|
-
export const REDACTED = '[redacted]';
|
|
10
|
-
|
|
11
9
|
/** Named once, so both refusals below say the same thing about the same call. */
|
|
12
10
|
const SUBJECT = 'memoryAuditLog';
|
|
13
11
|
|
package/src/authz.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface AdminAuthz {
|
|
|
62
62
|
decide(query: AdminAuthzQuery): AdminDecision;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
export function
|
|
65
|
+
export function adminAllowed(
|
|
66
66
|
permission: string,
|
|
67
67
|
reason: string,
|
|
68
68
|
trace: readonly string[] = [],
|
|
@@ -70,7 +70,7 @@ export function allowed(
|
|
|
70
70
|
return { allowed: true, permission, reason, trace };
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
export function
|
|
73
|
+
export function adminDenied(
|
|
74
74
|
permission: string,
|
|
75
75
|
reason: string,
|
|
76
76
|
trace: readonly string[] = [],
|
|
@@ -82,7 +82,7 @@ export function denied(
|
|
|
82
82
|
* Every permission must hold. The first denial wins, and carries its own reason.
|
|
83
83
|
*
|
|
84
84
|
* An EMPTY list is refused, never granted. `permissions[length - 1] ?? ''` used to fall through to
|
|
85
|
-
* `
|
|
85
|
+
* `adminAllowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
|
|
86
86
|
* decision it returned named no permission at all. `visibleNav` hands an author's
|
|
87
87
|
* `item.permissions` straight here, so `permissions: []` on a nav item was that gate. `pages.ts`
|
|
88
88
|
* already refuses an empty page list at declaration time (`X_ADMIN_PAGE_UNGUARDED`); this is the
|
|
@@ -96,7 +96,7 @@ export function decideAll(
|
|
|
96
96
|
subject?: AdminSubject,
|
|
97
97
|
): AdminDecision {
|
|
98
98
|
if (permissions.length === 0) {
|
|
99
|
-
return
|
|
99
|
+
return adminDenied('', 'admin.policy.none-declared', [
|
|
100
100
|
'no permission was declared for this surface, so there is nothing to satisfy',
|
|
101
101
|
]);
|
|
102
102
|
}
|
|
@@ -107,10 +107,10 @@ export function decideAll(
|
|
|
107
107
|
);
|
|
108
108
|
trace.push(`${permission}: ${decision.allowed ? 'allow' : 'deny'} (${decision.reason})`);
|
|
109
109
|
if (!decision.allowed)
|
|
110
|
-
return
|
|
110
|
+
return adminDenied(permission, decision.reason, [...trace, ...decision.trace]);
|
|
111
111
|
}
|
|
112
112
|
const last = permissions[permissions.length - 1] ?? '';
|
|
113
|
-
return
|
|
113
|
+
return adminAllowed(last, 'admin.policy.all-granted', trace);
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
export function isAllowed(
|
|
@@ -167,8 +167,8 @@ export function staticAuthz(granted: readonly string[]): AdminAuthz {
|
|
|
167
167
|
return {
|
|
168
168
|
decide({ permission }): AdminDecision {
|
|
169
169
|
return set.has(permission)
|
|
170
|
-
?
|
|
171
|
-
:
|
|
170
|
+
? adminAllowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
|
|
171
|
+
: adminDenied(permission, 'admin.policy.not-granted', [
|
|
172
172
|
`static: ${permission} not in [${[...set].join(', ')}]`,
|
|
173
173
|
]);
|
|
174
174
|
},
|
package/src/batch-matching.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import type { AdminDecision } from './authz';
|
|
6
6
|
import type { CrudCtx } from './crud';
|
|
7
|
+
import { atomicallyAudited } from './crud-outcome';
|
|
7
8
|
import { type AdminListRequest, listWhere } from './list-scope';
|
|
8
9
|
import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
|
|
9
10
|
import type { AdminAction, AdminActionCtx, AdminMatchingResult } from './registry';
|
|
@@ -57,36 +58,30 @@ export async function runMatching(args: MatchingInput): Promise<MatchingAnswer>
|
|
|
57
58
|
locale: ctx.actor.locale ?? 'en',
|
|
58
59
|
timeZone: ctx.actor.timeZone ?? 'UTC',
|
|
59
60
|
};
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
reason: BATCH_MATCHING_REASON,
|
|
86
|
-
diff: [
|
|
87
|
-
{ field: 'affected', before: null, after: result.affected },
|
|
88
|
-
{ field: 'remaining', before: null, after: result.remaining },
|
|
89
|
-
],
|
|
90
|
-
});
|
|
61
|
+
// The set-based write and its one entry commit together or not at all — a `matching` verb that
|
|
62
|
+
// rides the ambient transaction is rolled back with an entry that could not be written, and the
|
|
63
|
+
// log says `failed` (`atomicallyAudited`). It used to append AFTER the call, outside any `try`:
|
|
64
|
+
// a sink that threw left a committed write with no entry at all.
|
|
65
|
+
const { value: result } = await atomicallyAudited(
|
|
66
|
+
ctx.audit,
|
|
67
|
+
{
|
|
68
|
+
requestId: ctx.requestId,
|
|
69
|
+
actor: ctx.actor,
|
|
70
|
+
operation: action.name,
|
|
71
|
+
kind: 'action',
|
|
72
|
+
entity: resource.name,
|
|
73
|
+
permission: args.decision.permission,
|
|
74
|
+
},
|
|
75
|
+
{ allowed: BATCH_MATCHING_REASON, failed: MATCHING_FAILED_REASON },
|
|
76
|
+
null,
|
|
77
|
+
() => action.matching({ where, input: args.input, ctx: actionCtx }),
|
|
78
|
+
(answered) => ({
|
|
79
|
+
entityId: null,
|
|
80
|
+
diff: [
|
|
81
|
+
{ field: 'affected', before: null, after: answered.affected },
|
|
82
|
+
{ field: 'remaining', before: null, after: answered.remaining },
|
|
83
|
+
],
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
91
86
|
return { ok: true, result };
|
|
92
87
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// The queued half of a batch past its declared threshold: every chunk enqueued and the one entry
|
|
2
|
+
// naming them, as ONE audited unit, under a batch id DERIVED from the request's content — so a
|
|
3
|
+
// retry after a failed enqueue asks the queue for the chunks it already holds.
|
|
4
|
+
|
|
5
|
+
import { keyedFingerprint } from '@ultimat3/core';
|
|
6
|
+
import type { AdminDecision } from './authz';
|
|
7
|
+
import type { AdminBatchInput, BatchEnqueue } from './batch';
|
|
8
|
+
import { atomicallyAudited } from './crud-outcome';
|
|
9
|
+
|
|
10
|
+
/** The reason a queued batch's one entry carries. */
|
|
11
|
+
export const BATCH_QUEUED_REASON = 'admin.batch.queued';
|
|
12
|
+
|
|
13
|
+
/** The reason it carries when its chunks could not all be queued — the gate's own key. */
|
|
14
|
+
const BATCH_QUEUE_FAILED_REASON = 'admin.error.action-failed';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A queued batch's id, DERIVED from what was asked — who, which action on which resource, these
|
|
18
|
+
* rows, this input — never drawn at random. The chunk job's idempotency key is
|
|
19
|
+
* `admin.batch:<batchId>:<index>`, so an operator retrying a request whose third enqueue failed
|
|
20
|
+
* asks for chunks 0–2 under the keys they already hold, and the queue answers the jobs it has
|
|
21
|
+
* instead of running those rows twice. The request id is deliberately NOT part of it: a retry is
|
|
22
|
+
* a new request. Keyed (`keyedFingerprint`), because it is persisted in the queue beside the rows
|
|
23
|
+
* it was derived from.
|
|
24
|
+
*/
|
|
25
|
+
export const batchIdOf = (
|
|
26
|
+
input: Pick<AdminBatchInput, 'resource' | 'action' | 'ctx'>,
|
|
27
|
+
ids: readonly string[],
|
|
28
|
+
own: Readonly<Record<string, unknown>>,
|
|
29
|
+
): string =>
|
|
30
|
+
keyedFingerprint(
|
|
31
|
+
{
|
|
32
|
+
entity: input.resource.name,
|
|
33
|
+
action: input.action.name,
|
|
34
|
+
actor: input.ctx.actor.id,
|
|
35
|
+
org: input.ctx.actor.orgId ?? null,
|
|
36
|
+
ids,
|
|
37
|
+
input: own,
|
|
38
|
+
},
|
|
39
|
+
'admin.batch',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Enqueue `ids` in chunks of `chunk`, answering the job ids. The chunks and the entry ride one
|
|
44
|
+
* `audit.atomic`: an enqueue joins the caller's transaction, so chunk 3 failing takes chunks 0–2
|
|
45
|
+
* back out with it. Where nothing can roll back (the memory log), the derived `batchId` is what
|
|
46
|
+
* makes the retry safe — a chunk already queued is the queue's duplicate, not a second run.
|
|
47
|
+
*/
|
|
48
|
+
export async function queueBatch(
|
|
49
|
+
input: AdminBatchInput & { readonly enqueue: BatchEnqueue },
|
|
50
|
+
decision: AdminDecision,
|
|
51
|
+
chunk: number,
|
|
52
|
+
ids: readonly string[],
|
|
53
|
+
own: Readonly<Record<string, unknown>>,
|
|
54
|
+
): Promise<readonly string[]> {
|
|
55
|
+
const { resource, action, ctx, enqueue } = input;
|
|
56
|
+
const batchId = batchIdOf(input, ids, own);
|
|
57
|
+
const { value } = await atomicallyAudited(
|
|
58
|
+
ctx.audit,
|
|
59
|
+
{
|
|
60
|
+
requestId: ctx.requestId,
|
|
61
|
+
actor: ctx.actor,
|
|
62
|
+
operation: action.name,
|
|
63
|
+
kind: 'action',
|
|
64
|
+
entity: resource.name,
|
|
65
|
+
permission: decision.permission,
|
|
66
|
+
},
|
|
67
|
+
{ allowed: BATCH_QUEUED_REASON, failed: BATCH_QUEUE_FAILED_REASON },
|
|
68
|
+
null,
|
|
69
|
+
async () => {
|
|
70
|
+
const queued: string[] = [];
|
|
71
|
+
for (let at = 0; at < ids.length; at += chunk) {
|
|
72
|
+
const slice = ids.slice(at, at + chunk);
|
|
73
|
+
const index = at / chunk;
|
|
74
|
+
queued.push(
|
|
75
|
+
await enqueue({ resource, action, ids: slice, input: own, ctx, batchId, index }),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
return queued;
|
|
79
|
+
},
|
|
80
|
+
// Which job runs handle the chunks: each row's own entry is written when its job reaches it.
|
|
81
|
+
(queued) => ({
|
|
82
|
+
entityId: null,
|
|
83
|
+
diff: [
|
|
84
|
+
{ field: 'rows', before: null, after: ids.length },
|
|
85
|
+
{ field: 'jobs', before: null, after: queued },
|
|
86
|
+
],
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
return value;
|
|
90
|
+
}
|
package/src/batch.ts
CHANGED
|
@@ -4,19 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
import { finiteCount } from '@ultimat3/core';
|
|
6
6
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
7
|
-
import { decideAction } from './action-gate';
|
|
7
|
+
import { adminPermissionForAction, decideAction } from './action-gate';
|
|
8
8
|
import { invokeRowAction } from './action-row';
|
|
9
|
-
import type { AuditEntry } from './audit';
|
|
10
9
|
import type { AdminDecision } from './authz';
|
|
11
10
|
import { runMatching } from './batch-matching';
|
|
11
|
+
import { queueBatch } from './batch-queue';
|
|
12
12
|
import { type CrudCtx, decideOperation } from './crud';
|
|
13
13
|
import { type AdminListRequest, listWhere } from './list-scope';
|
|
14
|
-
import {
|
|
15
|
-
ADMIN_DESTROY,
|
|
16
|
-
ADMIN_WRITE,
|
|
17
|
-
CONFIRMATION_REQUIRED_REASON,
|
|
18
|
-
confirmationToken,
|
|
19
|
-
} from './permissions';
|
|
14
|
+
import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
|
|
20
15
|
import type { AdminAction, AdminFilter } from './registry';
|
|
21
16
|
import { rowId } from './registry';
|
|
22
17
|
import { type AdminResource, repoOf } from './resource';
|
|
@@ -35,9 +30,6 @@ export const MAX_BATCH_ROWS = 200;
|
|
|
35
30
|
*/
|
|
36
31
|
export const MAX_BATCH_QUEUED_ROWS = 1_000;
|
|
37
32
|
|
|
38
|
-
/** The reason a queued batch's one entry carries. */
|
|
39
|
-
export const BATCH_QUEUED_REASON = 'admin.batch.queued';
|
|
40
|
-
|
|
41
33
|
/** The reason an action that is not a batch action is refused the batch bar with. */
|
|
42
34
|
export const BATCH_NOT_OFFERED_REASON = 'admin.error.batch-not-offered';
|
|
43
35
|
|
|
@@ -185,29 +177,6 @@ async function select(
|
|
|
185
177
|
return { ids, remaining };
|
|
186
178
|
}
|
|
187
179
|
|
|
188
|
-
const queuedEntry = (
|
|
189
|
-
input: AdminBatchInput,
|
|
190
|
-
decision: AdminDecision,
|
|
191
|
-
rows: number,
|
|
192
|
-
jobs: readonly string[],
|
|
193
|
-
): Promise<AuditEntry> =>
|
|
194
|
-
input.ctx.audit.append({
|
|
195
|
-
requestId: input.ctx.requestId,
|
|
196
|
-
actor: input.ctx.actor,
|
|
197
|
-
operation: input.action.name,
|
|
198
|
-
kind: 'action',
|
|
199
|
-
entity: input.resource.name,
|
|
200
|
-
entityId: null,
|
|
201
|
-
permission: decision.permission,
|
|
202
|
-
outcome: 'allowed',
|
|
203
|
-
reason: BATCH_QUEUED_REASON,
|
|
204
|
-
// Which job runs handle the chunks: each row's own entry is written when its job reaches it.
|
|
205
|
-
diff: [
|
|
206
|
-
{ field: 'rows', before: null, after: rows },
|
|
207
|
-
{ field: 'jobs', before: null, after: jobs },
|
|
208
|
-
],
|
|
209
|
-
});
|
|
210
|
-
|
|
211
180
|
/**
|
|
212
181
|
* Run `action` over a selection of `resource`'s rows.
|
|
213
182
|
*
|
|
@@ -228,7 +197,7 @@ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchA
|
|
|
228
197
|
if (action.batch === undefined) {
|
|
229
198
|
return denied({
|
|
230
199
|
allowed: false,
|
|
231
|
-
permission: action
|
|
200
|
+
permission: adminPermissionForAction(action),
|
|
232
201
|
reason: BATCH_NOT_OFFERED_REASON,
|
|
233
202
|
trace: [`batch: ${action.name} does not declare batch`],
|
|
234
203
|
});
|
|
@@ -285,22 +254,13 @@ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchA
|
|
|
285
254
|
};
|
|
286
255
|
|
|
287
256
|
if (plan !== null && selected.ids.length > plan.threshold && input.enqueue !== undefined) {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
ids: selected.ids.slice(at, at + plan.chunk),
|
|
296
|
-
input: own,
|
|
297
|
-
ctx,
|
|
298
|
-
batchId,
|
|
299
|
-
index: at / plan.chunk,
|
|
300
|
-
}),
|
|
301
|
-
);
|
|
302
|
-
}
|
|
303
|
-
await queuedEntry(input, decision, selected.ids.length, jobs);
|
|
257
|
+
const jobs = await queueBatch(
|
|
258
|
+
{ ...input, enqueue: input.enqueue },
|
|
259
|
+
decision,
|
|
260
|
+
plan.chunk,
|
|
261
|
+
selected.ids,
|
|
262
|
+
own,
|
|
263
|
+
);
|
|
304
264
|
return {
|
|
305
265
|
ok: true,
|
|
306
266
|
done: 0,
|
package/src/crud-input.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// tenant column taken out of the caller's hands, and the audit diff of what the write changed.
|
|
3
3
|
// Pure over the resource — `crud.ts` decides and writes, this file only shapes the input.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { REDACTED } from '@ultimat3/core';
|
|
6
|
+
import { type AuditFieldDiff, diffRows } from './audit';
|
|
6
7
|
import type { CrudCtx } from './crud';
|
|
7
8
|
import type { AdminRow } from './registry';
|
|
8
9
|
import type { AdminResource } from './resource';
|