@ultimat3/admin 23.0.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.
- package/CLAUDE.md +22 -23
- package/README.md +12 -8
- package/package.json +16 -16
- package/src/action-gate.ts +15 -41
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +8 -43
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/dev/data.ts +3 -6
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +4 -2
- package/src/dev/server.ts +1 -4
- package/src/dev/sql-runner.ts +31 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +42 -1
- package/src/form.tsx +19 -5
- package/src/index.ts +3 -2
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +3 -3
- package/src/keyset-seek.ts +189 -0
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/mcp.ts +4 -0
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +10 -8
- package/src/registry.ts +2 -1
- package/src/relations.ts +25 -9
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/row-version.ts +20 -0
- package/src/screen-resource.tsx +56 -8
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +10 -2
- package/src/widgets.tsx +2 -6
package/CLAUDE.md
CHANGED
|
@@ -9,51 +9,50 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
9
9
|
- **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
|
|
10
10
|
- **The subject carries the tenant and the loaded row.** `AdminActor.orgId` reaches `policyActor` and `AdminSubject.row` reaches `evaluate`; `adminDetail`/`adminUpdate`/`adminDestroy` load the row BEFORE the guard. `undefined` = not loaded (left off the subject), `null` = looked and found none (a row rule fails closed). Pinned in `policy-bridge.test.ts`, which asserts the SUBJECT a policy receives.
|
|
11
11
|
- **The MCP caller is the ambient actor for the whole call, tenant included.** `mcp.ts` wraps `callAdminTool` in a child context (a fresh root one over stdio); `resolveToken` and `adminActorOf` both carry `orgId`. Pinned in `mcp-context.test.ts`, `mcp-tenant.test.ts`.
|
|
12
|
-
- **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out.
|
|
12
|
+
- **`defineAdmin({ entities, db })` is a served admin; a host writes nothing.** `routes.ts` gives EVERY route a screen (`screens.ts` — a `Record<AdminView, …>`, so a new view cannot mount with nothing behind it) and `AdminRouteConfig.respond(request)` is the only way in: GET or a posted form in, `{ status, body }` or a 303 out. `repo-entity.ts` is the ONE adapter from the typed handle to `AdminRepo`; it adds no predicate (tenancy, soft delete, sealing and invariants are the handle's) and never spreads a row. A resource with neither a table in `db` nor a `repo:` is `X_ADMIN_REPO_UNBOUND` at `defineAdmin`. Pinned in `mounted-screens.test.ts`, `repo-entity.test.ts`, `admin-bind.test.ts`.
|
|
13
13
|
- **A screen's guard is composed, never written.** A resource screen asks `crud.ts` FIRST and renders what it answered — only it holds the row a row-level rule reads. Every other screen (a `pages:` entry, dashboard, jobs, audit, search) is built through `guardedScreen()` (`screen-frame.tsx`): `decideAll` over the route's permissions, the refusal audited (`auditRefusal`), answered 403. `pages.ts` composes `[admin:read, …declared]`; `AdminPageProps.ctx` is required by the type; an empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin`.
|
|
14
14
|
- **No screen ships a script.** Every route is `ssr` + `hydrate: 'never'`, so a control is a link or a native form: `<Pagination hrefFor>`, `<DataTable sortHrefFor>`, scope tabs as links, the filter bar one GET form, a form posted at the URL that rendered it, an action posted at its ROW's URL (`_operation`). `form-decode.ts` is the one place posted strings become typed input, per widget — an empty box of a typed widget (money, number, instant, JSON) is `null`, never `''`.
|
|
15
|
-
- **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name
|
|
15
|
+
- **A list's state is its URL, and a parameter the resource does not derive is REFUSED.** `list-request.ts` is the one parser and builder — `?cursor=`, `?sort=<field>:<asc|desc>`, `?scope=<name>` (`*` = none, not even the default; undeclarable), `?f.<field>[.<op>]=<value>` (repeat for `in`; empty = absent). `list-filters.ts` owns the operators per filter shape and the ONE validator (`checkedFilter`) the URL and the MCP list tool's `where` both pass. Unknown field, operator, sort, scope or parameter, or a value that is not the column's type: `X_ADMIN_FILTER_INVALID`, a 400 naming what the list answers — never the unfiltered list.
|
|
16
16
|
- **One composition of predicates** (`list-scope.ts`): row scope (`rows`), then scope, then filters. Every read of a resource builds its `where` there, and every read of ONE row is `findRow` — never `repo.find` from a screen, `crud.ts` or `mcp.ts` — so a row outside `rows(actor)` is "not found" on list, search, detail, edit, action, lookup, labels and MCP alike (`row-scope.test.ts`). App-declared predicates (`scopes`, `rows`) pass `declaredFilters`: any column, never a sealed one.
|
|
17
17
|
- **A widget never reads a row.** What a page shows of the resources it references is read ONCE per target by `relations.ts` (`relationsFor`: one statement, two for a picked target over `LOOKUP_SELECT_MAX`) and handed down as `WidgetContext.labelFor` / `optionsFor` / `lookupHref`. A target the actor may not list is an id with no name. `list-relations.test.ts` asserts the statement count at the driver. The picker is server-rendered: a `<select>` for a small target, else an id box + the target's lookup screen (`/admin/<resource>/lookup`, `screen-lookup.tsx`, `return` held to the admin's own paths).
|
|
18
18
|
- **A sealed column is never a filter, a scope or row-scope predicate, a label, a lookup key or a computed column's input** — `.sealed({ lookup: true })` included: a filter value rides a URL, and a URL is logged. `computedRow` drops sealed names from the row a column's `value` is handed.
|
|
19
19
|
- **The tenant column is the handle's, never an input** (`crud-input.ts`): not a form field, a list column or a filter; a create is stamped with the ACTOR's tenant and a patch never carries the column.
|
|
20
20
|
- **Computed columns are a closed renderer set** (`renderers.tsx`: `badge`, `relative-time`, `money`, `truncate`, `link`, `json`) or a component; `AdminComputedColumn` is typed over `AdminRow` so a resource stays covariant in `Row`.
|
|
21
|
+
- **A page seeks on the PAIR `(sort, id)`** (`keyset-seek.ts`): the rest of the boundary's class by id, then past it, then the NULLs (last ascending, first descending), so any number of ties and a nullable sort column are positions; the cursor carries `null`, never `''`. An instant's class is its MILLISECOND — a row's `Date` drops Postgres' microseconds — read whole by id. `repo-entity-keyset(.contract).test.ts` walks to the end and back.
|
|
21
22
|
- **A read by a mistyped id is a row that does not exist** — `repo-entity.ts`'s `find` answers `null` (the admin's 404); a write keeps the throw. An instant compared by the handle is a `Date`, never ISO text: the memory driver orders a string against a `Date` by its characters.
|
|
22
23
|
- **Every admin document is `noindex, nofollow`**, a refusal and a 404 included (`route-config.ts`).
|
|
23
|
-
- **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is
|
|
24
|
-
- **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package
|
|
24
|
+
- **A sealed column is written, never read.** `adminColumnsOf` skips `$meta.sealed`, so no field any screen reads by name exists for it; `resource.secretFields` is touched only by the form (a password input, never a `value`) and `crud.ts` (`splitSecrets`: empty = unchanged). The audit diff is derived from that list, not from what a row enumerates. Pinned in `sealed-egress.test.ts`.
|
|
25
|
+
- **The mount registry is on the global symbol registry** (`mounts.ts`, `Symbol.for('ultimate.admin.mounts')`), so the CLI can ask "did this app declare an admin?" without importing this package.
|
|
25
26
|
- **One `AdminAction.name`, one handler** — the MCP tool name, the default label key and `callAdminTool`'s lookup. A duplicate is `X_ADMIN_ACTION_DUPLICATE` at `defineAdmin`; identity, not name, is what "already seen" means.
|
|
26
27
|
- **A `/_x` source reads a registry through its DESCRIPTOR TYPE, never as an untyped bag** — `import type { RouteDescriptor }` beside the dynamic `import()`; `dev/published-keys.test.ts` walks what the registries EMIT. A route fact carries `mount` (`{ by, permissions }` or `null`).
|
|
27
|
-
- **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `
|
|
28
|
+
- **A fact `/_x` cannot know is absent or `null` — never a value that happens to be constant.** `JobDescriptor.idempotent` is published *because* `job()` refuses a definition without a key. `DbPanelData.drift` is `null` when nothing wired the check, because drift is the entities against the DATABASE and `[]` claims a match nobody verified.
|
|
28
29
|
- **The routes are in the framework's ONE route list, as mounted routes** — `route-config.ts` calls `registerMountedRoutes` at `defineAdmin`, so `describeRoutes()` (`x routes`, `/_x`, `sw.js`, the manifest) lists each with `mount: { by: 'defineAdmin', permissions }` and `routeEntries()` holds none. `AdminApp.describe()` (`describe.ts`) is what the manifest's `admin` section records: filters, sorts, scopes, `rowScoped`, routes.
|
|
29
|
-
- **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`)
|
|
30
|
-
- **
|
|
30
|
+
- **The admin's stylesheets are claimed for `app/`** (`stylesheet-scope.ts`, `claimStylesheets`). `stylesheet-scope.test.ts`.
|
|
31
|
+
- **Importing this package declares NO permission.** `defineAdmin()` calls `declareAdminPermissions`, which registers `ADMIN_PERMISSIONS` plus what the mount derives (`policy-bridge.test.ts`).
|
|
32
|
+
- **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `route-config.ts` the only one calling `defineRoute`/`registerMountedRoutes`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph). Source only: a **test** declares the permissions its own `can()` fixtures use, and calls `defineAdmin()` in `beforeAll`, NEVER at module scope — the leak guard keeps what a file's module scope registered for every later file, so one module-scope admin closed the permission set for `bun test packages/admin packages/query`.
|
|
31
33
|
- **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
|
|
32
34
|
- **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
|
|
33
|
-
- **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (
|
|
35
|
+
- **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` — as do `batch.ts` (one row at a time) and `relations.ts` (label reads chunked at 200 ids, never cut). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
|
|
34
36
|
- **The timeline panel renders the detector's verdicts** (`DevSources.statementLoops()`), never a second count; none wired is `null`, never `[]`.
|
|
35
|
-
- **This catalog is the framework's one opt-OUT MCP surface,
|
|
36
|
-
`mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every
|
|
37
|
-
tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at
|
|
38
|
-
all, so opting in would list `admin.posts.delete` while hiding the action button beside it.
|
|
39
|
-
`mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in
|
|
40
|
-
`wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug.
|
|
37
|
+
- **This catalog is the framework's one opt-OUT MCP surface, deliberately**: `mcp-tools.ts` does not call `isMcpExposed` — every tool is already gated on an admin permission. `mcp: { expose: false }` withdraws one. A *second* exception anywhere is the bug.
|
|
41
38
|
- **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe"** — never cite it as a security property.
|
|
42
|
-
- **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider
|
|
43
|
-
- **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text —
|
|
39
|
+
- **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider — in `panel-live`, `panel-db`, `panel-timeline` and `panel-cache` alike (`dev/panel-degrade.test.ts`). Everything else reaches `panelPayload`, which renders its code and its fix.
|
|
40
|
+
- **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — the route table is `AdminApp`'s.
|
|
44
41
|
- **`panel-db.ts` executes the string `assertReadOnlyQuery` RETURNS**, never the textarea's bytes (`ReadOnlyVerdict`).
|
|
45
42
|
- **`decideAll([])` DENIES** (`admin.policy.none-declared`): an empty gate is a refusal for everyone, never a grant.
|
|
46
43
|
- **Two resources may not claim one `path:`** — `X_ADMIN_PAGE_PATH_INVALID` (`subject: 'resource'`) at `defineAdmin`, every generated path included.
|
|
47
44
|
- **The audit diff is TOTAL over every value a row holds** — compared with core's `canonicalJson`, never `JSON.stringify` (a `money()` bigint throws there, after the write committed).
|
|
48
|
-
- **A write
|
|
49
|
-
- **
|
|
45
|
+
- **A write and its entry commit together** (`atomicallyAudited`, `crud-outcome.ts`): a CRUD write, an action's handler, `matching` and a queued batch's enqueues each run inside `audit.atomic` with their `allowed` entry, so an entry that cannot be written rolls the work back; ONE `failed` entry is appended after, outside, and the thrown value is re-thrown UNCHANGED with nothing read off it. A schema refusal and a missing row are `failed` too. `audit-atomic(.contract).test.ts`.
|
|
46
|
+
- **An edit is optimistic** (`row-version.ts`): the form posts `_version` = `rowVersion(row)` (keyed), `adminUpdate(…, { version })` answers `stale` — 409, `admin.error.row-changed`, the form re-drawn over the row as it is now — when the row moved on; a post with no version is stale. An instant posted back as it was drawn (`datetimeInputValue`) is LEFT OUT of the patch (`decodeForm(…, before)`): `datetime-local` holds minutes. A create or update is decided again on the validated values as `subject.input` (update: beside `row`).
|
|
47
|
+
- **The audit trail is the actor's tenant's**: `/admin/audit` and the history card pass `orgId` to `entries()` when the actor has one (`audit-tenancy.test.ts`).
|
|
50
48
|
- **A table read by a runtime key is `Object.hasOwn` / a `Map`, never a bare index** — `impliedBy`, the `/_x` counters, `list-filters.ts`' operator table, `renderers.tsx` (`bun run proto-index`).
|
|
49
|
+
- **The `/_x` SQL runs in `readOnlyQuery`** (`dev/sql-runner.ts`, `readOnlySql(client)` — what a host wires as `runSql`): read-only transaction, timeout, `DEV_SQL_MAX_ROWS`.
|
|
51
50
|
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s** — no local keyword scan; the panel keeps only the emptiness test and the way out (`psql "$DATABASE_URL"`, the developer's own client — `x db` has no `psql`).
|
|
52
|
-
- **Every admin operation is audited, reads included.** `
|
|
51
|
+
- **Every admin operation is audited, reads included.** `ListResult` carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`). `AdminSearchResult.audit` carries one entry per resource it decided about — `allowed` per searched resource, a `deniedDraft` per refused one; a resource skipped for no text field, no repo or a repo that THREW (`admin.search.skipped.failed`) is listed in `skipped`, never a 500 for the rest.
|
|
53
52
|
- **A write's RESULT satisfies the actor's row scope** (`row-scope-write.ts`): a create, and an update over the fields it touches, outside `rows(actor)` is refused before the repo — `admin.error.row-out-of-scope`, audited as a denial. The one predicate evaluator in this package; an ordering over text is refused, never guessed. `row-scope-write.test.ts`.
|
|
54
53
|
- **An action's `when(row)` is asked by the button AND by the call** (`actionApplies`, `action-gate.ts`): a row it excludes is `X_ADMIN_ACTION_NOT_APPLICABLE` (409 on a screen, the code over MCP), audited `admin.error.action-not-applicable`. The row it reads carries no sealed column. Every action on one row runs through `invokeRowAction` (`findRow` first).
|
|
55
54
|
- **An action with an `input` schema is a form, never a bare post**: its button links to `<row>?action=<name>`, the form posts `input.<field>` (`action-input.ts`, through `tryIntrospect`), the gate validates against the schema and a refusal is a 422 with issues on their fields. No schema: a confirm only.
|
|
56
|
-
- **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler; counts `done`/`refused`/`failed`/`queued`/`remaining`; one audit entry per row. Selection is checked ids or "all matching" — the list URL's own `listWhere`, keyset by id, `MAX_BATCH_ROWS` inline / `MAX_BATCH_QUEUED_ROWS` queued per request. `batch: { threshold }` queues one `admin.batch` job per chunk (`batch-job.ts`)
|
|
55
|
+
- **A batch is the button's gate once per row** (`batch.ts`): policy, `when`, handler; counts `done`/`refused`/`failed`/`queued`/`remaining`; one audit entry per row. Selection is checked ids or "all matching" — the list URL's own `listWhere`, keyset by id, `MAX_BATCH_ROWS` inline / `MAX_BATCH_QUEUED_ROWS` queued per request. `batch: { threshold }` queues one `admin.batch` job per chunk (`batch-queue.ts`, `batch-job.ts`) under a DERIVED `batchIdOf` (actor, action, rows, input — not the request id), so a retry dedupes; the worker runs it AS the operator. Zero JS: the bar is one form (`batch-bar.tsx`), row checkboxes join it by `form=`; a destructive or input-taking batch is a server round trip. ONE MCP tool per action — a batch action's tool takes `ids`.
|
|
57
56
|
- **Sections and form groups never hide a field** (`resource-layout.ts`): undeclared fields fall into a default group drawn last; a name that is not a drawn field, or claimed twice, is refused at `defineAdmin`. `on: 'create' | 'update'` is enforced on the write too (`onlyFor`, `crud-input.ts`).
|
|
58
57
|
- **A related list IS the related resource's list** (`related.ts`): a `hasMany` of the entity, read through `adminList` (its gate, its `rows`), filtered to this row; a resource the actor may not list is left out. One label read per target for the whole detail page.
|
|
59
58
|
- **The audit log is the store, a sink is a copy.** `AuditLog.entries()` is async, keyset (`before`), `changes: true` drops allowed reads; `atomic(run)` puts a write and its entry in one transaction (`auditedWrite`). `postgresAuditLog()` writes `x_admin_audit` (`audit-pg.ts`), skips allowed reads unless `reads: true`. `audit-pg.contract.test.ts` boots PGlite.
|
|
@@ -61,7 +60,7 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
61
60
|
- **A job-store predicate the store cannot answer is refused by name** (`job-where.ts`, `X_ADMIN_FILTER_INVALID`): one order (`createdAt desc`), equality on state/queue/name/tenant, an id PREFIX for the id box. `listScreen` answers a repo's refusal 400, as the URL's. "All matching" over jobs is `AdminAction.matching` onto the store's bulk verbs, never a walk.
|
|
62
61
|
- **A resource's permissions are named after its `permission` noun** (`resource.permission`, default the entity name) — every `permissionsForOperation` site reads it, never `resource.name`.
|
|
63
62
|
- **`matching` is "all matching" as ONE call** (`batch-matching.ts`): the list's `where`, gated by the batch's grant, confirmed as `<entity>:all matching`, audited once (`admin.batch.matching`). A checked selection never reaches it.
|
|
64
|
-
- **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
|
|
63
|
+
- **An action on a row the actor cannot see is refused, `when` or none** (`actionApplies`: a `null` row is no row).
|
|
65
64
|
- **An action's input field is labelled `admin.input.<action>.<field>`** — never under `admin.action.<action>`, which is a leaf: a JSON catalog cannot hold both.
|
|
66
65
|
- **`x_admin_audit`'s DDL is the leaf `audit-schema.ts`** (`@ultimat3/admin/schema`): the boot applies it in every role; `serve-graph.test.ts` exempts exactly that file.
|
|
67
66
|
- Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
|
|
@@ -84,12 +83,12 @@ Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev
|
|
|
84
83
|
| `list-request.ts` / `list-filters.ts` / `list-scope.ts` | URL ⇄ list request / operators per shape + the validator / row scope → scope → filters, `findRow`, `scopeCounts` |
|
|
85
84
|
| `list.tsx` / `list-filter-bar.tsx` / `list-scope-tabs.tsx` / `renderers.tsx` | the list view / one GET form / tabs as links / computed cells |
|
|
86
85
|
| `resource-list.ts` / `relations.ts` / `describe.ts` | `scopes`·`rows`·`columns` normalised / labels + lookup, one read per target / the admin as manifest data |
|
|
87
|
-
| `repo-entity.ts` / `form-decode.ts` | typed handle → `AdminRepo` / posted form → typed input |
|
|
86
|
+
| `repo-entity.ts` / `keyset-seek.ts` / `form-decode.ts` / `row-version.ts` | typed handle → `AdminRepo` / the `(sort, id)` seek / posted form → typed input / an edit's version |
|
|
88
87
|
| `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
|
|
89
88
|
| `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
|
|
90
89
|
| `crud.ts` / `crud-input.ts` / `crud-outcome.ts` / `action-gate.ts` / `row-scope-write.ts` | policy → confirmation → validation → repo → audit / secrets, tenant, sides, diff / the entry of a write that did not happen / a write's result vs `rows` |
|
|
91
90
|
| `action-row.ts` / `action-input.ts` / `action-form.tsx` / `screen-action.tsx` | one action on one row / input schema ⇄ form fields / the action's form / row-action and batch writes |
|
|
92
|
-
| `batch.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / the bar and row checkboxes / the `admin.batch` job |
|
|
91
|
+
| `batch.ts` / `batch-queue.ts` / `batch-bar.tsx` / `batch-job.ts` | a batch through the gate / its queued half / the bar and row checkboxes / the `admin.batch` job |
|
|
93
92
|
| `resource-fields.ts` / `resource-layout.ts` / `related.ts` / `list-columns.tsx` | one column → one field / sections and form groups / related lists / a resource's table columns |
|
|
94
93
|
| `audit.ts` / `audit-pg.ts` / `json-text.ts` | the log interface + memory log / the Postgres log / readable JSON for a cell or a diff |
|
|
95
94
|
| `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
|
package/README.md
CHANGED
|
@@ -21,7 +21,7 @@ One panel per file. Each kills one question, and each is available as `--json`
|
|
|
21
21
|
| `timeline` | where did the time go? — flamegraph of SQL, cache, action, policy spans + the N+1 count |
|
|
22
22
|
| `live` | what does each subscriber receive, and **why** — the matcher's decision trace |
|
|
23
23
|
| `jobs` | the admin's own jobs overview (`/admin/jobs`'s component) over this process's queue; the payload — queue depth, step traces, retry-from-step target, dead letter — folded under it |
|
|
24
|
-
| `db` | psql in a tab (read-only
|
|
24
|
+
| `db` | psql in a tab (read-only twice: `assertReadOnly` refuses DML, and `readOnlySql(client)` — what a host wires as `runSql` — runs it in a `BEGIN READ ONLY` transaction with a timeout and `DEV_SQL_MAX_ROWS`), schema + drift (`null` unless a host wires the check) |
|
|
25
25
|
| `mail` | caught mail, rendered, per locale, with the locale gaps listed |
|
|
26
26
|
| `cache` | the tag graph — what invalidated what, and which tags are orphans |
|
|
27
27
|
| `policy` | the permission matrix per actor, every cell carrying its trace |
|
|
@@ -91,11 +91,11 @@ The list's whole state is its URL — one parser and one builder (`pageRequestOf
|
|
|
91
91
|
|
|
92
92
|
| Parameter | Means | Refused when |
|
|
93
93
|
|---|---|---|
|
|
94
|
-
| `?cursor=<signed>` | keyset position | never — an unreadable cursor is page one |
|
|
94
|
+
| `?cursor=<signed>` | keyset position on `(sort, id)` — any number of tied values, and NULLs (last ascending, first descending), are reached | never — an unreadable cursor is page one |
|
|
95
95
|
| `?sort=<field>:<asc\|desc>` | order; a bare field is `desc` | the field is not sortable, or the direction is neither |
|
|
96
|
-
| `?scope=<name>` | a declared scope; absent is the default one | no scope has that name |
|
|
96
|
+
| `?scope=<name>` | a declared scope; absent is the default one; `*` is NO scope, not even the default (a related card's "all" link) | no scope has that name; `*` cannot be declared |
|
|
97
97
|
| `?f.<field>=<value>` | a filter, by the field's default operator | the field is not a filter of the resource |
|
|
98
|
-
| `?f.<field>.<op>=<value>` | a named operator; repeat the parameter for `in` | the field's shape does not answer `<op>`, or the value is not the column's type |
|
|
98
|
+
| `?f.<field>.<op>=<value>` | a named operator; repeat the parameter for `in` | the field's shape does not answer `<op>`, or the value is not the column's type — a number is decimal digits (no blank, hex or exponent), a date is one the calendar has |
|
|
99
99
|
| anything else | — | always |
|
|
100
100
|
|
|
101
101
|
A refusal is `X_ADMIN_FILTER_INVALID`, a 400 naming what the list does answer — never the
|
|
@@ -179,8 +179,9 @@ export const resources: Readonly<Record<string, AdminResourceOptions>> = {
|
|
|
179
179
|
| `on` | the field is an input on one side only, and a write from the other side drops it |
|
|
180
180
|
| `hintKey` | the line under the control, through `t()` |
|
|
181
181
|
|
|
182
|
-
The detail page's history card is `audit.entries({ entity, entityId, changes: true })
|
|
183
|
-
|
|
182
|
+
The detail page's history card is `audit.entries({ entity, entityId, changes: true })` — and
|
|
183
|
+
`orgId` when the actor has one, as on `/admin/audit` — newest first, 20 per page, older pages by
|
|
184
|
+
keyset (`?history=`). Statements a detail page issues: 1 for
|
|
184
185
|
the row (2 with `rows`), 1 per related list, 1 per referenced target for the whole page, 1 for
|
|
185
186
|
the history with `postgresAuditLog`.
|
|
186
187
|
|
|
@@ -249,7 +250,9 @@ admin's own projection. `/_x/jobs` draws the same overview component over the de
|
|
|
249
250
|
### The audit log
|
|
250
251
|
|
|
251
252
|
`postgresAuditLog()` writes one row per audited write to `x_admin_audit`, in the same
|
|
252
|
-
transaction as the write (`AuditLog.atomic`)
|
|
253
|
+
transaction as the write (`AuditLog.atomic`) — a CRUD write, an action's handler, a `matching`
|
|
254
|
+
set and a queued batch's enqueues alike, so an entry that cannot be written rolls the work back
|
|
255
|
+
and the log holds one `failed` entry — and reads it back by keyset. An allowed READ is not
|
|
253
256
|
written unless `reads: true` — a refused one always is. Sealed and `sensitive` values reach the
|
|
254
257
|
table only as `[redacted]`. `memoryAuditLog()` is a ring that forgets at every restart. The
|
|
255
258
|
table is applied at boot with every other framework table (`@ultimat3/cli`'s `FRAMEWORK_SCHEMA`),
|
|
@@ -406,6 +409,7 @@ shape already is, written down once so two apps do not invent two layouts. `x g
|
|
|
406
409
|
| A button an actor cannot press is never rendered, and the call is refused by the same decision | `action-gate.ts` |
|
|
407
410
|
| Destructive operations re-confirm (`<entity>:<id>`) and are always audited | `permissions.ts`, `crud.ts` |
|
|
408
411
|
| Every mutation and every denial is on the audit log, with a before/after diff | `audit.ts` |
|
|
412
|
+
| An edit carries the row's version (`_version`); a row changed since the form was drawn is a 409, never overwritten. An instant posted back as drawn is left out of the patch | `row-version.ts`, `form-decode.ts` |
|
|
409
413
|
| Branding aliases tokens only — `accent: '#7c3aed'` is a compile error | `theme.ts` |
|
|
410
414
|
|
|
411
415
|
Reads are audited too, and on both branches: `adminDetail` keys its entry on the row,
|
|
@@ -460,4 +464,4 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
460
464
|
| `AdminPolicyMissingError` | `X_ADMIN_POLICY_MISSING` | `src/errors.ts` |
|
|
461
465
|
| `AdminRepoUnboundError` | `X_ADMIN_REPO_UNBOUND` | `src/errors.ts` |
|
|
462
466
|
| `DevDashboardInProdError` | `X_DEV_DASHBOARD_IN_PROD` | `src/errors.ts` |
|
|
463
|
-
| `DevSourceUnavailableError` | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
|
467
|
+
| `DevSourceUnavailableError` (extends core's `NotImplementedError`) | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -34,20 +34,20 @@
|
|
|
34
34
|
"test": "bun test"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "
|
|
38
|
-
"@ultimat3/ai": "
|
|
39
|
-
"@ultimat3/cache": "
|
|
40
|
-
"@ultimat3/core": "
|
|
41
|
-
"@ultimat3/db": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/i18n": "
|
|
44
|
-
"@ultimat3/jobs": "
|
|
45
|
-
"@ultimat3/mcp": "
|
|
46
|
-
"@ultimat3/money": "
|
|
47
|
-
"@ultimat3/policy": "
|
|
48
|
-
"@ultimat3/query": "
|
|
49
|
-
"@ultimat3/render": "
|
|
50
|
-
"@ultimat3/schema": "
|
|
51
|
-
"@ultimat3/ui": "
|
|
37
|
+
"@ultimat3/action": "24.0.0",
|
|
38
|
+
"@ultimat3/ai": "24.0.0",
|
|
39
|
+
"@ultimat3/cache": "24.0.0",
|
|
40
|
+
"@ultimat3/core": "24.0.0",
|
|
41
|
+
"@ultimat3/db": "24.0.0",
|
|
42
|
+
"@ultimat3/entity": "24.0.0",
|
|
43
|
+
"@ultimat3/i18n": "24.0.0",
|
|
44
|
+
"@ultimat3/jobs": "24.0.0",
|
|
45
|
+
"@ultimat3/mcp": "24.0.0",
|
|
46
|
+
"@ultimat3/money": "24.0.0",
|
|
47
|
+
"@ultimat3/policy": "24.0.0",
|
|
48
|
+
"@ultimat3/query": "24.0.0",
|
|
49
|
+
"@ultimat3/render": "24.0.0",
|
|
50
|
+
"@ultimat3/schema": "24.0.0",
|
|
51
|
+
"@ultimat3/ui": "24.0.0"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/action-gate.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
type AdminSubject,
|
|
13
13
|
decideAll,
|
|
14
14
|
} from './authz';
|
|
15
|
+
import { atomicallyAudited } from './crud-outcome';
|
|
15
16
|
import { AdminActionNotApplicableError } from './errors';
|
|
16
17
|
import {
|
|
17
18
|
ADMIN_DESTROY,
|
|
@@ -278,51 +279,24 @@ export async function invokeAdminAction<Input, Output>(
|
|
|
278
279
|
timeZone: args.timeZone ?? actor.timeZone ?? 'UTC',
|
|
279
280
|
};
|
|
280
281
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
actor,
|
|
289
|
-
operation: action.name,
|
|
290
|
-
kind: 'action',
|
|
291
|
-
entity,
|
|
292
|
-
entityId,
|
|
293
|
-
permission: action.permission,
|
|
294
|
-
outcome: 'allowed',
|
|
295
|
-
reason: decision.reason,
|
|
296
|
-
diff: args.diff ?? [],
|
|
297
|
-
}),
|
|
298
|
-
};
|
|
299
|
-
} catch (error) {
|
|
300
|
-
// NOTHING is read off `error` before the append, and nothing is read off it at all.
|
|
301
|
-
//
|
|
302
|
-
// This built an `AdminDecision` first, whose `trace` rendered the caught value with
|
|
303
|
-
// `String(error)` — and a `catch` binding is annotated by nobody, so it holds whatever an
|
|
304
|
-
// app's handler threw. `Object.create(null)` has no `toString`, no `valueOf` and no
|
|
305
|
-
// `Symbol.toPrimitive`, so `String(it)` raises `TypeError: No default value` from inside the
|
|
306
|
-
// block that owes the auditor an entry: measured, ZERO entries, and the caller received the
|
|
307
|
-
// TypeError instead of what was thrown. Ordering was the second half — the render ran BEFORE
|
|
308
|
-
// `append`, so its throw skipped the append rather than merely spoiling one field.
|
|
309
|
-
//
|
|
310
|
-
// The decision object was also DEAD: only its `reason` was ever read, and `append` takes no
|
|
311
|
-
// trace. So there is no destination for a rendered value here and `renderThrowable` is not
|
|
312
|
-
// needed either — an audit reason is a key the view renders, never a sentence from a
|
|
313
|
-
// database, an upstream or an attacker.
|
|
314
|
-
await audit.append({
|
|
282
|
+
// The handler and its entry are ONE unit (`atomicallyAudited`): a handler whose writes ride the
|
|
283
|
+
// ambient transaction commits with its `allowed` entry or not at all, and a throw from either
|
|
284
|
+
// leaves exactly one `failed` entry, appended after the rollback, with nothing read off the
|
|
285
|
+
// thrown value — which reaches the caller unchanged.
|
|
286
|
+
const { value, audit: appended } = await atomicallyAudited(
|
|
287
|
+
audit,
|
|
288
|
+
{
|
|
315
289
|
requestId,
|
|
316
290
|
actor,
|
|
317
291
|
operation: action.name,
|
|
318
292
|
kind: 'action',
|
|
319
293
|
entity,
|
|
320
|
-
entityId,
|
|
321
294
|
permission: action.permission,
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
|
|
295
|
+
},
|
|
296
|
+
{ allowed: decision.reason, failed: ACTION_FAILED_REASON },
|
|
297
|
+
entityId,
|
|
298
|
+
() => action.handle({ input, ctx }),
|
|
299
|
+
() => ({ entityId, diff: args.diff ?? [] }),
|
|
300
|
+
);
|
|
301
|
+
return { ok: true, value, audit: appended };
|
|
328
302
|
}
|
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
|
@@ -6,9 +6,9 @@ import { finiteCount } from '@ultimat3/core';
|
|
|
6
6
|
import { expectedQueryLoop } from '@ultimat3/db';
|
|
7
7
|
import { 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
14
|
import {
|
|
@@ -35,9 +35,6 @@ export const MAX_BATCH_ROWS = 200;
|
|
|
35
35
|
*/
|
|
36
36
|
export const MAX_BATCH_QUEUED_ROWS = 1_000;
|
|
37
37
|
|
|
38
|
-
/** The reason a queued batch's one entry carries. */
|
|
39
|
-
export const BATCH_QUEUED_REASON = 'admin.batch.queued';
|
|
40
|
-
|
|
41
38
|
/** The reason an action that is not a batch action is refused the batch bar with. */
|
|
42
39
|
export const BATCH_NOT_OFFERED_REASON = 'admin.error.batch-not-offered';
|
|
43
40
|
|
|
@@ -185,29 +182,6 @@ async function select(
|
|
|
185
182
|
return { ids, remaining };
|
|
186
183
|
}
|
|
187
184
|
|
|
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
185
|
/**
|
|
212
186
|
* Run `action` over a selection of `resource`'s rows.
|
|
213
187
|
*
|
|
@@ -285,22 +259,13 @@ export async function runAdminBatch(input: AdminBatchInput): Promise<AdminBatchA
|
|
|
285
259
|
};
|
|
286
260
|
|
|
287
261
|
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);
|
|
262
|
+
const jobs = await queueBatch(
|
|
263
|
+
{ ...input, enqueue: input.enqueue },
|
|
264
|
+
decision,
|
|
265
|
+
plan.chunk,
|
|
266
|
+
selected.ids,
|
|
267
|
+
own,
|
|
268
|
+
);
|
|
304
269
|
return {
|
|
305
270
|
ok: true,
|
|
306
271
|
done: 0,
|