@ultimat3/admin 29.4.0 → 30.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.
@@ -0,0 +1,173 @@
1
+ // Single responsibility: what an error GROUP is — one row per `errorFingerprint`, first and last
2
+ // seen, a count and one redacted sample — the store seam that keeps them, and its memory twin. The
3
+ // Postgres store is `error-groups-pg.ts`; the sink that feeds either is `error-groups-sink.ts`.
4
+
5
+ import { finiteCount } from '@ultimat3/core';
6
+ import type { PurgeTarget } from '@ultimat3/jobs';
7
+ import { ERROR_GROUPS_TABLE } from './error-groups-schema';
8
+
9
+ /** Triage state. `resolved` re-opens on the next occurrence; `ignored` stays ignored. */
10
+ export const ERROR_GROUP_STATUSES = ['open', 'resolved', 'ignored'] as const;
11
+
12
+ export type ErrorGroupStatus = (typeof ERROR_GROUP_STATUSES)[number];
13
+
14
+ /** One report, kept as the group's example: already redacted, never the thrown value. */
15
+ export interface ErrorGroupSample {
16
+ readonly at: string;
17
+ readonly severity: string;
18
+ readonly title: string;
19
+ readonly cause: string;
20
+ readonly fix: string;
21
+ readonly release: string | null;
22
+ readonly environment: string;
23
+ readonly requestId: string | null;
24
+ readonly traceId: string | null;
25
+ readonly meta: Readonly<Record<string, unknown>> | null;
26
+ readonly stack: string | null;
27
+ }
28
+
29
+ /** What the sink hands a store: `count` reports of one group, the newest at `at`. */
30
+ export interface ErrorGroupOccurrence {
31
+ readonly fingerprint: string;
32
+ readonly code: string;
33
+ readonly source: string;
34
+ readonly operation: string | null;
35
+ /** The cause's head, the line a list shows. */
36
+ readonly message: string;
37
+ readonly at: Date;
38
+ readonly count: number;
39
+ readonly sample: ErrorGroupSample;
40
+ }
41
+
42
+ export interface ErrorGroup {
43
+ readonly fingerprint: string;
44
+ readonly code: string;
45
+ readonly source: string;
46
+ readonly operation: string | null;
47
+ readonly message: string;
48
+ readonly status: ErrorGroupStatus;
49
+ readonly firstSeen: string;
50
+ readonly lastSeen: string;
51
+ readonly count: number;
52
+ readonly sample: ErrorGroupSample;
53
+ }
54
+
55
+ export interface ErrorGroupsQuery {
56
+ readonly status?: ErrorGroupStatus | undefined;
57
+ /** Newest-seen first. Default 50, at most `ERROR_GROUPS_MAX_LIMIT`. */
58
+ readonly limit?: number | undefined;
59
+ }
60
+
61
+ export const ERROR_GROUPS_DEFAULT_LIMIT = 50;
62
+ export const ERROR_GROUPS_MAX_LIMIT = 500;
63
+
64
+ export interface ErrorGroupsStore {
65
+ readonly kind: 'memory' | 'postgres';
66
+ /**
67
+ * Upsert on the fingerprint. `isNew` is true for exactly ONE caller per group, however many
68
+ * replicas raced to insert it — the answer `onNewGroup` is enqueued on.
69
+ */
70
+ record(occurrence: ErrorGroupOccurrence): Promise<{ readonly isNew: boolean }>;
71
+ list(query?: ErrorGroupsQuery): Promise<readonly ErrorGroup[]>;
72
+ get(fingerprint: string): Promise<ErrorGroup | undefined>;
73
+ /** The group after the change, or `undefined` when there is no such group. */
74
+ setStatus(fingerprint: string, status: ErrorGroupStatus): Promise<ErrorGroup | undefined>;
75
+ /** Deletes every group last seen before `beforeMs` (epoch ms); answers how many went. */
76
+ purge(beforeMs: number): Promise<number>;
77
+ }
78
+
79
+ /** The list limit, screened: a `NaN` here is `slice(0, NaN)`, an empty screen that looks fine. */
80
+ export function errorGroupsLimit(subject: string, limit: number | undefined): number {
81
+ const asked = finiteCount(subject, 'limit', limit ?? ERROR_GROUPS_DEFAULT_LIMIT, 1);
82
+ return Math.min(asked, ERROR_GROUPS_MAX_LIMIT);
83
+ }
84
+
85
+ /** `resolved` → `open` on a new occurrence: a fault that came back is a regression to look at. */
86
+ export const statusAfterOccurrence = (status: ErrorGroupStatus): ErrorGroupStatus =>
87
+ status === 'resolved' ? 'open' : status;
88
+
89
+ /** For tests and `x dev`: one process's heap, held to the same contract as the Postgres store. */
90
+ export function memoryErrorGroupsStore(): ErrorGroupsStore {
91
+ const groups = new Map<string, ErrorGroup>();
92
+ return {
93
+ kind: 'memory',
94
+ async record(occurrence) {
95
+ const at = occurrence.at.toISOString();
96
+ const seen = groups.get(occurrence.fingerprint);
97
+ if (seen === undefined) {
98
+ groups.set(occurrence.fingerprint, {
99
+ fingerprint: occurrence.fingerprint,
100
+ code: occurrence.code,
101
+ source: occurrence.source,
102
+ operation: occurrence.operation,
103
+ message: occurrence.message,
104
+ status: 'open',
105
+ firstSeen: at,
106
+ lastSeen: at,
107
+ count: occurrence.count,
108
+ sample: occurrence.sample,
109
+ });
110
+ return { isNew: true };
111
+ }
112
+ groups.set(occurrence.fingerprint, {
113
+ ...seen,
114
+ message: occurrence.message,
115
+ status: statusAfterOccurrence(seen.status),
116
+ lastSeen: at > seen.lastSeen ? at : seen.lastSeen,
117
+ count: seen.count + occurrence.count,
118
+ sample: occurrence.sample,
119
+ });
120
+ return { isNew: false };
121
+ },
122
+ async list(query = {}) {
123
+ const limit = errorGroupsLimit('memoryErrorGroupsStore', query.limit);
124
+ return [...groups.values()]
125
+ .filter((group) => query.status === undefined || group.status === query.status)
126
+ .sort((a, b) =>
127
+ a.lastSeen === b.lastSeen
128
+ ? a.fingerprint.localeCompare(b.fingerprint)
129
+ : a.lastSeen < b.lastSeen
130
+ ? 1
131
+ : -1,
132
+ )
133
+ .slice(0, limit);
134
+ },
135
+ async get(fingerprint) {
136
+ return groups.get(fingerprint);
137
+ },
138
+ async setStatus(fingerprint, status) {
139
+ const seen = groups.get(fingerprint);
140
+ if (seen === undefined) return undefined;
141
+ const next = { ...seen, status };
142
+ groups.set(fingerprint, next);
143
+ return next;
144
+ },
145
+ async purge(beforeMs) {
146
+ const before = new Date(beforeMs).toISOString();
147
+ let removed = 0;
148
+ for (const [fingerprint, group] of groups) {
149
+ if (group.lastSeen >= before) continue;
150
+ groups.delete(fingerprint);
151
+ removed += 1;
152
+ }
153
+ return removed;
154
+ },
155
+ };
156
+ }
157
+
158
+ export interface ErrorGroupsPurgeOptions {
159
+ /** REQUIRED: how long a group nobody has seen again is kept is the app's decision. */
160
+ readonly olderThanMs: number;
161
+ }
162
+
163
+ /** The sweep `purgeJob` runs: groups last seen more than `olderThanMs` before the job's clock. */
164
+ export function errorGroupsPurgeTarget(
165
+ store: ErrorGroupsStore,
166
+ options: ErrorGroupsPurgeOptions,
167
+ ): PurgeTarget {
168
+ const olderThanMs = finiteCount('errorGroupsPurgeTarget', 'olderThanMs', options.olderThanMs, 1);
169
+ return {
170
+ name: ERROR_GROUPS_TABLE,
171
+ purgeExpired: (nowMs) => store.purge(nowMs - olderThanMs),
172
+ };
173
+ }
@@ -0,0 +1,24 @@
1
+ // `@ultimat3/admin/error-groups` — the error-groups sink and its two stores, and nothing else:
2
+ // the sink is an `ErrorReporter` EVERY role installs (a worker's failures are grouped too), so it
3
+ // must load without one admin screen. The screens are `errorGroupsPages` on the main barrel.
4
+
5
+ export { type PostgresErrorGroupsStoreOptions, postgresErrorGroupsStore } from './error-groups-pg';
6
+ export {
7
+ ERROR_GROUPS_PER_MINUTE,
8
+ type ErrorGroupNotice,
9
+ type ErrorGroupsSink,
10
+ type ErrorGroupsSinkOptions,
11
+ errorGroupsSink,
12
+ } from './error-groups-sink';
13
+ export {
14
+ ERROR_GROUP_STATUSES,
15
+ type ErrorGroup,
16
+ type ErrorGroupOccurrence,
17
+ type ErrorGroupSample,
18
+ type ErrorGroupStatus,
19
+ type ErrorGroupsPurgeOptions,
20
+ type ErrorGroupsQuery,
21
+ type ErrorGroupsStore,
22
+ errorGroupsPurgeTarget,
23
+ memoryErrorGroupsStore,
24
+ } from './error-groups-store';
@@ -0,0 +1,60 @@
1
+ // `/admin/errors/:fingerprint`: one group — its counts, its status and the redacted sample the sink
2
+ // kept. Instants are printed as the ISO text the store holds (UTC, said so), never formatted in an
3
+ // ambient zone. A fingerprint with no group is a not-found body, not a blank page.
4
+
5
+ import { t } from '@ultimat3/i18n';
6
+ import { Card, Link } from '@ultimat3/ui';
7
+ import type { JSX } from 'solid-js';
8
+ import styles from './admin.module.scss';
9
+ import type { ErrorGroupsStore } from './error-groups-store';
10
+ import { jsonText } from './json-text';
11
+
12
+ export async function ErrorsDetail(props: {
13
+ readonly store: ErrorGroupsStore;
14
+ readonly listPath: string;
15
+ readonly fingerprint: string;
16
+ }): Promise<JSX.Element> {
17
+ const group = await props.store.get(props.fingerprint);
18
+ if (group === undefined) {
19
+ return (
20
+ <Card>
21
+ <p class="x-admin-empty">{t('admin.errors.missing')}</p>
22
+ <Link href={props.listPath}>{t('admin.errors.back')}</Link>
23
+ </Card>
24
+ );
25
+ }
26
+ const { sample } = group;
27
+ const facts: readonly (readonly [string, string])[] = [
28
+ ['admin.errors.field.code', group.code],
29
+ ['admin.errors.field.source', group.source],
30
+ ['admin.errors.field.operation', group.operation ?? ''],
31
+ ['admin.errors.field.status', t(`admin.errors.status.${group.status}`)],
32
+ ['admin.errors.field.count', String(group.count)],
33
+ ['admin.errors.field.first-seen', `${group.firstSeen} UTC`],
34
+ ['admin.errors.field.last-seen', `${group.lastSeen} UTC`],
35
+ ['admin.errors.field.release', sample.release ?? ''],
36
+ ['admin.errors.field.environment', sample.environment],
37
+ ['admin.errors.field.request', sample.requestId ?? ''],
38
+ ['admin.errors.field.trace', sample.traceId ?? ''],
39
+ ];
40
+ return (
41
+ <Card header={<h2 class={styles['mono']}>{group.fingerprint}</h2>}>
42
+ <dl>
43
+ {facts.map(([key, value]) => (
44
+ <>
45
+ <dt>{t(key)}</dt>
46
+ <dd class={styles['mono']}>{value}</dd>
47
+ </>
48
+ ))}
49
+ </dl>
50
+ <h3>{sample.title}</h3>
51
+ <p>{sample.cause}</p>
52
+ <p>
53
+ <code>{sample.fix}</code>
54
+ </p>
55
+ {sample.meta === null ? null : <pre class={styles['mono']}>{jsonText(sample.meta)}</pre>}
56
+ {sample.stack === null ? null : <pre class={styles['mono']}>{sample.stack}</pre>}
57
+ <Link href={props.listPath}>{t('admin.errors.back')}</Link>
58
+ </Card>
59
+ );
60
+ }
@@ -0,0 +1,74 @@
1
+ // `/admin/errors`: every error group the sink folded, newest-seen first, one native GET form to
2
+ // pick a status. No script, no state: the URL is the screen (`?status=`), and a parameter this
3
+ // screen does not read is the list's 400, never the unfiltered list.
4
+
5
+ import { t } from '@ultimat3/i18n';
6
+ import { Badge, Card, Link } from '@ultimat3/ui';
7
+ import type { JSX } from 'solid-js';
8
+ import styles from './admin.module.scss';
9
+ import {
10
+ ERROR_GROUP_STATUSES,
11
+ type ErrorGroupStatus,
12
+ type ErrorGroupsStore,
13
+ } from './error-groups-store';
14
+ import { AdminFilterInvalidError } from './errors';
15
+
16
+ /** `?status=` and nothing else; empty is "every status". */
17
+ export function errorsStatusOf(url: URL): ErrorGroupStatus | undefined {
18
+ for (const key of url.searchParams.keys()) {
19
+ if (key === 'status') continue;
20
+ throw new AdminFilterInvalidError({
21
+ entity: 'x_error_groups',
22
+ asked: key,
23
+ cause: 'is not a parameter of the error groups screen',
24
+ known: ['status'],
25
+ });
26
+ }
27
+ const asked = url.searchParams.get('status') ?? '';
28
+ if (asked === '') return undefined;
29
+ const status = ERROR_GROUP_STATUSES.find((known) => known === asked);
30
+ if (status !== undefined) return status;
31
+ throw new AdminFilterInvalidError({
32
+ entity: 'x_error_groups',
33
+ asked: `status=${asked}`,
34
+ cause: 'is not a triage status',
35
+ known: ERROR_GROUP_STATUSES.map((known) => `status=${known}`),
36
+ });
37
+ }
38
+
39
+ export async function ErrorsList(props: {
40
+ readonly store: ErrorGroupsStore;
41
+ readonly basePath: string;
42
+ readonly url: string;
43
+ }): Promise<JSX.Element> {
44
+ const status = errorsStatusOf(new URL(props.url));
45
+ const groups = await props.store.list(status === undefined ? {} : { status });
46
+ return (
47
+ <Card>
48
+ <nav class={styles['filters']} aria-label={t('admin.errors.filter.status')}>
49
+ <Link href={props.basePath}>{t('admin.filter.any')}</Link>{' '}
50
+ {ERROR_GROUP_STATUSES.map((one) => (
51
+ <Link href={`${props.basePath}?status=${one}`}>{t(`admin.errors.status.${one}`)}</Link>
52
+ ))}
53
+ </nav>
54
+ {groups.length === 0 ? (
55
+ <p class="x-admin-empty">{t('admin.errors.empty')}</p>
56
+ ) : (
57
+ <ol class={styles['list']}>
58
+ {groups.map((group) => (
59
+ <li data-fingerprint={group.fingerprint}>
60
+ <a href={`${props.basePath}/${encodeURIComponent(group.fingerprint)}`}>
61
+ <code>{group.code}</code>
62
+ </a>{' '}
63
+ <span>{group.message}</span>{' '}
64
+ <span class={styles['mono']}>{group.operation ?? group.source}</span>{' '}
65
+ <Badge>{t('admin.errors.count', { count: group.count })}</Badge>{' '}
66
+ <span data-status={group.status}>{t(`admin.errors.status.${group.status}`)}</span>{' '}
67
+ <code>{group.lastSeen}</code>
68
+ </li>
69
+ ))}
70
+ </ol>
71
+ )}
72
+ </Card>
73
+ );
74
+ }
package/src/errors.ts CHANGED
@@ -29,6 +29,7 @@ export const ADMIN_OWNED_ERROR_CODES = [
29
29
  'X_ADMIN_FILTER_INVALID',
30
30
  'X_ADMIN_ACTION_NOT_APPLICABLE',
31
31
  'X_ADMIN_MOUNT_MISSING',
32
+ 'X_ADMIN_VIEW_WRITE',
32
33
  ] as const;
