@ultimat3/admin 31.9.0 → 31.10.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/package.json +17 -17
- package/src/crud-outcome.ts +66 -4
- package/src/crud.ts +33 -4
- package/src/mcp-dispatch.ts +2 -1
- package/src/registry.ts +5 -0
- package/src/resource-fields.ts +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "31.
|
|
3
|
+
"version": "31.10.0",
|
|
4
4
|
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ultimate",
|
|
@@ -46,21 +46,21 @@
|
|
|
46
46
|
"test": "bun test"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@ultimat3/action": "31.
|
|
50
|
-
"@ultimat3/ai": "31.
|
|
51
|
-
"@ultimat3/cache": "31.
|
|
52
|
-
"@ultimat3/core": "31.
|
|
53
|
-
"@ultimat3/db": "31.
|
|
54
|
-
"@ultimat3/entity": "31.
|
|
55
|
-
"@ultimat3/http": "31.
|
|
56
|
-
"@ultimat3/i18n": "31.
|
|
57
|
-
"@ultimat3/jobs": "31.
|
|
58
|
-
"@ultimat3/mcp": "31.
|
|
59
|
-
"@ultimat3/money": "31.
|
|
60
|
-
"@ultimat3/policy": "31.
|
|
61
|
-
"@ultimat3/query": "31.
|
|
62
|
-
"@ultimat3/render": "31.
|
|
63
|
-
"@ultimat3/schema": "31.
|
|
64
|
-
"@ultimat3/ui": "31.
|
|
49
|
+
"@ultimat3/action": "31.10.0",
|
|
50
|
+
"@ultimat3/ai": "31.10.0",
|
|
51
|
+
"@ultimat3/cache": "31.10.0",
|
|
52
|
+
"@ultimat3/core": "31.10.0",
|
|
53
|
+
"@ultimat3/db": "31.10.0",
|
|
54
|
+
"@ultimat3/entity": "31.10.0",
|
|
55
|
+
"@ultimat3/http": "31.10.0",
|
|
56
|
+
"@ultimat3/i18n": "31.10.0",
|
|
57
|
+
"@ultimat3/jobs": "31.10.0",
|
|
58
|
+
"@ultimat3/mcp": "31.10.0",
|
|
59
|
+
"@ultimat3/money": "31.10.0",
|
|
60
|
+
"@ultimat3/policy": "31.10.0",
|
|
61
|
+
"@ultimat3/query": "31.10.0",
|
|
62
|
+
"@ultimat3/render": "31.10.0",
|
|
63
|
+
"@ultimat3/schema": "31.10.0",
|
|
64
|
+
"@ultimat3/ui": "31.10.0"
|
|
65
65
|
}
|
|
66
66
|
}
|
package/src/crud-outcome.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// row that was not there, a repo that threw. Each leaves its entry and answers the result the
|
|
3
3
|
// caller renders — so `crud.ts` reads as the five steps of each operation and nothing else.
|
|
4
4
|
|
|
5
|
+
import { isUltimateError } from '@ultimat3/core';
|
|
5
6
|
import {
|
|
6
7
|
type AuditDraft,
|
|
7
8
|
type AuditEntry,
|
|
@@ -73,14 +74,18 @@ export type AuditedEntry = Pick<
|
|
|
73
74
|
* say `failed` for an action that had committed, and an operator re-ran it.
|
|
74
75
|
*
|
|
75
76
|
* The `failed` entry is written OUTSIDE, after the rollback — inside, it would be rolled back with
|
|
76
|
-
* the write it reports. Nothing about the thrown value is
|
|
77
|
+
* the write it reports. Nothing about the thrown value is rendered (a `catch` binding holds
|
|
77
78
|
* whatever an app threw, and `String()` of a prototype-less object throws from inside the block
|
|
78
|
-
* that owes the auditor its entry); the caller gets it UNCHANGED, and an audit reason is a key
|
|
79
|
+
* that owes the auditor its entry); the caller gets it UNCHANGED, and an audit reason is a key —
|
|
80
|
+
* one `reasons.failed` may pick by asking the TOTAL `isUltimateError` for a code, nothing more.
|
|
79
81
|
*/
|
|
80
82
|
export async function atomicallyAudited<T>(
|
|
81
83
|
audit: AuditLog,
|
|
82
84
|
entry: AuditedEntry,
|
|
83
|
-
reasons: {
|
|
85
|
+
reasons: {
|
|
86
|
+
readonly allowed: string;
|
|
87
|
+
readonly failed: string | ((error: unknown) => string);
|
|
88
|
+
},
|
|
84
89
|
/** The id of the row the write addresses, for the `failed` entry: `null` for a set or a create. */
|
|
85
90
|
entityId: string | null,
|
|
86
91
|
write: () => Promise<T>,
|
|
@@ -89,6 +94,8 @@ export async function atomicallyAudited<T>(
|
|
|
89
94
|
readonly entityId: string | null;
|
|
90
95
|
readonly diff: readonly AuditFieldDiff[];
|
|
91
96
|
},
|
|
97
|
+
/** Handed the `failed` entry before the error is rethrown — `auditedUpdate` answers with it. */
|
|
98
|
+
failedWith?: (entry: AuditEntry) => void,
|
|
92
99
|
): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
|
|
93
100
|
try {
|
|
94
101
|
return await audit.atomic(async () => {
|
|
@@ -102,7 +109,9 @@ export async function atomicallyAudited<T>(
|
|
|
102
109
|
return { value, audit: appended };
|
|
103
110
|
});
|
|
104
111
|
} catch (error) {
|
|
105
|
-
|
|
112
|
+
const reason = typeof reasons.failed === 'string' ? reasons.failed : reasons.failed(error);
|
|
113
|
+
const failed = await audit.append({ ...entry, entityId, outcome: 'failed', reason, diff: [] });
|
|
114
|
+
failedWith?.(failed);
|
|
106
115
|
throw error;
|
|
107
116
|
}
|
|
108
117
|
}
|
|
@@ -139,6 +148,59 @@ export function auditedWrite<T>(
|
|
|
139
148
|
);
|
|
140
149
|
}
|
|
141
150
|
|
|
151
|
+
/** The entity's version lock refused the write: the row moved between the admin's read and it. */
|
|
152
|
+
const lostRace = (error: unknown): boolean =>
|
|
153
|
+
isUltimateError(error) && error.code === 'X_ENTITY_STALE';
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `auditedWrite` for an update. A write the entity's declared lock refuses (`X_ENTITY_STALE`) is
|
|
157
|
+
* the same conflict the form's `_version` check answers, only later: one `failed` entry keyed
|
|
158
|
+
* `admin.error.row-changed`, answered as `stale` rather than thrown. Anything else rethrows.
|
|
159
|
+
*/
|
|
160
|
+
export async function auditedUpdate<T>(
|
|
161
|
+
resource: { readonly name: string },
|
|
162
|
+
ctx: CrudCtx,
|
|
163
|
+
entityId: string,
|
|
164
|
+
decision: AdminDecision,
|
|
165
|
+
write: () => Promise<T>,
|
|
166
|
+
written: (value: T) => {
|
|
167
|
+
readonly entityId: string | null;
|
|
168
|
+
readonly diff: readonly AuditFieldDiff[];
|
|
169
|
+
},
|
|
170
|
+
): Promise<
|
|
171
|
+
| { readonly stale: false; readonly value: T; readonly audit: AuditEntry }
|
|
172
|
+
| { readonly stale: true; readonly audit: AuditEntry }
|
|
173
|
+
> {
|
|
174
|
+
let refused: AuditEntry | undefined;
|
|
175
|
+
try {
|
|
176
|
+
const done = await atomicallyAudited(
|
|
177
|
+
ctx.audit,
|
|
178
|
+
{
|
|
179
|
+
requestId: ctx.requestId,
|
|
180
|
+
actor: ctx.actor,
|
|
181
|
+
operation: 'update',
|
|
182
|
+
kind: 'operation',
|
|
183
|
+
entity: resource.name,
|
|
184
|
+
permission: decision.permission,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
allowed: decision.reason,
|
|
188
|
+
failed: (error) => (lostRace(error) ? ROW_CHANGED_REASON : WRITE_FAILED_REASON),
|
|
189
|
+
},
|
|
190
|
+
entityId,
|
|
191
|
+
write,
|
|
192
|
+
written,
|
|
193
|
+
(entry) => {
|
|
194
|
+
refused = entry;
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
return { stale: false, ...done };
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (refused !== undefined && lostRace(error)) return { stale: true, audit: refused };
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
142
204
|
/** The key a write to a row that is not there is logged with. */
|
|
143
205
|
const ROW_MISSING_REASON = 'admin.error.row-missing';
|
|
144
206
|
|
package/src/crud.ts
CHANGED
|
@@ -5,7 +5,15 @@
|
|
|
5
5
|
import type { AuditEntry, AuditLog } from './audit';
|
|
6
6
|
import { type AdminActor, type AdminAuthz, type AdminDecision, decideAll } from './authz';
|
|
7
7
|
import { onlyFor, rowDiff, splitSecrets, withoutTenant, withTenant } from './crud-input';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
auditedUpdate,
|
|
10
|
+
auditedWrite,
|
|
11
|
+
deniedEntry,
|
|
12
|
+
invalid,
|
|
13
|
+
missingRow,
|
|
14
|
+
refuse,
|
|
15
|
+
staleRow,
|
|
16
|
+
} from './crud-outcome';
|
|
9
17
|
import { type AdminListRequest, findRow, listWhere } from './list-scope';
|
|
10
18
|
import { type AdminPage, fetchPage } from './pagination';
|
|
11
19
|
import {
|
|
@@ -337,19 +345,40 @@ export async function adminUpdate<Row extends AdminRow>(
|
|
|
337
345
|
.map((key) => [key, parsed.value[key]]),
|
|
338
346
|
),
|
|
339
347
|
...secrets,
|
|
348
|
+
// A declared lock: the version the admin READ, which the form's `_version` just matched — so a
|
|
349
|
+
// write landing after that read is the entity's X_ENTITY_STALE, answered `stale` below.
|
|
350
|
+
...versionRead(resource, before, submittedKeys),
|
|
340
351
|
};
|
|
341
|
-
const
|
|
352
|
+
const outcome = await auditedUpdate(
|
|
342
353
|
resource,
|
|
343
|
-
'update',
|
|
344
354
|
ctx,
|
|
345
355
|
id,
|
|
346
356
|
written,
|
|
347
357
|
() => repo.update(id, validatedPatch),
|
|
348
358
|
(changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
|
|
349
359
|
);
|
|
350
|
-
return { ok: true, row:
|
|
360
|
+
if (!outcome.stale) return { ok: true, row: outcome.value, audit: outcome.audit };
|
|
361
|
+
const current = (await findRow(resource, ctx.actor, id)) ?? before;
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
kind: 'stale',
|
|
365
|
+
row: current,
|
|
366
|
+
version: rowVersion(current),
|
|
367
|
+
audit: outcome.audit,
|
|
368
|
+
};
|
|
351
369
|
}
|
|
352
370
|
|
|
371
|
+
/** `{ [lock]: before[lock] }` for an entity declaring `version:`, unless the caller named it. */
|
|
372
|
+
const versionRead = (
|
|
373
|
+
resource: AdminResource,
|
|
374
|
+
before: AdminRow,
|
|
375
|
+
submitted: readonly string[],
|
|
376
|
+
): Readonly<Record<string, unknown>> => {
|
|
377
|
+
const lock = resource.entity.$version;
|
|
378
|
+
if (typeof lock !== 'string' || submitted.includes(lock)) return {};
|
|
379
|
+
return { [lock]: before[lock] };
|
|
380
|
+
};
|
|
381
|
+
|
|
353
382
|
/** Destructive: the caller must echo `confirmationToken(entity, id)` or nothing happens. */
|
|
354
383
|
export async function adminDestroy<Row extends AdminRow>(
|
|
355
384
|
resource: AdminResource<Row>,
|
package/src/mcp-dispatch.ts
CHANGED
|
@@ -213,7 +213,8 @@ function crudResult(result: CrudResult<AdminRow>): AdminToolResult {
|
|
|
213
213
|
if (result.kind === 'missing') {
|
|
214
214
|
return { ok: false, error: 'X_ADMIN_INVALID', reason: 'no row has that id' };
|
|
215
215
|
}
|
|
216
|
-
//
|
|
216
|
+
// The update tool sends no form version: reached when an entity's declared lock refused the
|
|
217
|
+
// write (X_ENTITY_STALE) because another write landed after this call read the row.
|
|
217
218
|
if (result.kind === 'stale') {
|
|
218
219
|
return { ok: false, error: 'X_ADMIN_INVALID', reason: 'the row changed since it was read' };
|
|
219
220
|
}
|
package/src/registry.ts
CHANGED
|
@@ -90,6 +90,11 @@ export interface AdminEntity {
|
|
|
90
90
|
readonly $viewQuery?: { readonly materialized: boolean } | null;
|
|
91
91
|
/** `entity({ readOnly })`'s reason, `null` on a table the app writes: reads alone, as a view. */
|
|
92
92
|
readonly $readOnly?: string | null;
|
|
93
|
+
/**
|
|
94
|
+
* `entity({ version })`'s lock property, `null` without one. Optional only because a hand-built
|
|
95
|
+
* fixture has none: never a form input, and every update carries the version the admin read.
|
|
96
|
+
*/
|
|
97
|
+
readonly $version?: string | null;
|
|
93
98
|
/** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
|
|
94
99
|
$describe(): AdminEntityDescription;
|
|
95
100
|
}
|
package/src/resource-fields.ts
CHANGED
|
@@ -62,6 +62,9 @@ export function deriveField(
|
|
|
62
62
|
// The handle scopes every read and write by this column, so the acting actor's tenant is the
|
|
63
63
|
// only value it can hold: it is not an input, not a list column, and not a filter.
|
|
64
64
|
const tenant = entity.$tenantColumn === name;
|
|
65
|
+
// The declared lock counts writes: the update carries the version it read (`crud.ts`), so an
|
|
66
|
+
// operator never types one — a form or MCP input for it would be a way round the lock.
|
|
67
|
+
const version = entity.$version === name;
|
|
65
68
|
const filterKind = tenant ? undefined : filterKindFor(type, column);
|
|
66
69
|
|
|
67
70
|
return {
|
|
@@ -71,7 +74,7 @@ export function deriveField(
|
|
|
71
74
|
widget,
|
|
72
75
|
labelKey: override?.labelKey ?? `admin.${entity.$name}.field.${name}`,
|
|
73
76
|
required: override?.required ?? (!column.nullable && !generated && !tenant),
|
|
74
|
-
readOnly: override?.readOnly ?? (generated || tenant),
|
|
77
|
+
readOnly: override?.readOnly ?? (generated || tenant || version),
|
|
75
78
|
sensitive,
|
|
76
79
|
inList: override?.inList ?? (listable(type) && !sensitive && !tenant),
|
|
77
80
|
filterable: filterKind !== undefined && (override?.filterable ?? filterable(type, column)),
|