@ultimat3/admin 22.15.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/CLAUDE.md +83 -91
  2. package/README.md +282 -89
  3. package/package.json +19 -17
  4. package/src/action-form.tsx +139 -0
  5. package/src/action-gate.ts +141 -68
  6. package/src/action-input.ts +119 -0
  7. package/src/action-label.ts +9 -0
  8. package/src/action-row.ts +37 -0
  9. package/src/actions.tsx +89 -66
  10. package/src/actor.ts +53 -0
  11. package/src/admin.module.scss +298 -0
  12. package/src/admin.ts +187 -23
  13. package/src/audit-pg.ts +207 -0
  14. package/src/audit-schema.ts +47 -0
  15. package/src/audit.ts +109 -22
  16. package/src/authz.ts +11 -0
  17. package/src/batch-bar.tsx +66 -0
  18. package/src/batch-job.ts +152 -0
  19. package/src/batch-matching.ts +87 -0
  20. package/src/batch-queue.ts +90 -0
  21. package/src/batch.ts +396 -0
  22. package/src/catalog-keys.ts +55 -0
  23. package/src/crud-input.ts +104 -0
  24. package/src/crud-outcome.ts +224 -0
  25. package/src/crud.ts +172 -176
  26. package/src/describe.ts +131 -0
  27. package/src/detail.tsx +115 -43
  28. package/src/dev/data.ts +27 -12
  29. package/src/dev/facts.ts +28 -1
  30. package/src/dev/index.ts +1 -0
  31. package/src/dev/panel-cache.ts +8 -3
  32. package/src/dev/panel-db.ts +37 -5
  33. package/src/dev/panel-jobs.ts +8 -0
  34. package/src/dev/panel-routes.ts +10 -0
  35. package/src/dev/panel-timeline.ts +7 -2
  36. package/src/dev/panel.ts +10 -2
  37. package/src/dev/server.ts +16 -11
  38. package/src/dev/sql-runner.ts +31 -0
  39. package/src/entity-columns.ts +24 -5
  40. package/src/errors.ts +92 -5
  41. package/src/fields.ts +63 -4
  42. package/src/form-decode.ts +132 -0
  43. package/src/form.tsx +96 -49
  44. package/src/index.ts +199 -9
  45. package/src/jobs/fleet-repo.ts +155 -0
  46. package/src/jobs/job-actions.ts +172 -0
  47. package/src/jobs/job-entities.ts +118 -0
  48. package/src/jobs/job-labels.ts +108 -0
  49. package/src/jobs/job-repo.ts +137 -0
  50. package/src/jobs/job-resources.ts +211 -0
  51. package/src/jobs/job-where.ts +170 -0
  52. package/src/jobs/operator.ts +34 -0
  53. package/src/jobs/overview-data.ts +129 -0
  54. package/src/jobs/overview-tab.tsx +61 -0
  55. package/src/jobs/overview.tsx +189 -0
  56. package/src/json-text.ts +52 -0
  57. package/src/keyset-seek.ts +189 -0
  58. package/src/layout.tsx +55 -43
  59. package/src/list-columns.tsx +53 -0
  60. package/src/list-filter-bar.tsx +207 -0
  61. package/src/list-filters.ts +242 -0
  62. package/src/list-request.ts +129 -0
  63. package/src/list-scope-tabs.tsx +60 -0
  64. package/src/list-scope.ts +113 -0
  65. package/src/list.tsx +111 -72
  66. package/src/mcp-tools.ts +57 -13
  67. package/src/mcp.ts +112 -38
  68. package/src/mounts.ts +44 -0
  69. package/src/page-guard.tsx +26 -29
  70. package/src/pagination.ts +10 -4
  71. package/src/policy-bridge.ts +115 -18
  72. package/src/registry.ts +140 -9
  73. package/src/related.ts +105 -0
  74. package/src/relations.ts +240 -0
  75. package/src/renderers.tsx +89 -0
  76. package/src/repo-entity.ts +195 -0
  77. package/src/resource-fields.ts +116 -0
  78. package/src/resource-layout.ts +89 -0
  79. package/src/resource-list.ts +194 -0
  80. package/src/resource.ts +129 -79
  81. package/src/route-config.ts +71 -0
  82. package/src/routes.ts +55 -54
  83. package/src/row-scope-write.ts +130 -0
  84. package/src/row-version.ts +20 -0
  85. package/src/screen-action.tsx +333 -0
  86. package/src/screen-frame.tsx +167 -0
  87. package/src/screen-home.tsx +201 -0
  88. package/src/screen-lookup.tsx +157 -0
  89. package/src/screen-resource.tsx +441 -0
  90. package/src/screen-system.tsx +75 -0
  91. package/src/screens.ts +56 -0
  92. package/src/search.ts +22 -10
  93. package/src/stylesheet-scope.ts +8 -0
  94. package/src/test-ctx.ts +31 -0
  95. package/src/validate.ts +3 -0
  96. package/src/widget-value.ts +36 -8
  97. package/src/widgets.tsx +84 -16
