@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.
@@ -5,6 +5,7 @@
5
5
  import { isUltimateError } from '@ultimat3/core';
6
6
  import { getEntity, invariantViolated } from '@ultimat3/entity';
7
7
  import { adminColumnsOf } from './entity-columns';
8
+ import { seekPage } from './keyset-seek';
8
9
  import type {
9
10
  AdminDb,
10
11
  AdminEntity,
@@ -41,18 +42,21 @@ const literal = (text: string): string => text.replace(/[\\%_]/g, (wild) => `\\$
41
42
  * A comparison value, in the shape the column holds. An instant travels as ISO text — in a URL, in
42
43
  * a cursor — and the handle is given the `Date`: Postgres would cast the text, the memory driver
43
44
  * compares a `Date` to a string by its characters, and a keyset over `createdAt` (the admin's
44
- * default sort) then answers page one for every page.
45
+ * default sort) then answers page one for every page. An `integer` travels as text in a cursor
46
+ * too, and the memory driver's `eq` holds `'7'` and `7` to be two values where Postgres binds one.
45
47
  */
46
48
  type Typed = (field: string, value: unknown) => unknown;
47
49
 
48
50
  const typedBy = (entity: AdminEntity): Typed => {
49
- const instants = new Set(
50
- adminColumnsOf(entity)
51
- .filter((column) => column.kind === 'timestamptz')
52
- .map((column) => column.name),
53
- );
51
+ const kinds = new Map(adminColumnsOf(entity).map((column) => [column.name, column.kind]));
54
52
  return (field, value) => {
55
- if (typeof value !== 'string' || !instants.has(field)) return value;
53
+ if (typeof value !== 'string') return value;
54
+ const kind = kinds.get(field);
55
+ if (kind === 'integer') {
56
+ const count = Number(value);
57
+ return value.trim() !== '' && Number.isSafeInteger(count) ? count : value;
58
+ }
59
+ if (kind !== 'timestamptz') return value;
56
60
  const at = new Date(value);
57
61
  return Number.isNaN(at.getTime()) ? value : at;
58
62
  };
@@ -81,37 +85,6 @@ const filtered = (
81
85
  typed: Typed,
82
86
  ): AdminTableRead => where.reduce(predicate(typed), read);
83
87
 
84
- /**
85
- * `after`+asc and `before`+desc walk forward; the other two walk back. Written as one XOR rather
86
- * than a four-branch table, because the four branches are the same fact said twice.
87
- *
88
- * `gte`/`lte`, not `gt`/`lt`: rows sharing the boundary's sort value are still on the far side of
89
- * it, and `dropThroughTie` below is what removes the ones already served.
90
- */
91
- const sought = (read: AdminTableRead, query: AdminListQuery, typed: Typed): AdminTableRead => {
92
- const bound = query.after ?? query.before;
93
- if (bound === undefined) return read;
94
- const forward = (query.after !== undefined) !== (query.sort.direction === 'desc');
95
- // The bound is the STRING the cursor carried (`pagination.ts` writes a Date as ISO).
96
- return read.andWhere(bound.field, forward ? 'gte' : 'lte', typed(bound.field, bound.value));
97
- };
98
-
99
- /**
100
- * Drop everything up to and including the cursor row. A typed chain cannot express the row-value
101
- * comparison `(sort, id) > (value, id)` a keyset needs, and the alternative — a strict `gt` on the
102
- * sort column alone — silently skips every row that ties with the boundary.
103
- */
104
- const dropThroughTie = (
105
- rows: readonly AdminRow[],
106
- query: AdminListQuery,
107
- idField: string,
108
- ): readonly AdminRow[] => {
109
- const bound = query.after ?? query.before;
110
- if (bound === undefined) return rows;
111
- const at = rows.findIndex((row) => String(row[idField]) === bound.id);
112
- return at === -1 ? rows : rows.slice(at + 1);
113
- };
114
-
115
88
  /**
116
89
  * One entity + its table → one `AdminRepo`.
117
90
  *
@@ -124,6 +97,7 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
124
97
  const idField = entity.$primaryKey[0] ?? 'id';
125
98
  const idColumn = Object.hasOwn(entity.$columns, idField) ? entity.$columns[idField] : undefined;
126
99
  const typed = typedBy(entity);
100
+ const columns = adminColumnsOf(entity);
127
101
  /**
128
102
  * `AdminRepo` carries `id: string` — a URL param, untyped by nature. The primary key column's
129
103
  * OWN `$parse` is what earns the crossing: `/posts/nope` is judged here, at the door, instead
@@ -152,14 +126,31 @@ export function adminRepoFor(entity: AdminEntity, table: AdminTable): AdminRepo<
152
126
 
153
127
  return {
154
128
  async list(query: AdminListQuery): Promise<readonly AdminRow[]> {
155
- const rows = await sought(filtered(table, query.where ?? [], typed), query, typed)
156
- .orderBy(query.sort.field, query.sort.direction)
157
- // The tie-break, always: a page boundary on a partial order repeats or drops a row.
158
- .orderBy(idField, query.sort.direction)
159
- // One over the requested page so the tie-drop cannot hand back a short page.
160
- .limit(query.limit + 1)
161
- .all();
162
- return dropThroughTie(rows, query, idField).slice(0, query.limit);
129
+ const base = filtered(table, query.where ?? [], typed);
130
+ const sortField = query.sort.field;
131
+ const facts = columns.find((column) => column.name === sortField);
132
+ return seekPage(
133
+ query,
134
+ {
135
+ field: sortField,
136
+ idField,
137
+ nullable: facts?.nullable ?? true,
138
+ instant: facts?.kind === 'timestamptz',
139
+ typed: (value) => typed(sortField, value),
140
+ },
141
+ async (where, order, limit) => {
142
+ const read = where.reduce(
143
+ (chain, term) =>
144
+ term.value === undefined
145
+ ? chain.andWhere(term.field, term.op)
146
+ : chain.andWhere(term.field, term.op, term.value),
147
+ base,
148
+ );
149
+ // The tie-break, always: a page boundary on a partial order repeats or drops a row.
150
+ const sorted = order.by === 'pair' ? read.orderBy(sortField, order.direction) : read;
151
+ return [...(await sorted.orderBy(idField, order.direction).limit(limit).all())];
152
+ },
153
+ );
163
154
  },
164
155
  // `async`, so a refused id arrives as a rejection: these return promises, and a caller that
165
156
  // chains `.catch()` instead of `await`ing would never see a synchronous throw.
@@ -94,6 +94,12 @@ interface Declared {
94
94
  readonly entity: AdminEntity;
95
95
  }
96
96
 
97
+ /**
98
+ * The list URL's spelling of "no scope, not even the default" — `?scope=*`, what a related card's
99
+ * "all" link opens. Reserved: a scope declared under it is refused, so the word means one thing.
100
+ */
101
+ export const NO_SCOPE = '*';
102
+
97
103
  /** The declared scopes, in declaration order — which is the order the tabs are drawn in. */
98
104
  export function scopesOf(
99
105
  resource: Declared,
@@ -105,6 +111,9 @@ export function scopesOf(
105
111
  const refuse = (asked: string, cause: string): never => {
106
112
  throw new AdminFilterInvalidError({ entity: resource.name, asked, cause, known: names });
107
113
  };
114
+ if (Object.hasOwn(scopes ?? {}, NO_SCOPE)) {
115
+ refuse(`scopes.${NO_SCOPE}`, `is the list URL's word for "no scope" and cannot name one`);
116
+ }
108
117
  const defaults = entries.filter(([, scope]) => scope.default === true).map(([name]) => name);
109
118
  if (defaults.length > 1) {
110
119
  refuse(
@@ -0,0 +1,20 @@
1
+ // The version an edit form is rendered against: a keyed fingerprint of the row as it was READ,
2
+ // posted back in a hidden field, so `adminUpdate` can tell "the row I was shown" from "the row as
3
+ // it is now" and refuse a write that would silently overwrite a concurrent edit.
4
+
5
+ import { keyedFingerprint } from '@ultimat3/core';
6
+ import type { AdminRow } from './registry';
7
+
8
+ /** The posted name of the version, beside `_operation`: never a column, never an input field. */
9
+ export const VERSION_FIELD = '_version';
10
+
11
+ /** The key a write against a stale version is refused and audited with. */
12
+ export const ROW_CHANGED_REASON = 'admin.error.row-changed';
13
+
14
+ /**
15
+ * Every value the row enumerates — a sealed one is non-enumerable and is not in it, so a password
16
+ * change is not a "concurrent edit" and no sealed value is ever folded into page text. Keyed: the
17
+ * page carries it, and an unkeyed hash of a row is an offline oracle for a short value in a column
18
+ * the form does not draw.
19
+ */
20
+ export const rowVersion = (row: AdminRow): string => keyedFingerprint(row, 'admin.row-version');
@@ -36,6 +36,7 @@ import type { AdminRow } from './registry';
36
36
  import { relatedLists, relatedOf } from './related';
37
37
  import { type RelationNeed, relationNeeds, relationsFor } from './relations';
38
38
  import type { AdminResource } from './resource';
39
+ import { ROW_CHANGED_REASON, rowVersion, VERSION_FIELD } from './row-version';
39
40
  import { batchWrite, rowActionFormScreen, rowActionWrite } from './screen-action';
40
41
  import {
41
42
  type AdminRouteRequest,
@@ -250,6 +251,9 @@ export function detailScreen(app: AdminApp, resource: AdminResource): AdminScree
250
251
  entityId: id,
251
252
  changes: true,
252
253
  limit: HISTORY_PAGE,
254
+ // The actor's tenant's entries only, as on the audit screen — a row two orgs may open
255
+ // does not show one org who in the other changed it.
256
+ ...(request.ctx.actor.orgId === undefined ? {} : { orgId: request.ctx.actor.orgId }),
253
257
  ...(before === undefined ? {} : { before }),
254
258
  }),
255
259
  ]);
