@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.
- package/CLAUDE.md +22 -23
- package/README.md +12 -8
- package/package.json +16 -16
- package/src/action-gate.ts +15 -41
- package/src/batch-matching.ts +26 -31
- package/src/batch-queue.ts +90 -0
- package/src/batch.ts +8 -43
- package/src/crud-outcome.ts +96 -38
- package/src/crud.ts +48 -4
- package/src/dev/data.ts +3 -6
- package/src/dev/facts.ts +1 -1
- package/src/dev/index.ts +1 -0
- package/src/dev/panel-cache.ts +8 -3
- package/src/dev/panel-timeline.ts +7 -2
- package/src/dev/panel.ts +4 -2
- package/src/dev/server.ts +1 -4
- package/src/dev/sql-runner.ts +31 -0
- package/src/errors.ts +9 -5
- package/src/form-decode.ts +42 -1
- package/src/form.tsx +19 -5
- package/src/index.ts +3 -2
- package/src/jobs/fleet-repo.ts +15 -3
- package/src/jobs/job-repo.ts +51 -17
- package/src/jobs/operator.ts +3 -3
- package/src/keyset-seek.ts +189 -0
- package/src/list-filters.ts +30 -5
- package/src/list-request.ts +8 -6
- package/src/mcp.ts +4 -0
- package/src/pagination.ts +10 -4
- package/src/policy-bridge.ts +10 -8
- package/src/registry.ts +2 -1
- package/src/relations.ts +25 -9
- package/src/repo-entity.ts +37 -46
- package/src/resource-list.ts +9 -0
- package/src/row-version.ts +20 -0
- package/src/screen-resource.tsx +56 -8
- package/src/screen-system.tsx +8 -2
- package/src/search.ts +10 -2
- package/src/widgets.tsx +2 -6
package/src/crud-outcome.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
52
|
-
*
|
|
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.
|
|
64
|
-
* the
|
|
65
|
-
*
|
|
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
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
94
|
+
return await audit.atomic(async () => {
|
|
92
95
|
const value = await write();
|
|
93
|
-
const
|
|
96
|
+
const appended = await audit.append({
|
|
94
97
|
...entry,
|
|
95
98
|
...written(value),
|
|
96
99
|
outcome: 'allowed',
|
|
97
|
-
reason:
|
|
100
|
+
reason: reasons.allowed,
|
|
98
101
|
});
|
|
99
|
-
return { value, audit };
|
|
102
|
+
return { value, audit: appended };
|
|
100
103
|
});
|
|
101
104
|
} catch (error) {
|
|
102
|
-
await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
|
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
package/src/dev/panel-cache.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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`
|
|
36
|
-
* neither titles nor registers
|
|
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
|
|
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
|
package/src/form-decode.ts
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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,
|