package/src/audit.ts CHANGED
@@ -24,7 +24,12 @@ export interface AuditEntry {
24
24
  /** ISO-8601 UTC. Stored UTC, formatted per viewer zone at the edge. */
25
25
  readonly at: string;
26
26
  readonly requestId: string;
27
- readonly actor: { readonly id: string; readonly roles: readonly string[] };
27
+ readonly actor: {
28
+ readonly id: string;
29
+ readonly roles: readonly string[];
30
+ /** The tenant the actor acted under. Absent for a single-tenant app. */
31
+ readonly orgId?: string;
32
+ };
28
33
  /** `list` | `create` | `update` | `delete`, or an action name. */
29
34
  readonly operation: string;
30
35
  readonly kind: 'operation' | 'action';
@@ -50,19 +55,49 @@ export interface AuditDraft {
50
55
  readonly diff?: readonly AuditFieldDiff[];
51
56
  }
52
57
 
53
- /** Where entries go beyond memory: a table, stdout as JSON lines, an OTel log. */
58
+ /** Where a copy of every entry goes besides the log's own store: stdout as JSON lines, OTel. */
54
59
  export interface AuditSink {
55
60
  write(entry: AuditEntry): Promise<void> | void;
56
61
  }
57
62
 
63
+ /** A position in the trail: the entry a page ended on. Entries are ordered `at` then `id`. */
64
+ export interface AuditCursor {
65
+ readonly at: string;
66
+ readonly id: string;
67
+ }
68
+
69
+ export interface AuditQuery {
70
+ readonly entity?: string;
71
+ /** One row's trail — what the detail page's history card asks, with `entity`. */
72
+ readonly entityId?: string;
73
+ readonly actorId?: string;
74
+ /** Only entries written by an actor of this tenant. */
75
+ readonly orgId?: string;
76
+ readonly limit?: number;
77
+ /** Keyset: only entries strictly OLDER than this one. There is no offset. */
78
+ readonly before?: AuditCursor;
79
+ /**
80
+ * Leave out the reads that were allowed — `list`, `detail`, `search`, a page — and keep what
81
+ * changed something or was refused. What a row's history shows.
82
+ */
83
+ readonly changes?: boolean;
84
+ }
85
+
58
86
  export interface AuditLog {
59
87
  append(draft: AuditDraft): Promise<AuditEntry>;
60
- /** Newest first. A copy — the log cannot be mutated through what it hands out. */
61
- entries(query?: {
62
- readonly entity?: string;
63
- readonly actorId?: string;
64
- readonly limit?: number;
65
- }): readonly AuditEntry[];
88
+ /**
89
+ * Newest first, at most `limit` (`AUDIT_PAGE_MAX` at the outside). A copy — the log cannot be
90
+ * mutated through what it hands out. Async because the record is a table, not this process.
91
+ */
92
+ entries(query?: AuditQuery): Promise<readonly AuditEntry[]>;
93
+ /**
94
+ * Run a write and its entry as ONE unit where the store can: a durable log opens a transaction,
95
+ * so a row never changes without its entry and an entry never names a write that rolled back.
96
+ * The memory log has nothing to join and runs `run` as it is.
97
+ */
98
+ atomic<T>(run: () => Promise<T>): Promise<T>;
99
+ /** `memory`, `postgres`, or an app's own word. What the manifest records about this admin. */
100
+ readonly kind: string;
66
101
  }
67
102
 
68
103
  export interface AuditLogOptions {
@@ -74,12 +109,52 @@ export interface AuditLogOptions {
74
109
  readonly capacity?: number;
75
110
  }
76
111
 
112
+ /** The most entries one read answers, whatever it asked for. */
113
+ export const AUDIT_PAGE_MAX = 200;
114
+
115
+ /** What a read answers when it names no limit. */
116
+ export const AUDIT_PAGE_DEFAULT = 100;
117
+
118
+ /** The operations that only LOOK. An allowed one is a read; a refused one is still an event. */
119
+ export const AUDIT_READ_OPERATIONS: readonly string[] = ['list', 'detail', 'search', 'page'];
120
+
121
+ /** An entry that changed nothing and was refused nothing — the only kind `changes` leaves out. */
122
+ export const isAllowedRead = (entry: AuditEntry): boolean =>
123
+ entry.kind === 'operation' &&
124
+ entry.outcome === 'allowed' &&
125
+ AUDIT_READ_OPERATIONS.includes(entry.operation);
126
+
127
+ /** The page size a query asks for, bounded. Refused when it is not a count at all. */
128
+ export const auditLimit = (subject: string, query: AuditQuery): number =>
129
+ Math.min(
130
+ AUDIT_PAGE_MAX,
131
+ query.limit === undefined
132
+ ? AUDIT_PAGE_DEFAULT
133
+ : // `slice(0, NaN)` is `[]`: an unreadable limit would answer "nothing was ever logged".
134
+ // 0 stays legal — asking for none is a coherent request.
135
+ finiteCount(subject, 'entries limit', query.limit),
136
+ );
137
+
138
+ /** Where the page after `entries` starts, or `null` for an empty page. */
139
+ export const auditCursorOf = (entries: readonly AuditEntry[]): AuditCursor | null => {
140
+ const last = entries[entries.length - 1];
141
+ return last === undefined ? null : { at: last.at, id: last.id };
142
+ };
143
+
144
+ /** `-1` when `a` is NEWER than `b`: the trail's one order, `at` then `id`, both descending. */
145
+ const newestFirst = (a: AuditCursor, b: AuditCursor): number =>
146
+ a.at === b.at ? (a.id === b.id ? 0 : a.id > b.id ? -1 : 1) : a.at > b.at ? -1 : 1;
147
+
77
148
  export function auditEntry(draft: AuditDraft, id: string, at: Date): AuditEntry {
78
149
  return {
79
150
  id,
80
151
  at: at.toISOString(),
81
152
  requestId: draft.requestId,
82
- actor: { id: draft.actor.id, roles: draft.actor.roles ?? [] },
153
+ actor: {
154
+ id: draft.actor.id,
155
+ roles: draft.actor.roles ?? [],
156
+ ...(draft.actor.orgId === undefined ? {} : { orgId: draft.actor.orgId }),
157
+ },
83
158
  operation: draft.operation,
84
159
  kind: draft.kind,
85
160
  entity: draft.entity,
@@ -102,6 +177,7 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
102
177
  const log: AuditEntry[] = [];
103
178
 
104
179
  return {
180
+ kind: 'memory',
105
181
  async append(draft: AuditDraft): Promise<AuditEntry> {
106
182
  // Timestamp first: the entry is stamped when it happened, not when the id generator
107
183
  // got around to it.
@@ -112,20 +188,31 @@ export function memoryAuditLog(opts: AuditLogOptions = {}): AuditLog {
112
188
  for (const sink of sinks) await sink.write(entry);
113
189
  return entry;
114
190
  },
115
- entries(query = {}): readonly AuditEntry[] {
116
- const filtered = log.filter(
117
- (entry) =>
118
- (query.entity === undefined || entry.entity === query.entity) &&
119
- (query.actorId === undefined || entry.actor.id === query.actorId),
120
- );
121
- const newestFirst = [...filtered].reverse();
122
- // The opposite failure to `capacity`, from the same missing check: `slice(0, NaN)` is `[]`,
123
- // so an unreadable limit answers "nothing was ever logged" and reads as a successful read.
124
- // 0 stays legal — asking for none is a coherent request.
125
- return query.limit === undefined
126
- ? newestFirst
127
- : newestFirst.slice(0, finiteCount(SUBJECT, 'entries limit', query.limit));
191
+ async entries(query = {}): Promise<readonly AuditEntry[]> {
192
+ const limit = auditLimit(SUBJECT, query);
193
+ const before = query.before;
194
+ // Insertion order reversed IS newest first, ties included: two entries of one millisecond
195
+ // read in the order they were written, which an id drawn at random could not promise.
196
+ const at = before === undefined ? -1 : log.findIndex((entry) => entry.id === before.id);
197
+ const older =
198
+ before === undefined
199
+ ? log
200
+ : at >= 0
201
+ ? log.slice(0, at)
202
+ : log.filter((entry) => newestFirst(before, entry) < 0);
203
+ return older
204
+ .filter(
205
+ (entry) =>
206
+ (query.entity === undefined || entry.entity === query.entity) &&
207
+ (query.entityId === undefined || entry.entityId === query.entityId) &&
208
+ (query.actorId === undefined || entry.actor.id === query.actorId) &&
209
+ (query.orgId === undefined || entry.actor.orgId === query.orgId) &&
210
+ (query.changes !== true || !isAllowedRead(entry)),
211
+ )
212
+ .reverse()
213
+ .slice(0, limit);
128
214
  },
215
+ atomic: (run) => run(),
129
216
  };
130
217
  }
131
218
 
package/src/authz.ts CHANGED
@@ -147,6 +147,17 @@ export function expandPermissions(granted: readonly string[]): readonly string[]
147
147
  return [...out].sort();
148
148
  }
149
149
 
150
+ /**
151
+ * The admin permissions whose grant carries `permission` — `admin:write` is carried by
152
+ * `admin:destroy`. The inverse of `expandPermissions`, so an authz that decides one name at a time
153
+ * (`roleAuthz`) answers exactly what a grant-list authz answers for the same grants.
154
+ */
155
+ export function impliersOf(permission: string): readonly string[] {
156
+ return Object.keys(ADMIN_PERMISSION_SPEC).filter(
157
+ (candidate) => candidate !== permission && expandPermissions([candidate]).includes(permission),
158
+ );
159
+ }
160
+
150
161
  /**
151
162
  * A grant-list authz for tests, seeds, and `x dev --actor`. Production always goes through
152
163
  * `policyAuthz()` so the app's real rules (ownership, org scoping) are the ones evaluated.
@@ -0,0 +1,66 @@
1
+ // The list's batch bar: one native form posted at the LIST's own URL — whose query string is the
2
+ // filter "all matching" means — holding which action, over which rows, and the submit. The row
3
+ // checkboxes sit in the table and join this form through their `form` attribute, because a form
4
+ // cannot hold a table that already holds the filter bar's. No script: selection is a checkbox,
5
+ // "every row this filter matches" is a radio, and the server answers with what it did.
6
+
7
+ import { t } from '@ultimat3/i18n';
8
+ import { Button, Checkbox, Radio, Select } from '@ultimat3/ui';
9
+ import type { JSX } from 'solid-js';
10
+ import { actionLabelKey } from './action-label';
11
+ import { BATCH_OPERATION, OPERATION_FIELD } from './actions';
12
+ import styles from './admin.module.scss';
13
+ import type { AdminAction } from './registry';
14
+
15
+ /** The id of the batch form, which every row checkbox names in its `form` attribute. */
16
+ export const BATCH_FORM_ID = 'x-admin-batch';
17
+
18
+ /** The posted field each checked row's id rides in; repeated once per row. */
19
+ export const BATCH_IDS_FIELD = 'ids';
20
+
21
+ /** `checked` (the default) or `all` — every row the list's scope and filters match. */
22
+ export const BATCH_SELECTION_FIELD = 'selection';
23
+
24
+ export interface AdminBatchBarProps {
25
+ /** The batch actions this actor may run on this resource — decided by the caller's gate. */
26
+ readonly actions: readonly AdminAction[];
27
+ /** The list's URL in its current state: the form posts here. */
28
+ readonly href: string;
29
+ }
30
+
31
+ export function AdminBatchBar(props: AdminBatchBarProps): JSX.Element {
32
+ if (props.actions.length === 0) return <span class="x-admin-batch-empty" />;
33
+ return (
34
+ <form id={BATCH_FORM_ID} class={styles['batch']} method="post" action={props.href}>
35
+ <input type="hidden" name={OPERATION_FIELD} value={BATCH_OPERATION} />
36
+ <Select
37
+ name="name"
38
+ aria-label={t('admin.batch.action')}
39
+ options={props.actions.map((action) => ({
40
+ value: action.name,
41
+ label: t(actionLabelKey(action)),
42
+ }))}
43
+ />
44
+ <Radio
45
+ legend={t('admin.batch.over')}
46
+ name={BATCH_SELECTION_FIELD}
47
+ direction="row"
48
+ value="checked"
49
+ options={[
50
+ { value: 'checked', label: t('admin.batch.checked') },
51
+ { value: 'all', label: t('admin.batch.all') },
52
+ ]}
53
+ />
54
+ <Button type="submit" size="sm" variant="secondary">
55
+ {t('admin.batch.run')}
56
+ </Button>
57
+ </form>
58
+ );
59
+ }
60
+
61
+ /** One row's checkbox, joined to the batch form by its id. */
62
+ export function BatchCheckbox(props: { readonly id: string; readonly label: string }): JSX.Element {
63
+ return (
64
+ <Checkbox form={BATCH_FORM_ID} name={BATCH_IDS_FIELD} value={props.id} label={props.label} />
65
+ );
66
+ }
@@ -0,0 +1,152 @@
1
+ // The queued half of a batch: ONE framework job, `admin.batch`, that runs one chunk of a batch
2
+ // past its declared threshold through the same per-row gate an inline batch uses. A job and not a
3
+ // ninth kind of thing — `enqueue` and a declaration are all it uses of `@ultimat3/jobs`.
4
+
5
+ import { userActor, withChildContext } from '@ultimat3/core';
6
+ import { type AnyJobHandle, getJob, job, t } from '@ultimat3/jobs';
7
+ import type { AdminActor } from './authz';
8
+ import { type BatchEnqueue, type BatchRow, runBatchRows } from './batch';
9
+ import { AdminMountMissingError } from './errors';
10
+ import { jsonText } from './json-text';
11
+ import { adminMountAt, adminMounts } from './mounts';
12
+
13
+ /** The queued input text, back to an object. Anything else is no input, which the schema judges. */
14
+ const inputOf = (text: string): Readonly<Record<string, unknown>> => {
15
+ try {
16
+ const value: unknown = JSON.parse(text);
17
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
18
+ ? (value as Readonly<Record<string, unknown>>)
19
+ : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ };
24
+
25
+ /** The job's durable name: the queue key every queued chunk carries. Never renamed. */
26
+ export const ADMIN_BATCH_JOB = 'admin.batch';
27
+
28
+ const chunkInput = t.object({
29
+ basePath: t.string,
30
+ entity: t.string,
31
+ action: t.string,
32
+ ids: t.array(t.string),
33
+ // The action's input as JSON text: the action's own schema judges it again on the worker, so
34
+ // the queue carries no second description of a contract this package does not own.
35
+ input: t.string,
36
+ batchId: t.string,
37
+ index: t.number.int(),
38
+ requestId: t.string,
39
+ actor: t.object({
40
+ id: t.string,
41
+ roles: t.array(t.string),
42
+ orgId: t.optional(t.string),
43
+ locale: t.optional(t.string),
44
+ timeZone: t.optional(t.string),
45
+ }),
46
+ });
47
+
48
+ export interface AdminBatchChunk {
49
+ readonly basePath: string;
50
+ readonly entity: string;
51
+ readonly action: string;
52
+ readonly ids: readonly string[];
53
+ /** `jsonText` of the validated input. */
54
+ readonly input: string;
55
+ readonly batchId: string;
56
+ readonly index: number;
57
+ readonly requestId: string;
58
+ readonly actor: {
59
+ readonly id: string;
60
+ readonly roles: readonly string[];
61
+ readonly orgId?: string | undefined;
62
+ readonly locale?: string | undefined;
63
+ readonly timeZone?: string | undefined;
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Run one chunk: find the admin it was queued from, and every row through the gate AS the
69
+ * operator who queued it — their roles decide, their tenant scopes every read. Nothing about the
70
+ * operator is trusted beyond what the queue row carries, which only the server wrote.
71
+ */
72
+ export async function runBatchChunk(chunk: AdminBatchChunk): Promise<readonly BatchRow[]> {
73
+ const app = adminMountAt(chunk.basePath);
74
+ if (app === undefined) {
75
+ throw new AdminMountMissingError({
76
+ basePath: chunk.basePath,
77
+ mounted: adminMounts().map((one) => one.basePath),
78
+ });
79
+ }
80
+ const resource = app.resource(chunk.entity);
81
+ const action = resource.actions.find((declared) => declared.name === chunk.action);
82
+ // An action renamed or removed between the enqueue and the claim has nothing to run: every row
83
+ // is refused by the gate's own answer for an action this resource does not declare.
84
+ if (action === undefined) {
85
+ return chunk.ids.map((id) => ({ id, outcome: 'refused', reason: 'admin.actions.unknown' }));
86
+ }
87
+ const actor: AdminActor = {
88
+ id: chunk.actor.id,
89
+ roles: chunk.actor.roles,
90
+ ...(chunk.actor.orgId === undefined ? {} : { orgId: chunk.actor.orgId }),
91
+ ...(chunk.actor.locale === undefined ? {} : { locale: chunk.actor.locale }),
92
+ ...(chunk.actor.timeZone === undefined ? {} : { timeZone: chunk.actor.timeZone }),
93
+ };
94
+ const ctx = app.ctx({ actor, requestId: chunk.requestId });
95
+ // The operator is the ambient actor too, tenant included — what the entity's tenant guard
96
+ // reads — exactly as `mcp.ts` installs an agent for its call.
97
+ return withChildContext(
98
+ {
99
+ actor: userActor({
100
+ id: actor.id,
101
+ roles: [...(actor.roles ?? [])],
102
+ ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
103
+ }),
104
+ },
105
+ () => runBatchRows({ resource, action, ctx, ids: chunk.ids, input: inputOf(chunk.input) }),
106
+ );
107
+ }
108
+
109
+ /**
110
+ * The job, declared on first use and once per process. `tenant: 'none'` because the TENANT is
111
+ * the operator's and the body installs it (`runBatchChunk`); a chunk is one attempt — a handler
112
+ * that half-ran is not one to re-run, and every row it reached is already on the audit log.
113
+ */
114
+ export function adminBatchJob(): AnyJobHandle {
115
+ const seated = getJob(ADMIN_BATCH_JOB);
116
+ if (seated !== undefined) return seated;
117
+ return job({
118
+ name: ADMIN_BATCH_JOB,
119
+ input: chunkInput,
120
+ idempotencyKey: (chunk) => `${ADMIN_BATCH_JOB}:${chunk.batchId}:${String(chunk.index)}`,
121
+ tenant: 'none',
122
+ retry: { attempts: 1, backoff: 'fixed' },
123
+ // A chunk's input is the server's own, written by `batchEnqueue` below: parsed, never trusted
124
+ // past what it names — the rows are decided again, one by one, on the worker.
125
+ run: ({ input }) => runBatchChunk(input),
126
+ }) as AnyJobHandle;
127
+ }
128
+
129
+ /** What `runAdminBatch` queues a chunk with: one `admin.batch` job, its id answered. */
130
+ export const batchEnqueue =
131
+ (basePath: string): BatchEnqueue =>
132
+ async (chunk) => {
133
+ const actor = chunk.ctx.actor;
134
+ const queued = await adminBatchJob().enqueue({
135
+ basePath,
136
+ entity: chunk.resource.name,
137
+ action: chunk.action.name,
138
+ ids: [...chunk.ids],
139
+ input: jsonText(chunk.input),
140
+ batchId: chunk.batchId,
141
+ index: chunk.index,
142
+ requestId: chunk.ctx.requestId,
143
+ actor: {
144
+ id: actor.id,
145
+ roles: [...(actor.roles ?? [])],
146
+ ...(actor.orgId === undefined ? {} : { orgId: actor.orgId }),
147
+ ...(actor.locale === undefined ? {} : { locale: actor.locale }),
148
+ ...(actor.timeZone === undefined ? {} : { timeZone: actor.timeZone }),
149
+ },
150
+ } satisfies AdminBatchChunk);
151
+ return queued.id;
152
+ };
@@ -0,0 +1,87 @@
1
+ // "All matching" as ONE set-based call: an action that declares `matching` is handed the list's own
2
+ // `where` and answers `{ affected, remaining }` — the shape of a store's bounded bulk verb. Decided
3
+ // by the batch's gate, confirmed as a set, audited as one entry; `batch.ts` routes here.
4
+
5
+ import type { AdminDecision } from './authz';
6
+ import type { CrudCtx } from './crud';
7
+ import { atomicallyAudited } from './crud-outcome';
8
+ import { type AdminListRequest, listWhere } from './list-scope';
9
+ import { ADMIN_DESTROY, CONFIRMATION_REQUIRED_REASON, confirmationToken } from './permissions';
10
+ import type { AdminAction, AdminActionCtx, AdminMatchingResult } from './registry';
11
+ import type { AdminResource } from './resource';
12
+
13
+ /** The reason the one entry of a set-based batch carries. */
14
+ export const BATCH_MATCHING_REASON = 'admin.batch.matching';
15
+ const MATCHING_FAILED_REASON = 'admin.error.action-failed';
16
+
17
+ /** The token a destructive set-based batch is typed against: the count is the store's to know. */
18
+ export const matchingConfirmationToken = (entity: string): string =>
19
+ confirmationToken(entity, 'all matching');
20
+
21
+ export type MatchingAnswer =
22
+ | { readonly ok: true; readonly result: AdminMatchingResult }
23
+ | { readonly ok: false; readonly token: string; readonly decision: AdminDecision };
24
+
25
+ export interface MatchingInput {
26
+ readonly resource: AdminResource;
27
+ readonly action: AdminAction & Required<Pick<AdminAction, 'matching'>>;
28
+ readonly ctx: CrudCtx;
29
+ readonly request: Pick<AdminListRequest, 'scope' | 'filters'>;
30
+ /** Already validated against the action's schema. */
31
+ readonly input: Readonly<Record<string, unknown>>;
32
+ readonly confirmation?: string | undefined;
33
+ /** The batch's own grant, decided before this is called. */
34
+ readonly decision: AdminDecision;
35
+ }
36
+
37
+ export async function runMatching(args: MatchingInput): Promise<MatchingAnswer> {
38
+ const { resource, action, ctx } = args;
39
+ // `expected`, as the gate names it: a typed-to-confirm echo the page prints, not a secret.
40
+ const expected = matchingConfirmationToken(resource.name);
41
+ if (action.destructive === true && args.confirmation !== expected) {
42
+ return {
43
+ ok: false,
44
+ token: expected,
45
+ decision: {
46
+ allowed: false,
47
+ permission: ADMIN_DESTROY,
48
+ reason: CONFIRMATION_REQUIRED_REASON,
49
+ trace: [`confirmation: expected "${expected}"`],
50
+ },
51
+ };
52
+ }
53
+ // The list's own composition, the row scope first: an org-scoped operator's set is that org's.
54
+ const { where } = listWhere(resource, ctx.actor, args.request);
55
+ const actionCtx: AdminActionCtx = {
56
+ requestId: ctx.requestId,
57
+ actorId: ctx.actor.id,
58
+ locale: ctx.actor.locale ?? 'en',
59
+ timeZone: ctx.actor.timeZone ?? 'UTC',
60
+ };
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
+ );
86
+ return { ok: true, result };
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
+ }