@@ -310,6 +314,7 @@ async function form(
310
314
  mode: 'create' | 'edit',
311
315
  values: Readonly<Record<string, unknown>>,
312
316
  issues: readonly ValidationIssue[],
317
+ version: string | null,
313
318
  ): Promise<JSX.Element> {
314
319
  const list = listHref(app.basePath, resource);
315
320
  return (
@@ -323,10 +328,18 @@ async function form(
323
328
  ctx={await relationsOf(app, resource, request, values, true)}
324
329
  action={new URL(request.url).pathname}
325
330
  cancelHref={mode === 'create' ? list : rowHref(app, resource, idOf(request))}
331
+ version={version}
326
332
  />
327
333
  );
328
334
  }
329
335
 
336
+ /** The form-level issue a write against a stale version is re-rendered with. */
337
+ const ROW_CHANGED_ISSUE: ValidationIssue = {
338
+ path: VERSION_FIELD,
339
+ message: 'the row was changed after this form was opened',
340
+ messageKey: ROW_CHANGED_REASON,
341
+ };
342
+
330
343
  export function createScreen(
331
344
  app: AdminApp,
332
345
  route: AdminRoute,
@@ -340,7 +353,7 @@ export function createScreen(
340
353
  app,
341
354
  request,
342
355
  resource.titleKey,
343
- await form(app, resource, request, 'create', {}, []),
356
+ await form(app, resource, request, 'create', {}, [], null),
344
357
  );
345
358
  }
346
359
  const input = decodeForm(resource, request.form ?? {});
@@ -350,13 +363,14 @@ export function createScreen(
350
363
  return { kind: 'redirect', location: rowHref(app, resource, id) };
351
364
  }
352
365
  if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
353
- if (result.kind === 'missing') return notFound(app, resource, request);
366
+ // A create has no row to have gone missing or stale: neither is an answer it can give.
367
+ if (result.kind !== 'invalid') return notFound(app, resource, request);
354
368
  // 422, with what was typed: a refused form that came back empty is a form filled in twice.
355
369
  return framed(
356
370
  app,
357
371
  request,
358
372
  resource.titleKey,
359
- await form(app, resource, request, 'create', input, result.issues),
373
+ await form(app, resource, request, 'create', input, result.issues, null),
360
374
  422,
361
375
  );
362
376
  };
