@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
@@ -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 sidebar becomes a block above the screen it navigates.
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.visually-hidden;
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.focus-ring;
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, uuid } from '@ultimat3/core';
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 ?? uuid;
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 allowed(
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 denied(
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
- * `allowed('')`, so a declared-but-empty gate opened for every actor, anonymous included — and the
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 denied('', 'admin.policy.none-declared', [
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 denied(permission, decision.reason, [...trace, ...decision.trace]);
110
+ return adminDenied(permission, decision.reason, [...trace, ...decision.trace]);
111
111
  }
112
112
  const last = permissions[permissions.length - 1] ?? '';
113
- return allowed(last, 'admin.policy.all-granted', trace);
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
- ? allowed(permission, 'admin.policy.granted', [`static: ${permission} in grant list`])
171
- : denied(permission, 'admin.policy.not-granted', [
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
  },
@@ -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
- const entry = {
61
- requestId: ctx.requestId,
62
- actor: ctx.actor,
63
- operation: action.name,
64
- kind: 'action',
65
- entity: resource.name,
66
- entityId: null,
67
- permission: args.decision.permission,
68
- } as const;
69
- let result: AdminMatchingResult;
70
- try {
71
- result = await action.matching({ where, input: args.input, ctx: actionCtx });
72
- } catch (error) {
73
- // The gate's rule: the failure is recorded before it travels, and nothing is read off it.
74
- await ctx.audit.append({
75
- ...entry,
76
- outcome: 'failed',
77
- reason: MATCHING_FAILED_REASON,
78
- diff: [],
79
- });
80
- throw error;
81
- }
82
- await ctx.audit.append({
83
- ...entry,
84
- outcome: 'allowed',
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.destructive === true ? ADMIN_DESTROY : ADMIN_WRITE,
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 batchId = crypto.randomUUID();
289
- const jobs: string[] = [];
290
- for (let at = 0; at < selected.ids.length; at += plan.chunk) {
291
- jobs.push(
292
- await input.enqueue({
293
- resource,
294
- action,
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 { type AuditFieldDiff, diffRows, REDACTED } from './audit';
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';