@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.
@@ -2,12 +2,19 @@
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 { type AuditEntry, type AuditFieldDiff, deniedDraft } from './audit';
5
+ import {
6
+ type AuditDraft,
7
+ type AuditEntry,
8
+ type AuditFieldDiff,
9
+ type AuditLog,
10
+ deniedDraft,
11
+ } from './audit';
6
12
  import type { AdminDecision } from './authz';
7
13
  import type { CrudCtx, CrudResult } from './crud';
8
14
  import type { AdminOperation } from './permissions';
9
15
  import type { AdminRow } from './registry';
10
16
  import type { AdminResource } from './resource';
17
+ import { ROW_CHANGED_REASON } from './row-version';
11
18
  import type { ValidationIssue } from './validate';
12
19
 
13
20
  export const deniedEntry = (
@@ -47,31 +54,35 @@ export async function refuse<Row extends AdminRow>(
47
54
  /** The reason on a `failed` entry. A key, never the database's message. */
48
55
  const WRITE_FAILED_REASON = 'admin.audit.write-failed';
49
56
 
57
+ /** What every entry of one audited write shares: who, what, on which table, under which grant. */
58
+ export type AuditedEntry = Pick<
59
+ AuditDraft,
60
+ 'requestId' | 'actor' | 'operation' | 'kind' | 'entity' | 'permission'
61
+ >;
62
+
50
63
  /**
51
- * Run a repo WRITE and append its entry — as one unit where the log can make it one — and leave a
52
- * `failed` entry behind if either throws.
53
- *
54
- * `AuditOutcome` has declared a `failed` member all along and this file emitted it in exactly one
55
- * place — `invalid()`, for a VALIDATION issue. A constraint violation, a statement that timed out
56
- * after committing, a connection dropped mid-write: each left no entry at all, which is the case
57
- * an auditor opens the log for. Both siblings already do this and each states the rule
58
- * (`search.ts`, `action-gate.ts`).
64
+ * Run a WRITE and append its entry as one unit where the log can make it one, and leave a `failed`
65
+ * entry behind if either throws — the one shape for a CRUD write, an action's handler, a set-based
66
+ * batch and a queued one.
59
67
  *
60
68
  * A mutation cannot append BEFORE the call the way a read does — that would record a write which
61
69
  * may never have happened. So the write and its entry go inside `audit.atomic`: a durable log
62
70
  * opens a transaction, so a row never changes without the entry naming who changed it, and an
63
- * entry never describes a write that rolled back. The `failed` entry is written OUTSIDE it, after
64
- * the rollback — inside, it would be rolled back with the write it reports. Nothing about the
65
- * thrown value is read or rendered; the caller owns it, and an audit reason is a key.
71
+ * entry never describes a write that rolled back. A sink that throws on the `allowed` entry rolls
72
+ * the write back with it; the log then says `failed`, and so does the caller's error — it used to
73
+ * say `failed` for an action that had committed, and an operator re-ran it.
74
+ *
75
+ * 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
+ * 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.
66
79
  */
67
- export async function auditedWrite<T>(
68
- // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
69
- // sites would need the generic threaded through for nothing.
70
- resource: { readonly name: string },
71
- op: AdminOperation,
72
- ctx: CrudCtx,
80
+ export async function atomicallyAudited<T>(
81
+ audit: AuditLog,
82
+ entry: AuditedEntry,
83
+ reasons: { readonly allowed: string; readonly failed: string },
84
+ /** The id of the row the write addresses, for the `failed` entry: `null` for a set or a create. */
73
85
  entityId: string | null,
74
- decision: AdminDecision,
75
86
  write: () => Promise<T>,
76
87
  /** The diff of what the write did, and the id of the row it left — known only afterwards. */
77
88
  written: (value: T) => {
@@ -79,37 +90,55 @@ export async function auditedWrite<T>(
79
90
  readonly diff: readonly AuditFieldDiff[];
80
91
  },
81
92
  ): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
82
- const entry = {
83
- requestId: ctx.requestId,
84
- actor: ctx.actor,
85
- operation: op,
86
- kind: 'operation',
87
- entity: resource.name,
88
- permission: decision.permission,
89
- } as const;
90
93
  try {
91
- return await ctx.audit.atomic(async () => {
94
+ return await audit.atomic(async () => {
92
95
  const value = await write();
93
- const audit = await ctx.audit.append({
96
+ const appended = await audit.append({
94
97
  ...entry,
95
98
  ...written(value),
96
99
  outcome: 'allowed',
97
- reason: decision.reason,
100
+ reason: reasons.allowed,
98
101
  });
99
- return { value, audit };
102
+ return { value, audit: appended };
100
103
  });
101
104
  } catch (error) {
102
- await ctx.audit.append({
103
- ...entry,
104
- entityId,
105
- outcome: 'failed',
106
- reason: WRITE_FAILED_REASON,
107
- diff: [],
108
- });
105
+ await audit.append({ ...entry, entityId, outcome: 'failed', reason: reasons.failed, diff: [] });
109
106
  throw error;
110
107
  }
111
108
  }
112
109
 
110
+ /** A repo WRITE of one CRUD operation, through `atomicallyAudited`. */
111
+ export function auditedWrite<T>(
112
+ // Only the NAME is read, so this stays invariance-free: `AdminResource<Row>` at four call
113
+ // sites would need the generic threaded through for nothing.
114
+ resource: { readonly name: string },
115
+ op: AdminOperation,
116
+ ctx: CrudCtx,
117
+ entityId: string | null,
118
+ decision: AdminDecision,
119
+ write: () => Promise<T>,
120
+ written: (value: T) => {
121
+ readonly entityId: string | null;
122
+ readonly diff: readonly AuditFieldDiff[];
123
+ },
124
+ ): Promise<{ readonly value: T; readonly audit: AuditEntry }> {
125
+ return atomicallyAudited(
126
+ ctx.audit,
127
+ {
128
+ requestId: ctx.requestId,
129
+ actor: ctx.actor,
130
+ operation: op,
131
+ kind: 'operation',
132
+ entity: resource.name,
133
+ permission: decision.permission,
134
+ },
135
+ { allowed: decision.reason, failed: WRITE_FAILED_REASON },
136
+ entityId,
137
+ write,
138
+ written,
139
+ );
140
+ }
141
+
113
142
  /** The key a write to a row that is not there is logged with. */
114
143
  const ROW_MISSING_REASON = 'admin.error.row-missing';
115
144
 
@@ -138,6 +167,35 @@ export async function missingRow<Row extends AdminRow>(
138
167
  };
139
168
  }
140
169
 
170
+ /** An update against a version the row no longer has — `crud.ts`'s optimistic check. */
171
+ export async function staleRow<Row extends AdminRow>(
172
+ resource: AdminResource<Row>,
173
+ ctx: CrudCtx,
174
+ id: string,
175
+ decision: AdminDecision,
176
+ row: Row,
177
+ version: string,
178
+ ): Promise<CrudResult<Row>> {
179
+ return {
180
+ ok: false,
181
+ kind: 'stale',
182
+ row,
183
+ version,
184
+ audit: await ctx.audit.append({
185
+ requestId: ctx.requestId,
186
+ actor: ctx.actor,
187
+ operation: 'update',
188
+ kind: 'operation',
189
+ entity: resource.name,
190
+ entityId: id,
191
+ permission: decision.permission,
192
+ outcome: 'failed',
193
+ reason: ROW_CHANGED_REASON,
194
+ diff: [],
195
+ }),
196
+ };
197
+ }
198
+
141
199
  export async function invalid<Row extends AdminRow>(
142
200
  resource: AdminResource<Row>,
143
201
  op: AdminOperation,
package/src/crud.ts CHANGED
@@ -5,7 +5,7 @@
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 } from './crud-outcome';
8
+ import { auditedWrite, deniedEntry, invalid, missingRow, refuse, staleRow } from './crud-outcome';
9
9
  import { type AdminListRequest, findRow, listWhere } from './list-scope';
10
10
  import { type AdminPage, fetchPage } from './pagination';
11
11
  import {
@@ -20,6 +20,7 @@ import type { AdminRow } from './registry';
20
20
  import { type AdminResource, repoOf } from './resource';
21
21
  import type { AdminScope } from './resource-list';
22
22
  import { outOfScopeDecision, writeOutsideScope } from './row-scope-write';
23
+ import { rowVersion } from './row-version';
23
24
  import { type ValidationIssue, validateInput } from './validate';
24
25
 
25
26
  export interface CrudCtx {
@@ -49,7 +50,28 @@ export type CrudResult<Row extends AdminRow> =
49
50
  * actor's row scope, which are one answer on purpose. Nothing was written; the attempt is on
50
51
  * the log as `failed`, like every other write that did not happen.
51
52
  */
52
- | { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry };
53
+ | { readonly ok: false; readonly kind: 'missing'; readonly audit: AuditEntry }
54
+ /**
55
+ * An update against a `version` the row no longer has: somebody wrote it after the form was
56
+ * rendered. Nothing was written; the attempt is on the log as `failed`
57
+ * (`admin.error.row-changed`), and `row` is the row as it is NOW — what a re-render shows.
58
+ */
59
+ | {
60
+ readonly ok: false;
61
+ readonly kind: 'stale';
62
+ readonly row: Row;
63
+ readonly version: string;
64
+ readonly audit: AuditEntry;
65
+ };
66
+
67
+ export interface AdminUpdateOptions {
68
+ /**
69
+ * `rowVersion()` of the row the caller was shown — the edit form posts it as `_version`. Given,
70
+ * a row that has changed since is refused (`kind: 'stale'`) instead of overwritten with the
71
+ * stale copy. Omitted (an MCP call, a script), the write is against the row as it is now.
72
+ */
73
+ readonly version?: string;
74
+ }
53
75
 
54
76
  export type ListResult<Row extends AdminRow> =
55
77
  | {
@@ -89,6 +111,12 @@ export function decideOperation(
89
111
  * pair was the only gate on a single row.
90
112
  */
91
113
  row?: AdminRow | null,
114
+ /**
115
+ * The validated values a create or an update WRITES — the row as the write leaves it. A rule
116
+ * over what is written ("nobody files an invoice as paid") reads it as `input`; absent on every
117
+ * surface that writes nothing.
118
+ */
119
+ input?: Readonly<Record<string, unknown>>,
92
120
  ): AdminDecision {
93
121
  // First, before any grant is consulted: an operation the resource does not OFFER is refused for
94
122
  // everyone. `canOperate` checked this for the nav, the buttons and the MCP tools, and the three
@@ -106,6 +134,7 @@ export function decideOperation(
106
134
  entity: resource.name,
107
135
  ...(id === undefined ? {} : { id }),
108
136
  ...(row === undefined ? {} : { row }),
137
+ ...(input === undefined ? {} : { input }),
109
138
  });
110
139
  }
111
140
 
@@ -231,13 +260,17 @@ export async function adminCreate<Row extends AdminRow>(
231
260
  const permission = entityPermissionFor(resource.permission, 'create');
232
261
  return refuse(resource, 'create', ctx, outOfScopeDecision(permission, outside), null);
233
262
  }
263
+ // Decided again on what is WRITTEN: the first decision had no values to read, and a rule over
264
+ // them is the one an ownership or a state rule on a create is.
265
+ const written = decideOperation(resource, 'create', ctx, undefined, undefined, parsed.value);
266
+ if (!written.allowed) return refuse(resource, 'create', ctx, written, null);
234
267
 
235
268
  const { value: row, audit } = await auditedWrite(
236
269
  resource,
237
270
  'create',
238
271
  ctx,
239
272
  null,
240
- decision,
273
+ written,
241
274
  () => repoOf(resource).create({ ...parsed.value, ...secrets }),
242
275
  (made) => ({
243
276
  entityId: String(made[resource.idField] ?? ''),
@@ -252,6 +285,7 @@ export async function adminUpdate<Row extends AdminRow>(
252
285
  ctx: CrudCtx,
253
286
  id: string,
254
287
  patch: Readonly<Record<string, unknown>>,
288
+ options: AdminUpdateOptions = {},
255
289
  ): Promise<CrudResult<Row>> {
256
290
  // `before` was already loaded here, just after the guard rather than before it — so the rule
257
291
  // that decides whether this actor may touch THIS row never saw the row.
@@ -260,6 +294,12 @@ export async function adminUpdate<Row extends AdminRow>(
260
294
  const decision = decideOperation(resource, 'update', ctx, id, before);
261
295
  if (!decision.allowed) return refuse(resource, 'update', ctx, decision, id);
262
296
  if (before === null) return missingRow(resource, 'update', ctx, id, decision);
297
+ // Optimistic concurrency: the patch was composed against what the caller was SHOWN, and a merge
298
+ // onto a row that has moved on writes their stale copy of every field they posted.
299
+ const now = rowVersion(before);
300
+ if (options.version !== undefined && options.version !== now) {
301
+ return staleRow(resource, ctx, id, decision, before, now);
302
+ }
263
303
 
264
304
  // `{ ...before }` carries no sealed value — a row's sealed properties are non-enumerable — and
265
305
  // `$schema` has no member for one, so the merged object is exactly what the schema describes.
@@ -286,6 +326,10 @@ export async function adminUpdate<Row extends AdminRow>(
286
326
  const permission = entityPermissionFor(resource.permission, 'update');
287
327
  return refuse(resource, 'update', ctx, outOfScopeDecision(permission, outside), id);
288
328
  }
329
+ // Decided again with the row before AND the row the write leaves — a rule over the values an
330
+ // operator is about to write ("not to paid", "not to another owner") reads `input`.
331
+ const written = decideOperation(resource, 'update', ctx, id, before, parsed.value);
332
+ if (!written.allowed) return refuse(resource, 'update', ctx, written, id);
289
333
  const validatedPatch: Readonly<Record<string, unknown>> = {
290
334
  ...Object.fromEntries(
291
335
  submittedKeys
@@ -299,7 +343,7 @@ export async function adminUpdate<Row extends AdminRow>(
299
343
  'update',
300
344
  ctx,
301
345
  id,
302
- decision,
346
+ written,
303
347
  () => repo.update(id, validatedPatch),
304
348
  (changed) => ({ entityId: id, diff: rowDiff(resource, before, changed, secrets) }),
305
349
  );
package/src/dev/data.ts CHANGED
@@ -155,12 +155,9 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
155
155
  hydrate: route.hydrate,
156
156
  // A descriptor has no `handler`: the FILE is what names the row.
157
157
  handler: route.file,
158
- // Two flat fields on the descriptor, one nested bag on the fact — the panel's budget
159
- // check reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
160
- budget: {
161
- ...(route.budgetJs === null ? {} : { js: route.budgetJs }),
162
- ...(route.budgetLcp === null ? {} : { lcp: route.budgetLcp }),
163
- },
158
+ // A flat field on the descriptor, a nested bag on the fact — the panel's budget check
159
+ // reads `budget.js`. Spread, never `js: undefined`: `exactOptionalPropertyTypes`.
160
+ budget: route.budgetJs === null ? {} : { js: route.budgetJs },
164
161
  // `revalidateTags`, already flattened to keys — never `revalidate.tags`, a shape the
165
162
  // descriptor does not have and which answered `[]` for every ISR route in the app.
166
163
  revalidateTags: route.revalidateTags,
package/src/dev/facts.ts CHANGED
@@ -20,7 +20,7 @@ export interface RouteFact {
20
20
  readonly hydrate: string;
21
21
  /** The route's source file: what names the row. A descriptor publishes no handler. */
22
22
  readonly handler: string;
23
- readonly budget: { readonly js?: string; readonly lcp?: number };
23
+ readonly budget: { readonly js?: string };
24
24
  readonly revalidateTags: readonly string[];
25
25
  /**
26
26
  * `RouteDescriptor.mount` — who mounted a route no surface file declares (`defineAdmin`), and
package/src/dev/index.ts CHANGED
@@ -48,3 +48,4 @@ export {
48
48
  devDashboard,
49
49
  devShellStyle,
50
50
  } from './server';
51
+ export { DEV_SQL_MAX_ROWS, readOnlySql } from './sql-runner';
@@ -2,6 +2,7 @@
2
2
  // Kills: "why is this page still stale?" — the tag graph, and the invalidation log showing
3
3
  // what busted what.
4
4
 
5
+ import { DevSourceUnavailableError } from '../errors';
5
6
  import type { CacheEdgeFact, InvalidationFact } from './facts';
6
7
  import type { DevPanel } from './panel';
7
8
 
@@ -23,11 +24,15 @@ export const cachePanel: DevPanel<CachePanelData> = {
23
24
  questionKey: 'dev.panel.cache.question',
24
25
  async data(sources): Promise<CachePanelData> {
25
26
  const graph = await sources.cacheGraph();
26
- // The invalidation log needs a running process that has served a write; the graph alone
27
- // is still worth showing.
27
+ // The invalidation log needs a running process that has served a write; the graph alone is
28
+ // still worth showing when it is NOT WIRED — and only then. A log that is wired and failed is
29
+ // a diagnostic, not "no invalidations yet".
28
30
  const invalidations = await sources
29
31
  .invalidations()
30
- .catch((): readonly InvalidationFact[] => []);
32
+ .catch((error: unknown): readonly InvalidationFact[] => {
33
+ if (!(error instanceof DevSourceUnavailableError)) throw error;
34
+ return [];
35
+ });
31
36
 
32
37
  const busted = new Set(invalidations.flatMap((event) => event.busted));
33
38
  // A `Map`, then `Object.fromEntries` — never `count[key] = (count[key] ?? 0) + 1` on a plain
@@ -3,6 +3,7 @@
3
3
  // calls, and policy decisions on one axis, with the N+1 already counted for you — counted by
4
4
  // `x dev`'s statement ledger and read here through `statementLoops()`, never re-derived.
5
5
 
6
+ import { DevSourceUnavailableError } from '../errors';
6
7
  import type { RequestTrace, SpanKind, StatementLoopFact, TimelineSpan } from './facts';
7
8
  import type { DevPanel } from './panel';
8
9
 
@@ -68,8 +69,12 @@ export const timelinePanel: DevPanel<TimelinePanelData> = {
68
69
  (wanted === null ? traces[0] : traces.find((trace) => trace.requestId === wanted)) ?? null;
69
70
  // Degrade rather than reject, as `panel-live.ts` does for `subscribers`: a host with traces
70
71
  // but no detector installed must still get its flamegraph. `null` carries that difference —
71
- // "nobody counted" is not "counted, and this request was clean".
72
- const loops = await sources.statementLoops().catch((): null => null);
72
+ // "nobody counted" is not "counted, and this request was clean". Only for the error that MEANS
73
+ // unwired: a detector that ran and failed is a diagnostic, never "no detector".
74
+ const loops = await sources.statementLoops().catch((error: unknown): null => {
75
+ if (!(error instanceof DevSourceUnavailableError)) throw error;
76
+ return null;
77
+ });
73
78
 
74
79
  const totalsByKind: Record<string, number> = {};
75
80
  const sqlCounts = new Map<string, number>();
package/src/dev/panel.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // draws — and because `data()` returns plain JSON, `--json` and the rendered tab are the
3
3
  // same facts by construction.
4
4
 
5
- import { renderThrowable, stringField } from '@ultimat3/core';
5
+ import { InternalError, renderThrowable, stringField } from '@ultimat3/core';
6
6
  import type { DevSources } from './facts';
7
7
 
8
8
  export interface DevPanel<Data = unknown> {
@@ -53,7 +53,9 @@ export async function panelPayload(
53
53
  panel: panel.key,
54
54
  ok: false,
55
55
  error: {
56
- code: stringField(error, 'code') ?? 'X_NOT_IMPLEMENTED',
56
+ // Core's code for a failure nothing classified. `X_NOT_IMPLEMENTED` was here, and it told
57
+ // the reader a feature was missing when a source had thrown something with no code at all.
58
+ code: stringField(error, 'code') ?? InternalError.code,
57
59
  cause: stringField(error, 'cause') ?? renderThrowable(error),
58
60
  fix: stringField(error, 'fix') ?? 'x dev --help',
59
61
  },
package/src/dev/server.ts CHANGED
@@ -4,7 +4,7 @@
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';
7
+ import { DEFAULT_ENVIRONMENT, escapeHtml, tryResolveEnvironment } from '@ultimat3/core';
8
8
  import { FRAMEWORK_CATALOG_LOCALE, translatorFor } from '@ultimat3/i18n';
9
9
  import type { ColorRole } from '@ultimat3/ui';
10
10
  import { DevDashboardInProdError } from '../errors';
@@ -85,9 +85,6 @@ const jsonResponse = (body: unknown, status = 200): Response =>
85
85
  headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
86
86
  });
87
87
 
88
- const escapeHtml = (value: string): string =>
89
- value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
90
-
91
88
  /** The six roles /_x paints with. `--x-*` is the admin's namespace; the values are ui's. */
92
89
  const SHELL_ROLES = [
93
90
  'bg',
@@ -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
+ }
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
@@ -6,6 +6,7 @@
6
6
  // that was typed, so the schema refuses it in its own words against the field that carried it.
7
7
 
8
8
  import type { AdminField } from './fields';
9
+ import type { AdminRow } from './registry';
9
10
  import type { AdminResource } from './resource';
10
11
 
11
12
  /** The posted name of a money field's currency, beside the minor-unit amount under the field's own. */
@@ -13,6 +14,39 @@ export const currencyFieldOf = (field: string): string => `${field}.currency`;
13
14
 
14
15
  type Posted = Readonly<Record<string, unknown>>;
15
16
 
17
+ /**
18
+ * What a date control is RENDERED with: `<input type="date">` wants `YYYY-MM-DD`, `datetime-local`
19
+ * wants `YYYY-MM-DDTHH:mm`. One function for the widget that draws the box and for the decode that
20
+ * reads it back, so "the operator left it as it was" is a comparison of one text with itself.
21
+ */
22
+ export const datetimeInputValue = (iso: string, precision: 'date' | 'instant'): string =>
23
+ iso.slice(0, precision === 'date' ? 10 : 16);
24
+
25
+ const isoOf = (value: unknown): string | undefined =>
26
+ value instanceof Date
27
+ ? Number.isNaN(value.getTime())
28
+ ? undefined
29
+ : value.toISOString()
30
+ : typeof value === 'string'
31
+ ? value
32
+ : undefined;
33
+
34
+ /**
35
+ * An instant posted back as the box rendered it. `datetime-local` holds minutes, so writing what
36
+ * came back turned `10:20:45.123` into `10:20:00.000` on every edit of ANY field — a change nobody
37
+ * made, recorded in the audit diff as theirs. Such a field is left out of the patch and the stored
38
+ * instant stands. A calendar date carries every digit it has and is never in this position.
39
+ */
40
+ const untouchedInstant = (
41
+ field: AdminField,
42
+ raw: string | undefined,
43
+ before: AdminRow,
44
+ ): boolean => {
45
+ if (field.widget !== 'datetime' || field.type === 'date' || raw === undefined) return false;
46
+ const stored = isoOf(before[field.name]);
47
+ return stored !== undefined && raw === datetimeInputValue(stored, 'instant');
48
+ };
49
+
16
50
  /** The last value a posted name carried, as text. A repeated name arrives as a list. */
17
51
  export const posted = (form: Posted, name: string): string | undefined => {
18
52
  if (!Object.hasOwn(form, name)) return undefined;
@@ -79,13 +113,20 @@ function decodeField(field: AdminField, form: Posted): unknown {
79
113
  /**
80
114
  * Every field the form rendered, decoded. A sealed column rides along as the string that was
81
115
  * typed — `crud.ts` drops an empty one, which is what makes an untouched box mean "unchanged".
116
+ *
117
+ * `before` is the row an EDIT form was rendered from: an instant that comes back exactly as it was
118
+ * drawn is left out rather than rewritten at the control's precision. A create has none.
82
119
  */
83
120
  export function decodeForm(
84
121
  resource: AdminResource,
85
122
  form: Posted,
123
+ before?: AdminRow,
86
124
  ): Readonly<Record<string, unknown>> {
87
125
  const out: Record<string, unknown> = {};
88
- for (const field of resource.formFields) out[field.name] = decodeField(field, form);
126
+ for (const field of resource.formFields) {
127
+ if (before !== undefined && untouchedInstant(field, posted(form, field.name), before)) continue;
128
+ out[field.name] = decodeField(field, form);
129
+ }
89
130
  for (const field of resource.secretFields) out[field.name] = posted(form, field.name) ?? '';
90
131
  return out;
91
132
  }
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
@@ -104,7 +104,6 @@ export {
104
104
  type AdminBatchInput,
105
105
  type AdminBatchResult,
106
106
  BATCH_NOT_OFFERED_REASON,
107
- BATCH_QUEUED_REASON,
108
107
  type BatchEnqueue,
109
108
  type BatchRow,
110
109
  type BatchRowOutcome,
@@ -124,7 +123,9 @@ export {
124
123
  batchEnqueue,
125
124
  } from './batch-job';
126
125
  export { BATCH_MATCHING_REASON, matchingConfirmationToken } from './batch-matching';
126
+ export { BATCH_QUEUED_REASON } from './batch-queue';
127
127
  export {
128
+ type AdminUpdateOptions,
128
129
  adminCreate,
129
130
  adminDestroy,
130
131
  adminDetail,
@@ -290,7 +291,6 @@ export {
290
291
  ruleFor,
291
292
  } from './permissions';
292
293
  export {
293
- adminPermissions,
294
294
  declareAdminPermissions,
295
295
  type PolicyAuthzInput,
296
296
  policyAuthz,
@@ -382,6 +382,7 @@ export {
382
382
  adminRoutes,
383
383
  } from './routes';
384
384
  export { ROW_OUT_OF_SCOPE_REASON } from './row-scope-write';
385
+ export { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
385
386
  export {
386
387
  type AdminRouteRequest,
387
388
  type AdminRouteResponse,