@@ -365,8 +379,8 @@ export function createScreen(
365
379
  export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminResource): AdminScreen {
366
380
  return async (request) => {
367
381
  const id = idOf(request);
382
+ const row = await findRow(resource, request.ctx.actor, id);
368
383
  if (request.method === 'GET') {
369
- const row = await findRow(resource, request.ctx.actor, id);
370
384
  const decision = decideOperation(resource, 'update', request.ctx, id, row);
371
385
  if (!decision.allowed) return refusedPage(app, request, route, decision);
372
386
  if (row === null) return notFound(app, resource, request);
@@ -374,19 +388,53 @@ export function editScreen(app: AdminApp, route: AdminRoute, resource: AdminReso
374
388
  app,
375
389
  request,
376
390
  resource.titleKey,
377
- await form(app, resource, request, 'edit', row, []),
391
+ await form(app, resource, request, 'edit', row, [], rowVersion(row)),
378
392
  );
379
393
  }
380
- const input = decodeForm(resource, request.form ?? {});
381
- const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input);
394
+ const submitted = request.form ?? {};
395
+ // Decoded against the row as it is: an instant posted back as the minute it was drawn at is
396
+ // left out, not rewritten to that minute. A row that moved on since the form was rendered is
397
+ // refused below on its version, so "as it is" and "as it was drawn" are the same row here.
398
+ const input = decodeForm(resource, submitted, row ?? undefined);
399
+ // A post with no version is not a form this admin rendered: it is refused as stale, never run
400
+ // as an unversioned write — the version is how the form says which row it was looking at.
401
+ const version = posted(submitted, VERSION_FIELD) ?? '';
402
+ const result = await adminUpdate<AdminRow>(resource, request.ctx, id, input, { version });
382
403
  if (result.ok) return { kind: 'redirect', location: rowHref(app, resource, id) };
383
404
  if (result.kind === 'denied') return refused(app, request, resource.titleKey, result.decision);
384
405
  if (result.kind === 'missing') return notFound(app, resource, request);
406
+ if (result.kind === 'stale') {
407
+ // What they typed, over the row as it is NOW, under its current version: saving again is a
408
+ // decision taken with the other edit on screen, not an accident.
409
+ return framed(
410
+ app,
411
+ request,
412
+ resource.titleKey,
413
+ await form(
414
+ app,
415
+ resource,
416
+ request,
417
+ 'edit',
418
+ { ...result.row, ...input },
419
+ [ROW_CHANGED_ISSUE],
420
+ result.version,
421
+ ),
422
+ 409,
423
+ );
424
+ }
385
425
  return framed(
386
426
  app,
387
427
  request,
388
428
  resource.titleKey,
389
- await form(app, resource, request, 'edit', input, result.issues),
429
+ await form(
430
+ app,
431
+ resource,
432
+ request,
433
+ 'edit',
434
+ { ...(row ?? {}), ...input },
435
+ result.issues,
436
+ version,
437
+ ),
390
438
  422,
391
439
  );
392
440
  };
