@ultimat3/admin 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CLAUDE.md +32 -30
  2. package/README.md +51 -16
  3. package/package.json +17 -17
  4. package/src/action-gate.ts +33 -42
  5. package/src/admin.module.scss +118 -4
  6. package/src/audit-pg.ts +2 -2
  7. package/src/audit.ts +1 -3
  8. package/src/authz.ts +8 -8
  9. package/src/batch-matching.ts +26 -31
  10. package/src/batch-queue.ts +90 -0
  11. package/src/batch.ts +11 -51
  12. package/src/crud-input.ts +2 -1
  13. package/src/crud-outcome.ts +96 -38
  14. package/src/crud.ts +48 -4
  15. package/src/describe.ts +6 -0
  16. package/src/dev/data.ts +5 -8
  17. package/src/dev/dev-t.ts +9 -0
  18. package/src/dev/facts.ts +1 -1
  19. package/src/dev/index.ts +1 -0
  20. package/src/dev/jobs-state-chart.tsx +38 -0
  21. package/src/dev/panel-cache.ts +8 -3
  22. package/src/dev/panel-jobs.ts +10 -5
  23. package/src/dev/panel-timeline.ts +16 -3
  24. package/src/dev/panel.ts +10 -5
  25. package/src/dev/server.ts +43 -17
  26. package/src/dev/sql-runner.ts +31 -0
  27. package/src/dev/timeline-style.ts +47 -0
  28. package/src/dev/timeline-waterfall.ts +96 -0
  29. package/src/errors.ts +9 -5
  30. package/src/form-decode.ts +55 -5
  31. package/src/form.tsx +19 -5
  32. package/src/index.ts +11 -9
  33. package/src/jobs/fleet-repo.ts +15 -3
  34. package/src/jobs/job-repo.ts +51 -17
  35. package/src/jobs/operator.ts +5 -5
  36. package/src/jobs/overview-tab.tsx +2 -2
  37. package/src/keyset-seek.ts +189 -0
  38. package/src/layout.tsx +34 -8
  39. package/src/list-filters.ts +30 -5
  40. package/src/list-request.ts +8 -6
  41. package/src/list.tsx +1 -1
  42. package/src/mcp-scopes.ts +36 -0
  43. package/src/mcp-tools.ts +16 -2
  44. package/src/mcp.ts +27 -11
  45. package/src/pagination.ts +32 -22
  46. package/src/policy-bridge.ts +16 -14
  47. package/src/registry.ts +9 -1
  48. package/src/related.ts +2 -2
  49. package/src/relations.ts +26 -10
  50. package/src/repo-entity.ts +37 -46
  51. package/src/resource-list.ts +9 -0
  52. package/src/resource.ts +10 -0
  53. package/src/row-version.ts +20 -0
  54. package/src/screen-home-access.tsx +117 -0
  55. package/src/screen-home-counts.ts +42 -0
  56. package/src/screen-home-kpis.tsx +64 -0
  57. package/src/screen-home.module.scss +169 -0
  58. package/src/screen-home.tsx +21 -82
  59. package/src/screen-lookup.tsx +1 -1
  60. package/src/screen-resource.tsx +60 -12
  61. package/src/screen-system.tsx +8 -2
  62. package/src/search.ts +12 -4
  63. package/src/widgets.tsx +2 -6
  64. package/src/inert-jsx.ts +0 -182
package/src/dev/server.ts CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  // Type-only, so it is erased and the 46-component barrel stays out of the mount graph — the
6
6
  // values arrive through the dynamic `import()` in `devShellStyle()`, same reason as `data.ts`.