33
34
 
34
35
  /**
@@ -62,6 +63,7 @@ export const ADMIN_ERROR_TITLES: Readonly<Record<AdminOwnedErrorCode, string>> =
62
63
  'an admin list was asked for a filter, sort or scope its resource does not derive',
63
64
  X_ADMIN_ACTION_NOT_APPLICABLE: 'an admin action was run on a row its when() rule excludes',
64
65
  X_ADMIN_MOUNT_MISSING: 'an admin batch job ran in a process that never declared its admin',
66
+ X_ADMIN_VIEW_WRITE: 'an admin resource over a view entity offers a write',
65
67
  };
66
68
 
67
69
  // One unconditional call, so a second package claiming one of admin's codes throws
@@ -174,6 +176,8 @@ export class AdminFilterInvalidError extends UltimateError {
174
176
  cause: string;
175
177
  /** What the resource DOES answer, in the caller's own spelling. */
176
178
  known: readonly string[];
179
+ /** A site whose fix is not the manifest's — the audit trail's URL and its stores. */
180
+ fix?: string;
177
181
  }) {
178
182
  const known = input.known.length > 0 ? input.known.join(', ') : 'none';
179
183
  super({
@@ -181,7 +185,9 @@ export class AdminFilterInvalidError extends UltimateError {
181
185
  cause: `${input.entity}: "${input.asked}" ${input.cause} (this list answers: ${known})`,
182
186
  // The manifest records every resource's filters, sorts and scopes, so the command prints the
183
187
  // list this error names — for the resource, and for every other one.
184
- fix: `x manifest --json # admin[].resources lists the filters, sorts and scopes of "${input.entity}"`,
188
+ fix:
189
+ input.fix ??
190
+ `x manifest --json # admin[].resources lists the filters, sorts and scopes of "${input.entity}"`,
185
191
  });
186
192
  }
187
193
  }