@@ -14,8 +14,14 @@ import { adminSearch } from './search';
14
14
  const AUDIT_PAGE = 100;
15
15
 
16
16
  export function auditScreen(app: AdminApp, route: AdminRoute): AdminScreen {
17
- return guardedScreen(app, route, async () => {
18
- const entries = await app.audit.entries({ limit: AUDIT_PAGE });
17
+ return guardedScreen(app, route, async (request) => {
18
+ // The actor's tenant's trail: `audit:read` held in org A is not a read of org B's operators,
19
+ // their ids or what they touched. An actor with no org — the platform — reads every tenant's.
20
+ const orgId = request.ctx.actor.orgId;
21
+ const entries = await app.audit.entries({
22
+ limit: AUDIT_PAGE,
23
+ ...(orgId === undefined ? {} : { orgId }),
24
+ });
19
25
  return (
20
26
  <Card>
21
27
  {entries.length === 0 ? (
package/src/search.ts CHANGED
@@ -105,6 +105,7 @@ async function searchResource(
105
105
  }
106
106
 
107
107
  const SKIPPED_FORBIDDEN = 'admin.search.skipped.forbidden';
108
+ const SKIPPED_FAILED = 'admin.search.skipped.failed';
108
109
 
109
110
  export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchResult> {
110
111
  const { ctx } = input;
@@ -160,7 +161,6 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
160
161
  skipped.push({ entity: resource.name, reason: 'admin.search.skipped.no-repo' });
161
162
  continue;
162
163
  }
163
- searched.push(resource.name);
164
164
  // Appended BEFORE the read, so a repo that throws still leaves the record that the rows were
165
165
  // asked for — `audit.ts`: if it isn't logged, it didn't happen.
166
166
  audit.push(
@@ -176,7 +176,15 @@ export async function adminSearch(input: AdminSearchInput): Promise<AdminSearchR
176
176
  reason: decision.reason,
177
177
  }),
178
178
  );
179
- hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
179
+ try {
180
+ hits.push(...(await searchResource(resource, ctx.actor, term, limit)));
181
+ searched.push(resource.name);
182
+ } catch {
183
+ // One resource whose repo fails is a resource this search could not read — listed, so the
184
+ // operator is never silently shown a subset — never a 500 for every other one. Nothing is
185
+ // read off the thrown value: a reason is a key.
186
+ skipped.push({ entity: resource.name, reason: SKIPPED_FAILED });
187
+ }
180
188
  }
181
189
 
182
190
  return { term, hits, searched, skipped, audit };
package/src/widgets.tsx CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  } from '@ultimat3/ui';
18
18
  import type { JSX } from 'solid-js';
19
19
  import type { AdminField } from './fields';
20
- import { currencyFieldOf } from './form-decode';
20
+ import { currencyFieldOf, datetimeInputValue } from './form-decode';
21
21
  import { type WidgetContext, type WidgetProps, widgetProps } from './widget-value';
22
22
 
23
23
  export interface WidgetInput {
@@ -70,10 +70,6 @@ export const formatCalendarDate: DateTimeFormatter = (at, options) =>
70
70
  dateStyle: 'medium',
71
71
  }).format(at);
72
72
 
73
- /** `<input type="date">` wants `YYYY-MM-DD`; `datetime-local` wants `YYYY-MM-DDTHH:mm`. */
74
- const inputValueFor = (iso: string, precision: 'date' | 'instant'): string =>
75
- iso.slice(0, precision === 'date' ? 10 : 16);
76
-
77
73
  /**
78
74
  * An enum value's label: its translation when the catalog has one, the value itself when it does
79
75
  * not. The value is an identifier the ENTITY declares (`public`, `draft`), so showing it is true;
@@ -238,7 +234,7 @@ function editView(props: WidgetProps, input: WidgetInput): JSX.Element {
238
234
  {...shared}
239
235
  name={props.field}
240
236
  type={props.precision === 'date' ? 'date' : 'datetime-local'}
241
- value={props.value === null ? '' : inputValueFor(props.value, props.precision)}
237
+ value={props.value === null ? '' : datetimeInputValue(props.value, props.precision)}
242
238
  suffix={props.precision === 'date' ? undefined : 'UTC'}
243
239
  disabled={disabled}
244
240
  onInput={(event) => emit(input, event.currentTarget.value)}