7
- import { DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
8
- import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
7
+ import { DEFAULT_ENVIRONMENT, escapeHtml, tryResolveEnvironment } from '@ultimat3/core';
9
8
  import type { ColorRole } from '@ultimat3/ui';
10
9
  import { DevDashboardInProdError } from '../errors';
11
10
  import { defaultDevSources } from './data';
11
+ import { t } from './dev-t';
12
12
  import type { DevSources } from './facts';
13
13
  import { type DevPanel, type PanelPayload, panelPayload } from './panel';
14
14
  import { cachePanel } from './panel-cache';
@@ -20,6 +20,7 @@ import { manifestPanel } from './panel-manifest';
20
20
  import { policyPanel } from './panel-policy';
21
21
  import { routesPanel } from './panel-routes';
22
22
  import { timelinePanel } from './panel-timeline';
23
+ import { WATERFALL_STYLE } from './timeline-style';
23
24
 
24
25
  export const DEV_PANELS: readonly DevPanel[] = [
25
26
  routesPanel,
@@ -43,6 +44,13 @@ export interface DevDashboardOptions {
43
44
  readonly basePath?: string;
44
45
  readonly sources?: DevSources;
45
46
  readonly panels?: readonly DevPanel[];
47
+ /**
48
+ * The app surface's stylesheet URL — what `<link rel="stylesheet">` on an `app/` document carries.
49
+ * A drawn panel (the jobs tab) renders ui's own components, whose rules live there and not in the
50
+ * inlined shell; the host that mounts `/_x` is the one that knows the content-hashed URL. Asked
51
+ * per request, because a rebuild under `x dev` moves the hash. Absent or `undefined`: no link.
52
+ */
53
+ readonly stylesheetHref?: () => string | undefined;
46
54
  }
47
55
 
48
56
  const envOf = (name: string): string | undefined => {
@@ -85,10 +93,11 @@ const jsonResponse = (body: unknown, status = 200): Response =>
85
93
  headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
86
94
  });
87
95
 
88
- const escapeHtml = (value: string): string =>
89
- value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
-
91
- /** The six roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. */
96
+ /**
97
+ * The roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. The chart
98
+ * series are the timeline's span kinds — `chart-*` is contrast-gated against both surfaces, and
99
+ * every kind is named in text beside its bar, so colour is never the only key.
100
+ */
92
101
  const SHELL_ROLES = [
93
102
  'bg',
94
103
  'surface-raised',
@@ -96,6 +105,13 @@ const SHELL_ROLES = [
96
105
  'fg-muted',
97
106
  'line',
98
107
  'accent',
108
+ 'chart-1',
109
+ 'chart-2',
110
+ 'chart-3',
111
+ 'chart-4',
112
+ 'chart-5',
113
+ 'chart-6',
114
+ 'chart-7',
99
115
  ] as const satisfies readonly ColorRole[];
100
116
 
101
117
  const SHELL_LAYOUT = `
@@ -110,7 +126,7 @@ p.question { color: rgb(var(--x-color-fg-muted)); margin: 0 0 1rem; }
110
126
  pre { background: rgb(var(--x-color-surface-raised)); border: 1px solid rgb(var(--x-color-line));
111
127
  padding: 1rem; overflow: auto; }
112
128
  :focus-visible { outline: 2px solid rgb(var(--x-color-accent)); outline-offset: 2px; }
113
- `;
129
+ ${WATERFALL_STYLE}`;
114
130
 
115
131
  let stylePromise: Promise<string> | undefined;
116
132
 
@@ -157,15 +173,10 @@ html[data-theme="dark"] { ${block('dark')} }
157
173
  ${SHELL_LAYOUT}`;
158
174
  }
159
175
 
160
- /**
161
- * /_x is the framework's own tool, in the framework's own locale (`<html lang="en">` below): its
162
- * strings are registered under `FRAMEWORK_CATALOG_LOCALE` only, so the AMBIENT locale — the app's,
163
- * `es-co` for an app that defaults to it — answered `⟦dev.panel.mail.title⟧` for every tab.
164
- */
165
- const t = (key: string): string => translatorFor(FRAMEWORK_CATALOG_LOCALE)(key);
166
-
167
176
  function shell(
168
177
  style: string,
178
+ /** The app surface's stylesheet, linked BEFORE the shell's `<style>` so the shell wins ties. */
179
+ stylesheetHref: string | undefined,
169
180
  basePath: string,
170
181
  panels: readonly DevPanel[],
171
182
  active: DevPanel,
@@ -184,7 +195,11 @@ function shell(
184
195
  <html lang="en"><head><meta charset="utf-8">
185
196
  <meta name="viewport" content="width=device-width, initial-scale=1">
186
197
  <meta name="robots" content="noindex, nofollow">
187
- <title>_x · ${escapeHtml(active.key)}</title><style>${style}</style></head>
198
+ <title>_x · ${escapeHtml(active.key)}</title>${
199
+ stylesheetHref === undefined
200
+ ? ''
201
+ : `<link rel="stylesheet" href="${escapeHtml(stylesheetHref)}">`
202
+ }<style>${style}</style></head>
188
203
  <body><header><h1>_x</h1><nav>${tabs}</nav>
189
204
  <a href="${basePath}/${active.key}?json=1">--json</a></header>
190
205
  <main><p class="question">${escapeHtml(t(active.questionKey))}</p>
@@ -241,8 +256,19 @@ export function devDashboard(opts: DevDashboardOptions = {}): DevDashboard {
241
256
  if (wantsJson) return jsonResponse(payload, payload.ok ? 200 : 500);
242
257
  const tabPath = `${basePath}/${panel.key}`;
243
258
  const drawn =
244
- payload.ok && panel.html !== undefined ? await panel.html(url.searchParams, tabPath) : null;
245
- return new Response(shell(await devShellStyle(), basePath, panels, panel, payload, drawn), {
259
+ payload.ok && panel.html !== undefined
260
+ ? await panel.html(url.searchParams, tabPath, payload.data)
261
+ : null;
262
+ const page = shell(
263
+ await devShellStyle(),
264
+ opts.stylesheetHref?.(),
265
+ basePath,
266
+ panels,
267
+ panel,
268
+ payload,
269
+ drawn,
270
+ );
271
+ return new Response(page, {
246
272
  status: 200,
247
273
  headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
248
274
  });
@@ -0,0 +1,31 @@
1
+ // The `/_x` DB panel's statement, RUN: inside `readOnlyQuery` — its own `BEGIN READ ONLY`
2
+ // transaction, a statement timeout and a row ceiling — never through the app's plain `db.query`.
3
+ // A host wires `runSql: readOnlySql(client)`; the guard upstream decides what reaches it.
4
+
5
+ import { type DbClient, readOnlyQuery } from '@ultimat3/db';
6
+ import type { SqlResult } from './facts';
7
+
8
+ /**
9
+ * The most rows the panel asks the server for. It draws a grid, not an export: a cursor fetches
10
+ * this many and the rest of a `select * from events` is never materialised in the dev process.
11
+ */
12
+ export const DEV_SQL_MAX_ROWS = 500;
13
+
14
+ /**
15
+ * The runner a `/_x` host hands the DB panel. `assertReadOnlyQuery` (`panel-db.ts`) is the parse
16
+ * guard; this is the SERVER saying no: a statement the guard let through that writes, locks or
17
+ * runs away still meets a read-only transaction that always rolls back, with the app's own
18
+ * credentials and no way to commit. Columns are the first row's keys — the driver hands objects.
19
+ */
20
+ export function readOnlySql(client: DbClient): (sql: string) => Promise<SqlResult> {
21
+ return async (sql) => {
22
+ const started = performance.now();
23
+ const { rows } = await readOnlyQuery<Readonly<Record<string, unknown>>>(sql, {
24
+ client,
25
+ maxRows: DEV_SQL_MAX_ROWS,
26
+ });
27
+ const elapsedMs = Math.round(performance.now() - started);
28
+ const columns = Object.keys(rows[0] ?? {});
29
+ return { columns, rows: rows.map((row) => columns.map((column) => row[column])), elapsedMs };
30
+ };
31
+ }
@@ -0,0 +1,47 @@
1
+ // The `/_x` timeline waterfall's rules, inlined into the shell's one `<style>` (`server.ts`) — /_x
2
+ // has no stylesheet pipeline, and the CSP hash a host computes is of that whole text. Colours are
3
+ // the shell's `--x-color-*` tokens only; each span kind takes one `chart-*` series role.
4
+
5
+ import { SPAN_KINDS } from './timeline-waterfall';
6
+
7
+ /**
8
+ * The timeline's waterfall (`timeline-waterfall.ts`): a label column, a track whose bar is placed
9
+ * by the row's own `--offset`/`--width` (server-computed shares), and the duration. Below 720px the
10
+ * label takes its own line, so a long SQL text never squeezes the track to nothing on a phone.
11
+ */
12
+ export const WATERFALL_STYLE = `
13
+ h2 { font-size: .875rem; margin: 0 0 .5rem; }
14
+ .tl { display: grid; gap: 1.5rem; }
15
+ @media (min-width: 1024px) { .tl { grid-template-columns: minmax(12rem, 18rem) minmax(0, 1fr); } }
16
+ .tl-requests ol, .tl-kinds, .tl-flame, .tl-loops ul { list-style: none; margin: 0; padding: 0; }
17
+ .tl-requests li { display: flex; justify-content: space-between; gap: .5rem; padding: .25rem 0;
18
+ border-bottom: 1px solid rgb(var(--x-color-line)); }
19
+ .tl-requests a { overflow-wrap: anywhere; }
20
+ .tl-requests a[aria-current] { color: rgb(var(--x-color-fg)); font-weight: 700; }
21
+ .tl-ms { color: rgb(var(--x-color-fg-muted)); white-space: nowrap; font-variant-numeric: tabular-nums; }
22
+ .tl-kinds { display: flex; flex-wrap: wrap; gap: .25rem 1rem; margin-bottom: 1rem; }
23
+ .tl-swatch { display: inline-block; inline-size: .75rem; block-size: .75rem; border-radius: 2px;
24
+ margin-inline-end: .375rem; vertical-align: -1px; background: rgb(var(--tl-color)); }
25
+ .tl-row { display: grid; grid-template-columns: minmax(0, 1fr) 4.5rem; gap: .125rem .75rem;
26
+ align-items: center; padding: .25rem 0; border-bottom: 1px solid rgb(var(--x-color-line)); }
27
+ .tl-label { grid-column: 1 / -1; display: flex; gap: .5rem; align-items: baseline; min-inline-size: 0;
28
+ padding-inline-start: calc(var(--depth) * .75rem); }
29
+ @media (min-width: 720px) {
30
+ .tl-row { grid-template-columns: minmax(0, 22rem) minmax(0, 1fr) 4.5rem; }
31
+ .tl-label { grid-column: auto; }
32
+ }
33
+ .tl-kind { flex: none; padding: 0 .375rem; border-radius: 2px; font-size: .75rem;
34
+ border: 1px solid rgb(var(--tl-color)); color: rgb(var(--x-color-fg)); }
35
+ .tl-name { flex: none; }
36
+ .tl-detail { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
37
+ color: rgb(var(--x-color-fg-muted)); }
38
+ .tl-track { position: relative; block-size: .875rem; border-radius: 2px;
39
+ background: rgb(var(--x-color-line) / .35); }
40
+ .tl-bar { position: absolute; inset-block: 0; inset-inline-start: var(--offset);
41
+ inline-size: max(var(--width), 2px); border-radius: 2px; background: rgb(var(--tl-color)); }
42
+ .tl-row > .tl-ms { text-align: end; }
43
+ .tl-loops { margin-top: 1.5rem; }
44
+ .tl-loops li { padding: .5rem 0; border-bottom: 1px solid rgb(var(--x-color-line)); }
45
+ .tl-empty { color: rgb(var(--x-color-fg-muted)); }
46
+ ${SPAN_KINDS.map((kind, index) => `[data-kind="${kind}"] { --tl-color: var(--x-color-chart-${index + 1}); }`).join('\n')}
47
+ `;
@@ -0,0 +1,96 @@
1
+ // The `/_x` timeline tab, drawn: the selected request's spans as a waterfall — one row per span,
2
+ // indented under its parent, its bar placed and sized by the server from the payload's own
3
+ // `offset`/`width` — beside the recent requests, the time per kind and the N+1 verdicts. Plain
4
+ // escaped HTML on the shell's tokens: `/_x` loads no Solid and no stylesheet pipeline to draw it.
5
+
6
+ import { escapeHtml } from '@ultimat3/core';
7
+ import { t } from './dev-t';
8
+ import type { SpanKind, StatementLoopFact } from './facts';
9
+ import type { FlameRow, TimelinePanelData } from './panel-timeline';
10
+
11
+ /** Every kind a span can be, in the order the legend lists them — and the colour slot each takes. */
12
+ export const SPAN_KINDS: readonly SpanKind[] = [
13
+ 'http',
14
+ 'render',
15
+ 'action',
16
+ 'policy',
17
+ 'sql',
18
+ 'cache',
19
+ 'job',
20
+ ];
21
+
22
+ /** `/_x` is the framework's English tool; a duration reads to a tenth of a millisecond. */
23
+ const MS = new Intl.NumberFormat('en', { maximumFractionDigits: 1 });
24
+ const ms = (value: number): string => t('dev.panel.timeline.ms', { ms: MS.format(value) });
25
+
26
+ /** A 0..1 share as a CSS percentage: two decimals at most, never `-0`, clamped to the track. */
27
+ const percent = (share: number): string => {
28
+ const clamped = Number.isFinite(share) ? Math.min(1, Math.max(0, share)) : 0;
29
+ const text = (clamped * 100).toFixed(2).replace(/\.?0+$/, '');
30
+ return `${text === '-0' ? '0' : text}%`;
31
+ };
32
+
33
+ const kindLabel = (kind: SpanKind): string => t(`dev.panel.timeline.kind.${kind}`);
34
+
35
+ const kindTag = (kind: SpanKind): string =>
36
+ `<span class="tl-kind">${escapeHtml(kindLabel(kind))}</span>`;
37
+
38
+ function requestList(data: TimelinePanelData, tabPath: string): string {
39
+ const shown = data.selected?.requestId;
40
+ const items = data.requests
41
+ .map((request) => {
42
+ const href = `${tabPath}?requestId=${encodeURIComponent(request.requestId)}`;
43
+ const current = request.requestId === shown ? ' aria-current="true"' : '';
44
+ return `<li><a href="${escapeHtml(href)}"${current}>${escapeHtml(request.path)}</a> <span class="tl-ms">${escapeHtml(ms(request.totalMs))}</span></li>`;
45
+ })
46
+ .join('');
47
+ const title = escapeHtml(t('dev.panel.timeline.requests'));
48
+ return `<nav class="tl-requests" aria-label="${title}"><h2>${title}</h2><ol>${items}</ol></nav>`;
49
+ }
50
+
51
+ function kindTotals(data: TimelinePanelData): string {
52
+ const items = SPAN_KINDS.filter((kind) => Object.hasOwn(data.totalsByKind, kind))
53
+ .map(
54
+ (kind) =>
55
+ `<li><span class="tl-swatch" data-kind="${kind}" aria-hidden="true"></span>${escapeHtml(kindLabel(kind))} <span class="tl-ms">${escapeHtml(ms(data.totalsByKind[kind] ?? 0))}</span></li>`,
56
+ )
57
+ .join('');
58
+ return `<ul class="tl-kinds" aria-label="${escapeHtml(t('dev.panel.timeline.byKind'))}">${items}</ul>`;
59
+ }
60
+
61
+ function row(flame: FlameRow): string {
62
+ const { span } = flame;
63
+ const style = `--depth:${flame.depth};--offset:${percent(flame.offset)};--width:${percent(flame.width)}`;
64
+ const detail =
65
+ span.detail === ''
66
+ ? ''
67
+ : `<code class="tl-detail" title="${escapeHtml(span.detail)}">${escapeHtml(span.detail)}</code>`;
68
+ return `<li class="tl-row" data-kind="${span.kind}" style="${style}"><span class="tl-label">${kindTag(span.kind)} <span class="tl-name">${escapeHtml(span.name)}</span>${detail}</span><span class="tl-track" aria-hidden="true"><span class="tl-bar"></span></span><span class="tl-ms">${escapeHtml(ms(span.durationMs))}</span></li>`;
69
+ }
70
+
71
+ function verdicts(loops: readonly StatementLoopFact[] | null): string {
72
+ const title = `<h2>${escapeHtml(t('dev.panel.timeline.nPlusOne.title'))}</h2>`;
73
+ if (loops === null) {
74
+ return `<section class="tl-loops">${title}<p class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.unwired'))}</p></section>`;
75
+ }
76
+ if (loops.length === 0) {
77
+ return `<section class="tl-loops">${title}<p class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.clean'))}</p></section>`;
78
+ }
79
+ const items = loops
80
+ .map(
81
+ (loop) =>
82
+ `<li><strong>${escapeHtml(loop.code)}</strong> ${escapeHtml(loop.cause)}<br><span class="question">${escapeHtml(t('dev.panel.timeline.nPlusOne.fix'))}</span> <code>${escapeHtml(loop.fix)}</code></li>`,
83
+ )
84
+ .join('');
85
+ return `<section class="tl-loops">${title}<ul>${items}</ul></section>`;
86
+ }
87
+
88
+ /** The tab body. Pure over the panel's data, so the `--json` payload and the drawing agree. */
89
+ export function timelineHtml(data: TimelinePanelData, tabPath: string): string {
90
+ const selected = data.selected;
91
+ if (selected === null) {
92
+ return `<p class="tl-empty">${escapeHtml(t('dev.panel.timeline.empty'))}</p>`;
93
+ }
94
+ const heading = `${selected.method} ${selected.path} · ${selected.status} · ${ms(selected.totalMs)}`;
95
+ return `<div class="tl">${requestList(data, tabPath)}<section class="tl-trace" aria-labelledby="tl-heading"><h2 id="tl-heading">${escapeHtml(heading)}</h2>${kindTotals(data)}<ol class="tl-flame" aria-label="${escapeHtml(t('dev.panel.timeline.spans'))}">${data.flame.map(row).join('')}</ol>${verdicts(data.nPlusOne)}</section></div>`;
96
+ }
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The X_* codes owned by @ultimat3/admin. Every one names the exact edit that fixes it,
2
2
  // because the two dashboards fail at boot (bad registry, bad mount) where an agent has no
3
3
  // stack trace to reason from — only the message.
4
- import { registerErrorCodes, UltimateError } from '@ultimat3/core';
4
+ import { NotImplementedError, registerErrorCodes, UltimateError } from '@ultimat3/core';
5
5
 
6
6
  /** Codes this package declares and owns. */
7
7
  export const ADMIN_OWNED_ERROR_CODES = [
@@ -32,8 +32,8 @@ export const ADMIN_OWNED_ERROR_CODES = [
32
32
  ] as const;
33
33
 
34
34
  /**
35
- * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` throws it; this package
36
- * neither titles nor registers it, because the owner's title is the only one that may exist.
35
+ * `X_NOT_IMPLEMENTED` is `@ultimat3/core`'s. `DevSourceUnavailableError` IS core's
36
+ * `NotImplementedError`, narrowed; this package neither constructs, titles nor registers the code, because the owner's title is the only one that may exist.
37
37
  */
38
38
  export const ADMIN_BORROWED_ERROR_CODES = ['X_NOT_IMPLEMENTED'] as const;
39
39
 
@@ -254,11 +254,15 @@ export class AdminPagePathInvalidError extends UltimateError {
254
254
  * A `/_x` panel needs a fact the framework cannot introspect on its own — request traces,
255
255
  * caught mail, the read-only SQL tool, the committed manifest. Thrown instead of drawing an
256
256
  * empty panel, because an empty panel reads as "nothing happened".
257
+ *
258
+ * Core's NotImplementedError and not a sibling of it: core is the one constructor of the code.
259
+ * A class of its own all the same, because a panel degrades on "this source is not wired" and on
260
+ * nothing wider — a wired source whose driver answers X_NOT_IMPLEMENTED is a diagnostic, not an
261
+ * unwired tab (dev/panel-degrade.test.ts).
257
262
  */
258
- export class DevSourceUnavailableError extends UltimateError {
263
+ export class DevSourceUnavailableError extends NotImplementedError {
259
264
  constructor(input: { source: string; panel: string; wiring?: string }) {
260
265
  super({
261
- code: 'X_NOT_IMPLEMENTED',
262
266
  cause: `the /_x ${input.panel} panel needs the "${input.source}" source, which is not wired in this process`,
263
267
  // `wiring`, when supplied, replaces the whole `defaultDevSources(...)` argument text —
264
268
  // the default `hooks: { <source> }` phrasing is only valid when the source really is a
@@ -5,7 +5,9 @@
5
5
  // It converts and never validates: a value that will not convert is passed through as the string
6
6
  // that was typed, so the schema refuses it in its own words against the field that carried it.
7
7
 
8
+ import { numeric } from '@ultimat3/schema';
8
9
  import type { AdminField } from './fields';
10
+ import type { AdminRow } from './registry';
9
11
  import type { AdminResource } from './resource';
10
12
 
11
13
  /** The posted name of a money field's currency, beside the minor-unit amount under the field's own. */
@@ -13,6 +15,39 @@ export const currencyFieldOf = (field: string): string => `${field}.currency`;
13
15
 
14
16
  type Posted = Readonly<Record<string, unknown>>;
15
17
 
18
+ /**
19
+ * What a date control is RENDERED with: `<input type="date">` wants `YYYY-MM-DD`, `datetime-local`
20
+ * wants `YYYY-MM-DDTHH:mm`. One function for the widget that draws the box and for the decode that
21
+ * reads it back, so "the operator left it as it was" is a comparison of one text with itself.
22
+ */
23
+ export const datetimeInputValue = (iso: string, precision: 'date' | 'instant'): string =>
24
+ iso.slice(0, precision === 'date' ? 10 : 16);
25
+
26
+ const isoOf = (value: unknown): string | undefined =>
27
+ value instanceof Date
28
+ ? Number.isNaN(value.getTime())
29
+ ? undefined
30
+ : value.toISOString()
31
+ : typeof value === 'string'
32
+ ? value
33
+ : undefined;
34
+
35
+ /**
36
+ * An instant posted back as the box rendered it. `datetime-local` holds minutes, so writing what
37
+ * came back turned `10:20:45.123` into `10:20:00.000` on every edit of ANY field — a change nobody
38
+ * made, recorded in the audit diff as theirs. Such a field is left out of the patch and the stored
39
+ * instant stands. A calendar date carries every digit it has and is never in this position.
40
+ */
41
+ const untouchedInstant = (
42
+ field: AdminField,
43
+ raw: string | undefined,
44
+ before: AdminRow,
45
+ ): boolean => {
46
+ if (field.widget !== 'datetime' || field.type === 'date' || raw === undefined) return false;
47
+ const stored = isoOf(before[field.name]);
48
+ return stored !== undefined && raw === datetimeInputValue(stored, 'instant');
49
+ };
50
+
16
51
  /** The last value a posted name carried, as text. A repeated name arrives as a list. */
17
52
  export const posted = (form: Posted, name: string): string | undefined => {
18
53
  if (!Object.hasOwn(form, name)) return undefined;
@@ -38,10 +73,15 @@ const TYPED: ReadonlySet<AdminField['widget']> = new Set([
38
73
  const blank = (field: AdminField): unknown =>
39
74
  field.required && !TYPED.has(field.widget) ? '' : null;
40
75
 
41
- const numberOf = (raw: string): unknown => {
42
- const parsed = Number(raw);
43
- return Number.isFinite(parsed) ? parsed : raw;
44
- };
76
+ /**
77
+ * Schema's own decimal reader, never `Number(raw)`: that reads hex, binary and octal too, so `0x10`
78
+ * in a money box was stored as `minor: 16`. Anything else is handed on as typed for the schema to
79
+ * refuse against the field. Whitespace never reaches here — `decodeField` blanks it first.
80
+ */
81
+ const numberOf = (raw: string): unknown => numeric(raw) ?? raw;
82
+
83
+ /** The widgets that hold a number: a box of only whitespace is as empty as `''` for them. */
84
+ const NUMERIC: ReadonlySet<AdminField['widget']> = new Set(['money', 'number-input']);
45
85
 
46
86
  const jsonOf = (raw: string): unknown => {
47
87
  try {
@@ -57,6 +97,9 @@ function decodeField(field: AdminField, form: Posted): unknown {
57
97
  // An unchecked box posts nothing at all, so absence IS the answer for a checkbox.
58
98
  if (field.widget === 'checkbox') return raw !== undefined && raw !== 'false';
59
99
  if (raw === undefined || raw === '') return blank(field);
100
+ // `Number(' ')` is 0, so a space in an untouched optional number box was a write of 0 over a
101
+ // stored null — a change the audit diff then attributed to the operator.
102
+ if (NUMERIC.has(field.widget) && raw.trim() === '') return blank(field);
60
103
  switch (field.widget) {
61
104
  case 'number-input':
62
105
  return numberOf(raw);
@@ -79,13 +122,20 @@ function decodeField(field: AdminField, form: Posted): unknown {
79
122
  /**
80
123
  * Every field the form rendered, decoded. A sealed column rides along as the string that was
81
124
  * typed — `crud.ts` drops an empty one, which is what makes an untouched box mean "unchanged".
125
+ *
126
+ * `before` is the row an EDIT form was rendered from: an instant that comes back exactly as it was
127
+ * drawn is left out rather than rewritten at the control's precision. A create has none.
82
128
  */
83
129
  export function decodeForm(
84
130
  resource: AdminResource,
85
131
  form: Posted,
132
+ before?: AdminRow,
86
133
  ): Readonly<Record<string, unknown>> {
87
134
  const out: Record<string, unknown> = {};
88
- for (const field of resource.formFields) out[field.name] = decodeField(field, form);
135
+ for (const field of resource.formFields) {
136
+ if (before !== undefined && untouchedInstant(field, posted(form, field.name), before)) continue;
137
+ out[field.name] = decodeField(field, form);
138
+ }
89
139
  for (const field of resource.secretFields) out[field.name] = posted(form, field.name) ?? '';
90
140
  return out;
91
141
  }
package/src/form.tsx CHANGED
@@ -12,6 +12,7 @@ import type { AdminField } from './fields';
12
12
  import type { AdminRow } from './registry';
13
13
  import type { AdminResource } from './resource';
14
14
  import { fieldsFor } from './resource-layout';
15
+ import { VERSION_FIELD } from './row-version';
15
16
  import type { ValidationIssue } from './validate';
16
17
  import type { WidgetContext } from './widget-value';
17
18
  import { Widget } from './widgets';
@@ -28,6 +29,11 @@ export interface AdminFormProps<Row extends AdminRow> {
28
29
  readonly action: string;
29
30
  /** Where Cancel goes — the list for a create, the row for an edit. */
30
31
  readonly cancelHref: string;
32
+ /**
33
+ * An edit's `rowVersion()` of the row it was rendered from, posted back as `_version` so the
34
+ * write is refused if the row moved on meanwhile. `null` for a create, which has no row.
35
+ */
36
+ readonly version: string | null;
31
37
  }
32
38
 
33
39
  const issuesFor = (issues: readonly ValidationIssue[], field: string): readonly ValidationIssue[] =>
@@ -90,15 +96,23 @@ export function AdminForm<Row extends AdminRow>(props: AdminFormProps<Row>): JSX
90
96
  return (
91
97
  <Card header={<h2>{t(titleKey, { entity: t(props.resource.titleKey) })}</h2>}>
92
98
  <form class={styles['form']} method="post" action={props.action}>
99
+ {props.version === null ? null : (
100
+ <input type="hidden" name={VERSION_FIELD} value={props.version} />
101
+ )}
93
102
  {props.issues.length === 0 ? null : (
94
103
  <div class="x-admin-issues" role="alert" tabindex={-1}>
95
104
  <h3>{t('admin.form.issues')}</h3>
96
105
  <ul>
97
- {props.issues.map((issue) => (
98
- <li>
99
- <a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issueText(issue)}
100
- </li>
101
- ))}
106
+ {props.issues.map((issue) =>
107
+ // The form's own issue (the row changed under it) names no control to jump to.
108
+ issue.path === VERSION_FIELD ? (
109
+ <li>{issueText(issue)}</li>
110
+ ) : (
111
+ <li>
112
+ <a href={`#x-admin-field-${issue.path}`}>{issue.path}</a>: {issueText(issue)}
113
+ </li>
114
+ ),
115
+ )}
102
116
  </ul>
103
117
  </div>
104
118
  )}
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export {
9
9
  type AdminActionButton,
10
10
  actionButtons,
11
11
  actionDecisions,
12
+ adminPermissionForAction,
12
13
  decideAction,
13
14
  type InvokeInput,
14
15
  type InvokeResult,
@@ -77,24 +78,22 @@ export {
77
78
  deniedDraft,
78
79
  diffRows,
79
80
  memoryAuditLog,
80
- REDACTED,
81
81
  } from './audit';
82
82
  export {
83
83
  type PostgresAuditLogOptions,
84
84
  postgresAuditLog,
85
- SQL_ADMIN_AUDIT_INSERT,
86
85
  } from './audit-pg';
87
- export { ADMIN_AUDIT_TABLE, SQL_ADMIN_AUDIT_TABLE } from './audit-schema';
86
+ // The audit table's DDL is `@ultimat3/admin/schema`'s alone — one import path for one statement.
88
87
  export {
89
88
  type AdminActor,
90
89
  type AdminAuthz,
91
90
  type AdminAuthzQuery,
92
91
  type AdminDecision,
93
92
  type AdminSubject,
94
- allowed,
93
+ adminAllowed,
94
+ adminDenied,
95
95
  anonymousAuthz,
96
96
  decideAll,
97
- denied,
98
97
  expandPermissions,
99
98
  isAllowed,
100
99
  staticAuthz,
@@ -104,7 +103,6 @@ export {
104
103
  type AdminBatchInput,
105
104
  type AdminBatchResult,
106
105
  BATCH_NOT_OFFERED_REASON,
107
- BATCH_QUEUED_REASON,
108
106
  type BatchEnqueue,
109
107
  type BatchRow,
110
108
  type BatchRowOutcome,
@@ -124,7 +122,9 @@ export {
124
122
  batchEnqueue,
125
123
  } from './batch-job';
126
124
  export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
125
+ export { BATCH_QUEUED_REASON } from './batch-queue';
127
126
  export {
127
+ type AdminUpdateOptions,
128
128
  adminCreate,
129
129
  adminDestroy,
130
130
  adminDetail,
@@ -197,7 +197,7 @@ export { currencyFieldOf, decodeForm, posted } from './form-decode';
197
197
  export { JOB_MANAGE, JOB_READ } from './jobs/job-actions';
198
198
  export { JOB_ENTITY } from './jobs/job-entities';
199
199
  export { JOBS_PATH, jobRowScope } from './jobs/job-resources';
200
- export { AdminLayout, type AdminLayoutProps, actorLabel } from './layout';
200
+ export { AdminLayout, type AdminLayoutProps, adminActorLabel } from './layout';
201
201
  export { AdminList, type AdminListProps } from './list';
202
202
  export { type ResourceColumnsInput, resourceColumns } from './list-columns';
203
203
  export { AdminFilterBar, type AdminFilterBarProps } from './list-filter-bar';
@@ -229,12 +229,14 @@ export {
229
229
  } from './list-scope';
230
230
  export { AdminScopeTabs, type AdminScopeTabsProps } from './list-scope-tabs';
231
231
  export {
232
+ type AdminMcpActor,
232
233
  type AdminMcpOptions,
233
234
  type AdminToolResult,
234
235
  adminMcp,
235
236
  callAdminTool,
236
237
  type McpInput,
237
238
  } from './mcp';
239
+ export { adminTokenScopes, adminToolScopes } from './mcp-scopes';
238
240
  export {
239
241
  type AdminMcpTool,
240
242
  type AdminToolField,
@@ -290,7 +292,6 @@ export {
290
292
  ruleFor,
291
293
  } from './permissions';
292
294
  export {
293
- adminPermissions,
294
295
  declareAdminPermissions,
295
296
  type PolicyAuthzInput,
296
297
  policyAuthz,
@@ -341,8 +342,8 @@ export {
341
342
  labelOf,
342
343
  type RelationData,
343
344
  type RelationNeed,
345
+ relationDataFor,
344
346
  relationNeeds,
345
- relationsFor,
346
347
  } from './relations';
347
348
  export { ComputedCell, TRUNCATE_AT } from './renderers';
348
349
  export { adminEntitiesOf, adminRepoFor, adminTablesOf } from './repo-entity';
@@ -382,6 +383,7 @@ export {
382
383
  adminRoutes,
383
384
  } from './routes';
384
385
  export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
386
+ export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
385
387
  export {
386
388
  type AdminRouteRequest,
387
389
  type AdminRouteResponse,
@@ -14,18 +14,30 @@ const comparable = (value: unknown): string | number => {
14
14
  if (value instanceof Date) return value.getTime();
15
15
  if (typeof value === 'number') return value;
16
16
  if (typeof value === 'boolean') return value ? 1 : 0;
17
- return value === null || value === undefined ? '' : String(value);
17
+ return String(value);
18
18
  };
19
19
 
20
- /** Code-unit order, never a collation: the same set reads the same way on every machine. */
20
+ const absent = (value: unknown): boolean => value === null || value === undefined;
21
+
22
+ /**
23
+ * Code-unit order, never a collation: the same set reads the same way on every machine. NULL is
24
+ * the LARGEST value — last ascending, first descending — as the database orders a nullable sort
25
+ * key and as `repo-entity.ts` seeks one; it read as `''` here, which sorted it as a zero.
26
+ */
21
27
  const order = (a: unknown, b: unknown): number => {
28
+ if (absent(a) || absent(b)) return absent(a) && absent(b) ? 0 : absent(a) ? 1 : -1;
22
29
  const x = comparable(a);
23
30
  const y = comparable(b);
24
31
  return x < y ? -1 : x > y ? 1 : 0;
25
32
  };
26
33
 
27
- /** A cursor carries its value as text (`pagination.ts`); read back as the column's own type. */
34
+ /**
35
+ * A cursor carries its value as text (`pagination.ts`), or `null` for a row that held none; read
36
+ * back as the type of the row it is compared with. A `null` bound stays `null` — `new Date(null)`
37
+ * is the epoch and `new Date('')` is NaN, and either made the page after a NULL row the wrong one.
38
+ */
28
39
  const boundValue = (row: AdminRow, bound: KeysetBound, field: string): unknown => {
40
+ if (bound.value === null) return null;
29
41
  const held = row[field];
30
42
  if (held instanceof Date) return new Date(bound.value);
31
43
  if (typeof held === 'number') return Number(bound.value);