package/src/index.ts CHANGED
@@ -75,6 +75,8 @@ export {
75
75
  type AuditSink,
76
76
  auditCursorOf,
77
77
  auditEntry,
78
+ auditEntryMatches,
79
+ auditWindow,
78
80
  deniedDraft,
79
81
  diffRows,
80
82
  memoryAuditLog,
@@ -83,6 +85,12 @@ export {
83
85
  type PostgresAuditLogOptions,
84
86
  postgresAuditLog,
85
87
  } from './audit-pg';
88
+ export {
89
+ type AuditRequest,
90
+ auditHref,
91
+ auditRequestOf,
92
+ HISTORY_PARAM,
93
+ } from './audit-request';
86
94
  // The audit table's DDL is `@ultimat3/admin/schema`'s alone — one import path for one statement.
87
95
  export {
88
96
  type AdminActor,
@@ -155,7 +163,6 @@ export {
155
163
  export {
156
164
  AdminDetail,
157
165
  type AdminDetailProps,
158
- HISTORY_PARAM,
159
166
  } from './detail';
160
167
  export {
161
168
  type AdminColumnFacts,
@@ -163,6 +170,9 @@ export {
163
170
  adminColumnsOf,
164
171
  adminSealedColumnsOf,
165
172
  } from './entity-columns';
173
+ // The screens only. The sink and its stores are `@ultimat3/admin/error-groups`'s alone: every role
174
+ // installs the sink, and this barrel would carry every admin screen into a worker's graph.
175
+ export { ERRORS_READ, type ErrorGroupsPagesOptions, errorGroupsPages } from './error-groups-pages';
166
176
  // The /_x dashboard is NOT re-exported here — it has its own door, `@ultimat3/admin/dev`, so a
167
177
  // host that only mounts the dev panels never loads a production admin component.
168
178
  export {
@@ -380,6 +390,7 @@ export {
380
390
  type AdminScopeWhere,
381
391
  LOOKUP_SEGMENT,
382
392
  } from './resource-list';
393
+ export { AdminViewWriteError, VIEW_OPERATIONS } from './resource-operations';
383
394
  export {
384
395
  type AdminRouteConfig,
385
396
  type AdminRouteMatch,
@@ -81,22 +81,26 @@ const onCalendar = (raw: string): boolean => {
81
81
  };
82
82
 
83
83
  /**
84
- * A typed instant → the ISO string a driver compares. `datetime-local` posts `YYYY-MM-DDTHH:mm`
85
- * and the control says UTC beside the box — the same contract the edit form states — and a bare
86
- * date is that day's first instant in UTC.
84
+ * An instant → the ISO string a store compares. It names its zone — an offset or `Z` — or it is a
85
+ * bare `YYYY-MM-DD`, that day's first instant in UTC. A zoneless time is refused: no store invents
86
+ * a zone. What the audit stores and the audit URL read.
87
87
  */
88
- const instantOf = (raw: string): string | undefined => {
88
+ export const instantOf = (raw: string): string | undefined => {
89
89
  if (!onCalendar(raw)) return undefined;
90
- const text = DATE.test(raw)
91
- ? `${raw}T00:00:00.000Z`
92
- : LOCAL_MINUTE.test(raw)
93
- ? `${raw}:00.000Z`
94
- : raw;
90
+ const text = DATE.test(raw) ? `${raw}T00:00:00.000Z` : raw;
95
91
  if (!ZONED.test(text)) return undefined;
96
92
  const at = new Date(text);
97
93
  return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
98
94
  };
99
95
 
96
+ /**
97
+ * A `datetime-local` control's value as the UTC its label states: `YYYY-MM-DDTHH:mm` gets its `Z`
98
+ * HERE, the one place a form's minute is given a zone, and everything else is `instantOf`'s. A
99
+ * list filter's range control and the audit trail's form post exactly that shape.
100
+ */
101
+ export const utcFormInstantOf = (raw: string): string | undefined =>
102
+ instantOf(LOCAL_MINUTE.test(raw) ? `${raw}Z` : raw);
103
+
100
104
  interface Refusal {
101
105
  readonly cause: string;
102
106
  }
@@ -127,7 +131,7 @@ function scalarOf(field: AdminField, raw: unknown): Scalar | Refusal {
127
131
  : { cause: `takes a YYYY-MM-DD date, got ${shown(raw)}` };
128
132
  }
129
133
  return (
130
- instantOf(raw) ?? {
134
+ utcFormInstantOf(raw) ?? {
131
135
  cause: `takes a UTC instant (YYYY-MM-DDTHH:mm, or ISO-8601 with an offset), got ${shown(raw)}`,
132
136
  }
133
137
  );
package/src/mcp-tools.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // Tool descriptions are literal English on purpose: they are protocol payload read by a
6
6
  // model, not UI copy read by a person, so they are not `t()` keys.
7
7
 
8
+ import { toolDescription } from '@ultimat3/core';
8
9
  import { adminPermissionForAction, decideAction, permissionsForAction } from './action-gate';
9
10
  import type { AdminApp } from './admin';
10
11
  import { type AdminDecision, decideAll } from './authz';
@@ -73,7 +74,7 @@ const actionEnvelope = (action: AdminAction): readonly AdminToolField[] => [
73
74
  /** What an agent reads about when and over what an action runs — the schema cannot say it. */
74
75
  const actionDescription = (action: AdminAction): string =>
75
76
  [
76
- action.mcp?.description ?? `Run the ${action.name} action.`,
77
+ toolDescription(action),
77
78
  action.when === undefined
78
79
  ? ''
79
80
  : ' Applies only to some rows: a row it does not apply to is refused with X_ADMIN_ACTION_NOT_APPLICABLE.',
package/src/registry.ts CHANGED
@@ -81,6 +81,11 @@ export interface AdminEntity {
81
81
  * renders it as an input — the acting actor's tenant is the only value it may hold.
82
82
  */
83
83
  readonly $tenantColumn?: string | null;
84
+ /**
85
+ * `entity({ view })`'s query, `null` on a table. Optional in this surface only because a
86
+ * hand-built fixture has none; a view offers reads alone (`resource-operations.ts`).
87
+ */
88
+ readonly $viewQuery?: { readonly materialized: boolean } | null;
84
89
  /** Resolves foreign-key targets. See `entity-columns.ts` for what the admin takes from it. */
85
90
  $describe(): AdminEntityDescription;
86
91
  }
@@ -0,0 +1,36 @@
1
+ // Single responsibility: which operations an admin resource offers. Every one, by default, on a
2
+ // table; only the reads on a view entity (`entity({ view })`), whose rows Postgres derives — so a
3
+ // view gets no create/edit/delete route, button or MCP tool, and an override asking for one is
4
+ // refused where it was declared.
5
+
6
+ import { renderFixShellArg, UltimateError } from '@ultimat3/core';
7
+ import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
8
+ import type { AdminEntity } from './registry';
9
+
10
+ /** What a view offers. `search` reads, as `list` does. */
11
+ export const VIEW_OPERATIONS: readonly AdminOperation[] = ['list', 'detail', 'search'];
12
+
13
+ const READS: ReadonlySet<AdminOperation> = new Set(VIEW_OPERATIONS);
14
+
15
+ /** An admin resource over a view entity was declared with a write it can never perform. */
16
+ export class AdminViewWriteError extends UltimateError {
17
+ constructor(input: { readonly entity: string; readonly writes: readonly AdminOperation[] }) {
18
+ super({
19
+ code: 'X_ADMIN_VIEW_WRITE',
20
+ cause: `${input.entity} is a view (entity({ view })), whose rows Postgres derives, and its admin resource offers ${input.writes.join(', ')} — every one would be refused with X_ENTITY_VIEW_READ_ONLY`,
21
+ fix: `x entities show ${renderFixShellArg(input.entity, '<entity>')} --json # then drop operations from its admin resource (a view lists read-only), or name only ${VIEW_OPERATIONS.join(', ')}`,
22
+ });
23
+ }
24
+ }
25
+
26
+ export function operationsFor(
27
+ entity: AdminEntity,
28
+ declared: readonly AdminOperation[] | undefined,
29
+ ): readonly AdminOperation[] {
30
+ const isView = entity.$viewQuery !== undefined && entity.$viewQuery !== null;
31
+ if (!isView) return declared ?? ADMIN_OPERATIONS;
32
+ if (declared === undefined) return VIEW_OPERATIONS;
33
+ const writes = declared.filter((operation) => !READS.has(operation));
34
+ if (writes.length > 0) throw new AdminViewWriteError({ entity: entity.$name, writes });
35
+ return declared;
36
+ }
package/src/resource.ts CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  AdminRepoUnboundError,
13
13
  } from './errors';
14
14
  import type { AdminField } from './fields';
15
- import { ADMIN_OPERATIONS, type AdminOperation } from './permissions';
15
+ import type { AdminOperation } from './permissions';
16
16
  import type { AdminAction, AdminEntity, AdminRepo, AdminRow, AdminSort } from './registry';
17
17
  import { type AdminFieldOverride, deriveField, secretField } from './resource-fields';
18
18
  import {
@@ -31,6 +31,7 @@ import {
31
31
  rowScopeOf,
32
32
  scopesOf,
33
33
  } from './resource-list';
34
+ import { operationsFor } from './resource-operations';
34
35
 
35
36
  /** Six columns is what fits a laptop viewport without horizontal scroll. */
36
37
  const MAX_LIST_FIELDS = 6;
@@ -348,7 +349,7 @@ export function adminResource<Row extends AdminRow = AdminRow>(
348
349
  // `fetched.length > NaN` is false, the page is never trimmed, `hasMore` is false and the repo
349
350
  // is asked for `limit: NaN`. At least 1 — a page with no rows on it is not a page.
350
351
  pageSize: finiteCount('adminResource', 'pageSize', opts.pageSize ?? DEFAULT_PAGE_SIZE, 1),
351
- operations: opts.operations ?? ADMIN_OPERATIONS,
352
+ operations: operationsFor(entity, opts.operations),
352
353
  actions,
353
354
  ...(opts.repo === undefined ? {} : { repo: opts.repo }),
354
355
  field(name: string): AdminField {
@@ -16,6 +16,7 @@ import {
16
16
  import type { AdminApp, AdminRoute } from './admin';
17
17
  import styles from './admin.module.scss';
18
18
  import { auditCursorOf } from './audit';
19
+ import { HISTORY_PARAM, historyCursorOf, historyCursorText } from './audit-request';
19
20
  import {
20
21
  adminCreate,
21
22
  adminDestroy,
@@ -25,7 +26,7 @@ import {
25
26
  canOperate,
26
27
  decideOperation,
27
28
  } from './crud';
28
- import { AdminDetail, HISTORY_PARAM, historyCursorOf, historyCursorText } from './detail';
29
+ import { AdminDetail } from './detail';
29
30
  import { AdminFilterInvalidError } from './errors';
30
31
  import { AdminForm } from './form';
31
32
  import { decodeForm, posted } from './form-decode';