@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/admin",
3
- "version": "31.9.0",
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.9.0",
50
- "@ultimat3/ai": "31.9.0",
51
- "@ultimat3/cache": "31.9.0",
52
- "@ultimat3/core": "31.9.0",
53
- "@ultimat3/db": "31.9.0",
54
- "@ultimat3/entity": "31.9.0",
55
- "@ultimat3/http": "31.9.0",
56
- "@ultimat3/i18n": "31.9.0",
57
- "@ultimat3/jobs": "31.9.0",
58
- "@ultimat3/mcp": "31.9.0",
59
- "@ultimat3/money": "31.9.0",
60
- "@ultimat3/policy": "31.9.0",
61
- "@ultimat3/query": "31.9.0",
62
- "@ultimat3/render": "31.9.0",
63
- "@ultimat3/schema": "31.9.0",
64
- "@ultimat3/ui": "31.9.0"
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
  }
@@ -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 read or rendered (a `catch` binding holds
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: { readonly allowed: string; readonly failed: string },
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
- await audit.append({ ...entry, entityId, outcome: 'failed', reason: reasons.failed, diff: [] });
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 { auditedWrite, deniedEntry, invalid, missingRow, refuse, staleRow } from './crud-outcome';
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 { value: after, audit } = await auditedWrite(
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: after, audit };
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>,
@@ -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
- // Reached only by a caller that sent a version; the update tool sends none.
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
  }
@@ -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)),