@ultimat3/admin 1.2.0 → 2.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 +52 -0
- package/README.md +117 -3
- package/package.json +17 -14
- package/src/admin.ts +35 -6
- package/src/crud.ts +33 -4
- package/src/detail.tsx +4 -1
- package/src/dev/data.ts +69 -7
- package/src/dev/facts.ts +48 -1
- package/src/dev/index.ts +3 -0
- package/src/dev/panel-cache.ts +2 -2
- package/src/dev/panel-db.ts +67 -12
- package/src/dev/panel-jobs.ts +15 -4
- package/src/dev/panel-live.ts +20 -5
- package/src/dev/panel-mail.ts +2 -2
- package/src/dev/panel-manifest.ts +2 -2
- package/src/dev/panel-policy.ts +2 -2
- package/src/dev/panel-routes.ts +2 -2
- package/src/dev/panel-timeline.ts +26 -5
- package/src/dev/panel.ts +7 -3
- package/src/dev/server.ts +21 -10
- package/src/errors.ts +45 -2
- package/src/index.ts +17 -1
- package/src/mcp-tools.ts +25 -2
- package/src/mcp.ts +16 -11
- package/src/nav.ts +10 -0
- package/src/page-guard.tsx +58 -0
- package/src/pages.ts +117 -0
- package/src/pagination.ts +29 -5
- package/src/registry.ts +6 -0
- package/src/routes.ts +64 -6
- package/src/search.ts +29 -18
- package/src/widget-value.ts +19 -3
- package/src/widgets.tsx +11 -9
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# @ultimat3/admin — boundary
|
|
2
|
+
|
|
3
|
+
Tier 5. May import tiers 0–4. Exactly one importer: `@ultimat3/cli`, and only of `@ultimat3/admin/dev` — `cli → admin` is a declared sideways edge (`scripts/lib/tiers.ts`) so `x dev` can **mount** `/_x`. The root barrel `@ultimat3/admin` has no importer.
|
|
4
|
+
|
|
5
|
+
Two products, one package, **two entry points**: `@ultimat3/admin/dev` (`src/dev/index.ts`) = the `/_x` dev dashboard, `@ultimat3/admin` (`src/index.ts`) = the generated app admin (production, authz'd, AI-first). Keep them apart; nothing in `src/dev/` may be imported by an admin view, and the root barrel does not re-export the dev half — a host that mounts `/_x` (the CLI) must not load a Solid component to do it.
|
|
6
|
+
|
|
7
|
+
## Rules
|
|
8
|
+
|
|
9
|
+
- **One authz.** `AdminAuthz` (`authz.ts`) is the only decision path. `action-gate.ts`, `crud.ts` and `page-guard.tsx` call `decideAll`; views render what the gate returned. Never add a second check in a view or an MCP handler.
|
|
10
|
+
- **A custom page's guard is composed, never written.** `pages.ts` turns a `pages:` entry into an `AdminRoute` carrying `[admin:read, …declared]`; `routes.ts` is the only thing that may hand a page component to a router and it hands the `guardedPage()` wrapper, with `permissions[0]` already in `defineRoute({ policy })`. `AdminPageProps.ctx` is required by the type so the wrapper cannot be bypassed by calling the component directly. An empty permission list is `X_ADMIN_PAGE_UNGUARDED` at `defineAdmin` time — the one place an unauthenticated admin screen could have been born.
|
|
11
|
+
- **A host that serves an admin URL itself reads its gate, never restates it.** `adminRouteFor(app, path)` (`routes.ts`) is that read, and `AdminRouteConfig.policy` is what it hands over — the same object `config.policy` carries, non-optional so a caller need not prove it exists. Typing `policy: { permission: 'admin:read' }` into a page file beside a route table that already declares one for that URL is two declarations of one URL's authz; they agree until somebody edits one. The deployed demo shipped exactly that on five pages until 1.2.0, and its `apps/admin/app/admin/route-policy.test.ts` is the rule made executable: a quoted permission anywhere under `app/admin/**/page.tsx` fails the suite. An undeclared path is `X_ADMIN_PAGE_PATH_INVALID`, listing the paths that would have worked.
|
|
12
|
+
- **One bridge per foreign package.** `policy-bridge.ts` is the only file calling `evaluate`/`definePermissions`; `routes.ts` the only one calling `defineRoute`; `mcp.ts` the only one calling `defineAppMcp`; `dev/data.ts` the only one importing introspection (dynamically — `/_x` must stay out of the production graph).
|
|
13
|
+
- **One entity surface** (`registry.ts`) — the admin reads what `entity()` actually exposes: `$name`, `$primaryKey`, `$columns[c].$meta`, `$schema`, `$describe()`. It is a structural subset so a new column kind still derives, and `RegisteredEntity` is the `tsc`-checked proof that a real `entity()` result satisfies it — never a comment claiming it does.
|
|
14
|
+
- **One flattener.** `entity-columns.ts` is the only file that reads `$meta` or calls `$describe()`; everything downstream takes `AdminColumnFacts`. Money stays one property (the admin renders rows, not tables), a FK target comes back resolved from `$describe()`, and only a **generated** default (`uuid`, `now`) is read-only — `.default('free')` is a starting value.
|
|
15
|
+
- **A deliberate query loop declares itself.** `search.ts` runs one indexed lookup per text field on purpose — the query IR is a conjunction, so three small indexed reads beat one unindexed `OR` — and says so through `expectedQueryLoop()` from `@ultimat3/db` (tier 1, downward, and the only thing this package imports from it). That is the one suppression mechanism: never a comment pragma and never a list of exempt call sites. A new loop here either argues for itself in a `reason` or it is an N+1.
|
|
16
|
+
- **The timeline panel renders the detector's verdicts, it does not re-derive them.** `repeatedSql` (`dev/panel-timeline.ts`) is a measurement over the trace this panel recorded — every SQL text seen twice. `nPlusOne` is the verdict: `x dev`'s statement ledger, read through `DevSources.statementLoops()` and scoped to the selected request, in the ledger's own order. A second count here would be blind to the `expectedQueryLoop` above and to statement attribution (`members.findById`, not raw SQL text), and would disagree with the `fix:` an author actually pastes. No detector wired → `null`, never `[]`: "nobody counted" is not "this request was clean".
|
|
17
|
+
- **This catalog is the framework's one opt-OUT MCP surface, and that is deliberate.**
|
|
18
|
+
`mcp-tools.ts` is the only file that does not call `isMcpExposed` from `@ultimat3/core`: every
|
|
19
|
+
tool here is already gated on an admin permission, and the CRUD tools carry no `mcp` block at
|
|
20
|
+
all, so opting in would list `admin.posts.delete` while hiding the action button beside it.
|
|
21
|
+
`mcp: { expose: false }` withdraws one. Stated there, in core's `mcp-exposure.ts`, and in
|
|
22
|
+
`wiki/Admin-Dashboard.md` — a *second* exception anywhere is the bug.
|
|
23
|
+
- **`dev/panel-db.ts`'s `sanitize` decides "did they type a statement", not "is it safe".** It blanks every opaque span in ONE left-to-right pass — `'…'`, `"…"`, `$tag$…$tag$` and both comment forms in a single alternation, because each form can contain another's opener — so `-- still typing` reads as an empty box. It used to feed a write-keyword scan in this file and that scan is gone; **do not cite it as a security property**. It once said an unterminated quote "leaves the rest visible — the guard fails closed": true of the scan it fed, meaningless now, and it never covered `$tag$` or slash-star, whose surviving character is `$` or `/`.
|
|
24
|
+
- **A dev panel catches only the error that means "not wired".** `DevSourceUnavailableError` and nothing wider: a bare `catch` in `dev/panel-live.ts` reported an authz refusal and a dropped NATS connection from a *running* sync node as `dev.live.no-sync-node`, telling the reader to install a tier they already had. Everything else reaches `panelPayload`, which renders its code and its fix.
|
|
25
|
+
- **What an entity does not declare, the admin does not invent.** `sensitive`, a fixed `currency` and `labelField` come from `AdminResourceOptions` or they are absent. Same rule for a URL: the reference widget links through `WidgetContext.hrefFor` or renders plain text — it used to build `/${entity}s/${id}`, which is English pluralisation by concatenation and drops `basePath`. The route table is `AdminApp`'s; a widget three layers down does not get to guess it.
|
|
26
|
+
- **One read-only SQL guard, and it is `@ultimat3/mcp`'s — the whole verdict, with nothing held back.** `dev/panel-db.ts` calls `assertReadOnlyQuery` (tier 5 → tier 4, already a dependency) rather than keeping a second keyword scan: that guard also refuses a batch, a call into the `pg_read_*`/`pg_advisory_*`/`pg_sleep`/`set_config` families, `FOR UPDATE`, and a delimiter that never closes in all five forms (`'`, `E'`, `"`, `$tag$`, slash-star). A local unterminated-delimiter refusal lived here for one revision and was **deleted**: it tested for a surviving `'`/`"`, so it covered three of the five and called a dollar-quoted body "a quote" — one failure mode, two explanations, and a second detector for a property the guard below already tests. What stays local is the emptiness test and the **way out**: `@ultimat3/mcp` tells its caller to expose an action, which a developer at `/_x` cannot act on. The panel says "Fix the statement, or — if it is meant to write — run it with `x db psql --write`", conditional on purpose: `--write` grants writes, it does not close a delimiter, and it used to be printed as *the* fix for a syntax error.
|
|
27
|
+
- **Every admin operation is audited, reads included.** `adminList` was the one call that logged nothing in either direction; `ListResult` now carries its `AuditEntry` on both branches, keyed on the table (`entityId: null`), because the subject of a listing is not a row.
|
|
28
|
+
- Money through `assertMoney`, timestamps through `assertZone`, pagination through `pagination.ts`. No `offset`, ever.
|
|
29
|
+
- **One cursor codec.** `pagination.ts` is the only file calling core's `encodeCursor`/`decodeCursor`; it wraps them as `encodeAdminCursor`/`decodeAdminCursor`, scoped `admin:<resource>`. An invalid cursor is page one here, not an error page — but the signature is checked first, so a forged one cannot seek.
|
|
30
|
+
- Labels are i18n keys derived in `resource.ts` (`admin.<entity>.field.<name>`); only `.tsx` calls `t()`. MCP tool descriptions are literal English (protocol payload, not UI copy).
|
|
31
|
+
- Colours only via `ThemeTokenRef` (`--x-*`). Raw hex does not typecheck.
|
|
32
|
+
- Views are pure functions of props — no `createSignal`, no local state; the route owns it.
|
|
33
|
+
|
|
34
|
+
## Layout
|
|
35
|
+
|
|
36
|
+
| File | Owns |
|
|
37
|
+
|---|---|
|
|
38
|
+
| `admin.ts` | `defineAdmin` → resources, nav, route table, audit, authz |
|
|
39
|
+
| `pages.ts` / `page-guard.tsx` | a `pages:` entry → route + nav item (pure) / the wrapper that decides before it renders |
|
|
40
|
+
| `routes.ts` | the only `defineRoute` caller: mode per view, `policy` composed from the route's permissions |
|
|
41
|
+
| `registry.ts` / `entity-columns.ts` | the entity surface the admin reads / one entity → column facts |
|
|
42
|
+
| `resource.ts` / `fields.ts` / `widget-value.ts` | derivation, widget table, value guards |
|
|
43
|
+
| `crud.ts` / `action-gate.ts` | policy → confirmation → validation → repo → audit |
|
|
44
|
+
| `mcp-tools.ts` / `mcp.ts` | tool derivation (pure) / transport wiring |
|
|
45
|
+
| `dev/server.ts` + `dev/panel-*.ts` | `/_x` mount guard and one panel per file |
|
|
46
|
+
|
|
47
|
+
## Commands
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
bun test --filter @ultimat3/admin
|
|
51
|
+
bun run --filter @ultimat3/admin typecheck
|
|
52
|
+
```
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ import { devDashboard, defaultDevSources } from '@ultimat3/admin/dev';
|
|
|
32
32
|
|
|
33
33
|
const dev = devDashboard({ sources: defaultDevSources({ authz, actors }) }); // throws in prod
|
|
34
34
|
const response = await dev.handle(request); // null when the path is not /_x
|
|
35
|
-
await dev.json('jobs'); // the same payload
|
|
35
|
+
await dev.json('jobs'); // the same payload /_x/jobs renders from
|
|
36
36
|
```
|
|
37
37
|
|
|
38
38
|
The root barrel does not re-export any of this: `x dev` mounts `/_x` without pulling a Solid
|
|
@@ -58,9 +58,123 @@ export const admin = defineAdmin({
|
|
|
58
58
|
audit: memoryAuditLog({ sinks: [auditTable] }),
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
export const routes = adminRoutes(admin); //
|
|
61
|
+
export const routes = adminRoutes(admin); // generated views `spa`, custom pages `ssr`, all gated
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
+
### The views are TSX, and the pieces come apart
|
|
65
|
+
|
|
66
|
+
There is no view DSL here, and there will not be one. Every renderer is an ordinary SolidJS
|
|
67
|
+
component in an ordinary `.tsx` file importing `@ultimat3/ui` — `AdminList`, `AdminForm`,
|
|
68
|
+
`AdminDetail`, `Widget`, `AdminLayout` are each exported on their own, so a table can be lifted
|
|
69
|
+
out of its page and dropped into a screen the generator never wrote:
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { AdminList, AdminForm, Widget } from '@ultimat3/admin';
|
|
73
|
+
|
|
74
|
+
<AdminList resource={admin.resource('posts')} page={page} ctx={ctx} … />; // just the table
|
|
75
|
+
<Widget field={field} value={row.total} ctx={ctx} mode="read" />; // just one cell
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`Widget` takes the field and the raw row value, not pre-derived props: `widgetProps()` is the
|
|
79
|
+
guard (money is minor units, a timestamp has an IANA zone) and the component calls it, so there is
|
|
80
|
+
no way to render a cell that skipped it. `mode` is `read` or `edit`; an edit cell also takes the
|
|
81
|
+
`control` the surrounding `<Field>` hands its child, and `onInput`.
|
|
82
|
+
|
|
83
|
+
An admin route is an ordinary `route` primitive; an admin action is an ordinary `action`. Nothing
|
|
84
|
+
in this package is written in a second language that only the dashboard understands — the escape
|
|
85
|
+
hatch is the same TSX as the main path, which is why there is no cliff to fall off when a screen
|
|
86
|
+
stops being CRUD.
|
|
87
|
+
|
|
88
|
+
### Custom pages
|
|
89
|
+
|
|
90
|
+
The bespoke ops screen is the common case, not the corner: a reconciliation fixer, a proxy health
|
|
91
|
+
board, a deploy button. Declare it in `pages:` and it becomes a real admin route.
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// admin/ops/page.tsx — a component, nothing framework-shaped about it
|
|
95
|
+
export function OpsPage(props: AdminPageProps) {
|
|
96
|
+
return <OpsBoard counts={await mediaStateCounts()} />;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// admin/index.ts
|
|
100
|
+
defineAdmin({
|
|
101
|
+
entities: [posts],
|
|
102
|
+
pages: [
|
|
103
|
+
{
|
|
104
|
+
path: '/ops', // rooted at basePath → /admin/ops
|
|
105
|
+
titleKey: 'admin.ops.title',
|
|
106
|
+
navGroup: 'admin.group.operations', // omit to keep it out of the nav
|
|
107
|
+
permissions: ['ops:read'], // `admin:read` is composed in front of it
|
|
108
|
+
component: OpsPage,
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
auth,
|
|
112
|
+
});
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| What you get | How |
|
|
116
|
+
|---|---|
|
|
117
|
+
| a row in `app.routes`, `adminRoutes()`, `x manifest` | `pageRoutes()` folds it in beside the generated screens |
|
|
118
|
+
| a nav item that disappears for an actor who cannot open it | `NavItem.permissions` → `visibleNav` |
|
|
119
|
+
| a `defineRoute({ policy })` you never wrote and cannot omit | `adminRouteConfig` composes `permissions[0]` into it |
|
|
120
|
+
| a per-request refusal, audited, before your component runs | `guardedPage()` wraps it in `decideAll` |
|
|
121
|
+
|
|
122
|
+
**The guard is not yours to remember.** `adminRoutes()` hands the router the *wrapped* component,
|
|
123
|
+
never the one you wrote, and `AdminPageProps.ctx` is required by the type — a page component
|
|
124
|
+
cannot be called without the handle the guard decides on. `permissions: []` throws
|
|
125
|
+
`X_ADMIN_PAGE_UNGUARDED` where it is written, not on the first unauthenticated request. A path
|
|
126
|
+
that shadows a generated screen throws `X_ADMIN_PAGE_PATH_INVALID` the same way.
|
|
127
|
+
|
|
128
|
+
Custom pages are `render: 'ssr'`, `hydrate: 'never'`: the guard runs on the server, so there is no
|
|
129
|
+
shell to ship and nothing to decide twice.
|
|
130
|
+
|
|
131
|
+
### Serving an admin URL from your own route file
|
|
132
|
+
|
|
133
|
+
A host whose router is file-based writes its own `page.tsx` for an admin URL. Take the gate from
|
|
134
|
+
the route table — never type it a second time:
|
|
135
|
+
|
|
136
|
+
```ts
|
|
137
|
+
import { adminRouteFor } from '@ultimat3/admin';
|
|
138
|
+
import { admin } from './admin';
|
|
139
|
+
|
|
140
|
+
const route = adminRouteFor(admin, `${admin.basePath}/users`);
|
|
141
|
+
|
|
142
|
+
export const config = defineRoute({
|
|
143
|
+
render: 'ssr',
|
|
144
|
+
hydrate: 'never',
|
|
145
|
+
offline: 'network-only',
|
|
146
|
+
policy: route.policy, // `defineAdmin()`'s, from `permissionsForOperation('users', 'list')`
|
|
147
|
+
meta: () => ({ title: t('admin.users.title') }),
|
|
148
|
+
});
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`route.policy` is the same object `route.config.policy` carries, so the gate has one declaration
|
|
152
|
+
and one reader. A `path` the admin does not declare throws `X_ADMIN_PAGE_PATH_INVALID` listing the
|
|
153
|
+
paths that would have worked — a page serving an admin URL the admin never built is a screen whose
|
|
154
|
+
permissions nothing composed.
|
|
155
|
+
|
|
156
|
+
### Splitting the admin across files
|
|
157
|
+
|
|
158
|
+
`defineAdmin` takes **plain values** — `entities`, `resources`, `actions`, `jobs`, `pages`, `nav`,
|
|
159
|
+
`branding`, `auth`, `audit`. Every one of them can be imported from its own module, so the cut is
|
|
160
|
+
along the input's own keys and there is exactly one layout:
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
app/admin/
|
|
164
|
+
index.ts defineAdmin({ … }) — composition only, no logic
|
|
165
|
+
auth.ts actor() + policyAuthz({ policies })
|
|
166
|
+
nav.ts groups, order, extras
|
|
167
|
+
<resource>/
|
|
168
|
+
resource.ts the AdminResourceOptions entry (listFields, sensitive, labelField)
|
|
169
|
+
repo.ts the AdminRepo binding
|
|
170
|
+
actions.ts the AdminAction[] for this entity
|
|
171
|
+
pages/<name>.tsx a custom page component for this entity, if any
|
|
172
|
+
pages/<name>.tsx an app-wide custom page (ops, reconciliation, deploy)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
`index.ts` imports each and composes. Nothing here is a framework rule — it is what the input
|
|
176
|
+
shape already is, written down once so two apps do not invent two layouts. `x g admin` emits it.
|
|
177
|
+
|
|
64
178
|
### Derived from the entity, and only from it
|
|
65
179
|
|
|
66
180
|
| Admin decision | Read from |
|
|
@@ -112,4 +226,4 @@ Panes are off until enabled, and `runAiPane` refuses (never no-ops) without a ru
|
|
|
112
226
|
|
|
113
227
|
## Errors
|
|
114
228
|
|
|
115
|
-
`X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
|
|
229
|
+
`X_ADMIN_ENTITY_UNKNOWN` · `X_ADMIN_FIELD_UNSUPPORTED` · `X_ADMIN_POLICY_MISSING` · `X_ADMIN_PAGE_UNGUARDED` · `X_ADMIN_PAGE_PATH_INVALID` · `X_ADMIN_DENIED` · `X_ADMIN_TOOL_FORBIDDEN` · `X_ADMIN_INVALID` · `X_DEV_DASHBOARD_IN_PROD` · `X_NOT_IMPLEMENTED` (an unwired `/_x` source, carrying the wiring line).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/admin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Two dashboards: the /_x framework dev panels and the generated, AI-first app admin",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"files": [
|
|
21
21
|
"src",
|
|
22
22
|
"!src/**/*.test.ts",
|
|
23
|
+
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
25
26
|
],
|
|
@@ -31,18 +32,20 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "
|
|
35
|
-
"@ultimat3/ai": "
|
|
36
|
-
"@ultimat3/cache": "
|
|
37
|
-
"@ultimat3/core": "
|
|
38
|
-
"@ultimat3/
|
|
39
|
-
"@ultimat3/
|
|
40
|
-
"@ultimat3/
|
|
41
|
-
"@ultimat3/
|
|
42
|
-
"@ultimat3/
|
|
43
|
-
"@ultimat3/
|
|
44
|
-
"@ultimat3/
|
|
45
|
-
"@ultimat3/
|
|
46
|
-
"@ultimat3/
|
|
35
|
+
"@ultimat3/action": "2.0.0",
|
|
36
|
+
"@ultimat3/ai": "2.0.0",
|
|
37
|
+
"@ultimat3/cache": "2.0.0",
|
|
38
|
+
"@ultimat3/core": "2.0.0",
|
|
39
|
+
"@ultimat3/db": "2.0.0",
|
|
40
|
+
"@ultimat3/entity": "2.0.0",
|
|
41
|
+
"@ultimat3/i18n": "2.0.0",
|
|
42
|
+
"@ultimat3/jobs": "2.0.0",
|
|
43
|
+
"@ultimat3/mcp": "2.0.0",
|
|
44
|
+
"@ultimat3/money": "2.0.0",
|
|
45
|
+
"@ultimat3/policy": "2.0.0",
|
|
46
|
+
"@ultimat3/query": "2.0.0",
|
|
47
|
+
"@ultimat3/render": "2.0.0",
|
|
48
|
+
"@ultimat3/schema": "2.0.0",
|
|
49
|
+
"@ultimat3/ui": "2.0.0"
|
|
47
50
|
}
|
|
48
51
|
}
|
package/src/admin.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { type AuditLog, memoryAuditLog } from './audit';
|
|
|
6
6
|
import type { AdminActor, AdminAuthz } from './authz';
|
|
7
7
|
import type { CrudCtx } from './crud';
|
|
8
8
|
import { permissionsForOperation } from './crud';
|
|
9
|
-
import { adminNav, type NavGroup, type NavOptions, visibleNav } from './nav';
|
|
9
|
+
import { adminNav, type NavGroup, type NavItem, type NavOptions, visibleNav } from './nav';
|
|
10
|
+
import { type AdminCustomPage, type AdminPageComponent, pageNavItems, pageRoutes } from './pages';
|
|
10
11
|
import type { AdminOperation } from './permissions';
|
|
11
12
|
import type { AdminAction, AdminEntity, AdminJobSummary, AdminRow } from './registry';
|
|
12
13
|
import {
|
|
@@ -31,7 +32,9 @@ export type AdminView =
|
|
|
31
32
|
| 'search'
|
|
32
33
|
| 'jobs'
|
|
33
34
|
| 'audit'
|
|
34
|
-
| 'dashboard'
|
|
35
|
+
| 'dashboard'
|
|
36
|
+
/** A `pages:` entry: the app's own component, guarded by the frame. */
|
|
37
|
+
| 'page';
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* A route as data. `routes.ts` turns these into `defineRoute()` configs; keeping the table
|
|
@@ -43,6 +46,11 @@ export interface AdminRoute {
|
|
|
43
46
|
readonly entity: string | null;
|
|
44
47
|
readonly titleKey: string;
|
|
45
48
|
readonly permissions: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* Set only on `view: 'page'`. It is the AUTHOR's component, still unguarded — `routes.ts` is
|
|
51
|
+
* the only thing that may hand it out, and it hands out the wrapped one.
|
|
52
|
+
*/
|
|
53
|
+
readonly component?: AdminPageComponent;
|
|
46
54
|
}
|
|
47
55
|
|
|
48
56
|
export interface DefineAdminInput {
|
|
@@ -52,6 +60,8 @@ export interface DefineAdminInput {
|
|
|
52
60
|
/** Registered actions. Each is attached to `action.entity`, or the global toolbar. */
|
|
53
61
|
readonly actions?: readonly AdminAction[];
|
|
54
62
|
readonly jobs?: readonly AdminJobSummary[];
|
|
63
|
+
/** Screens the generator would never write. Declared here so they are not a second surface. */
|
|
64
|
+
readonly pages?: readonly AdminCustomPage[];
|
|
55
65
|
readonly nav?: NavOptions;
|
|
56
66
|
readonly branding?: Partial<AdminBranding>;
|
|
57
67
|
readonly auth: AdminAuth;
|
|
@@ -80,7 +90,7 @@ export interface AdminApp {
|
|
|
80
90
|
}
|
|
81
91
|
|
|
82
92
|
const VIEW_OPERATION: Readonly<
|
|
83
|
-
Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard'>, AdminOperation>
|
|
93
|
+
Record<Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>, AdminOperation>
|
|
84
94
|
> = {
|
|
85
95
|
list: 'list',
|
|
86
96
|
detail: 'detail',
|
|
@@ -92,7 +102,10 @@ const VIEW_OPERATION: Readonly<
|
|
|
92
102
|
function resourceRoutes(basePath: string, resource: AdminResource): readonly AdminRoute[] {
|
|
93
103
|
const base = `${basePath}${resource.path}`;
|
|
94
104
|
const routes: AdminRoute[] = [];
|
|
95
|
-
const add = (
|
|
105
|
+
const add = (
|
|
106
|
+
view: Exclude<AdminView, 'jobs' | 'audit' | 'dashboard' | 'page'>,
|
|
107
|
+
path: string,
|
|
108
|
+
): void => {
|
|
96
109
|
const op = VIEW_OPERATION[view];
|
|
97
110
|
if (!resource.operations.includes(op)) return;
|
|
98
111
|
routes.push({
|
|
@@ -128,8 +141,14 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
128
141
|
});
|
|
129
142
|
});
|
|
130
143
|
|
|
131
|
-
const
|
|
132
|
-
const
|
|
144
|
+
const pages = input.pages ?? [];
|
|
145
|
+
const navOptions = input.nav ?? {};
|
|
146
|
+
const extra: readonly (NavItem & { readonly group: string })[] = [
|
|
147
|
+
...(navOptions.extra ?? []),
|
|
148
|
+
...pageNavItems(pages),
|
|
149
|
+
];
|
|
150
|
+
const nav = adminNav(resources, { ...navOptions, extra });
|
|
151
|
+
const generated: AdminRoute[] = [
|
|
133
152
|
{
|
|
134
153
|
path: basePath,
|
|
135
154
|
view: 'dashboard',
|
|
@@ -161,6 +180,16 @@ export function defineAdmin(input: DefineAdminInput): AdminApp {
|
|
|
161
180
|
...resources.flatMap((resource) => resourceRoutes(basePath, resource)),
|
|
162
181
|
];
|
|
163
182
|
|
|
183
|
+
// Generated first, so a page that would shadow one is refused rather than deciding a race.
|
|
184
|
+
const routes: readonly AdminRoute[] = [
|
|
185
|
+
...generated,
|
|
186
|
+
...pageRoutes(
|
|
187
|
+
basePath,
|
|
188
|
+
pages,
|
|
189
|
+
generated.map((route) => route.path),
|
|
190
|
+
),
|
|
191
|
+
];
|
|
192
|
+
|
|
164
193
|
return {
|
|
165
194
|
basePath,
|
|
166
195
|
branding,
|
package/src/crud.ts
CHANGED
|
@@ -41,8 +41,13 @@ export type CrudResult<Row extends AdminRow> =
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export type ListResult<Row extends AdminRow> =
|
|
44
|
-
| { readonly ok: true; readonly page: AdminPage<Row
|
|
45
|
-
| {
|
|
44
|
+
| { readonly ok: true; readonly page: AdminPage<Row>; readonly audit: AuditEntry }
|
|
45
|
+
| {
|
|
46
|
+
readonly ok: false;
|
|
47
|
+
readonly kind: 'denied';
|
|
48
|
+
readonly decision: AdminDecision;
|
|
49
|
+
readonly audit: AuditEntry;
|
|
50
|
+
};
|
|
46
51
|
|
|
47
52
|
/** Both gates, always in this order: the admin-level one, then the entity-level one. */
|
|
48
53
|
export function permissionsForOperation(entity: string, op: AdminOperation): readonly string[] {
|
|
@@ -96,14 +101,38 @@ async function refuse<Row extends AdminRow>(
|
|
|
96
101
|
};
|
|
97
102
|
}
|
|
98
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The one read that logged nothing, in either direction: `audit.ts` says denied and failed attempts
|
|
106
|
+
* are logged too, and `adminDetail` below logs the allowed read as well — so a listing that walked
|
|
107
|
+
* every row of a table left no trace, and a refused listing left no trace of the refusal either.
|
|
108
|
+
* There is no `entityId`: the subject is the table, not a row.
|
|
109
|
+
*/
|
|
99
110
|
export async function adminList<Row extends AdminRow>(
|
|
100
111
|
resource: AdminResource<Row>,
|
|
101
112
|
ctx: CrudCtx,
|
|
102
113
|
req: PageRequest = {},
|
|
103
114
|
): Promise<ListResult<Row>> {
|
|
104
115
|
const decision = decideOperation(resource, 'list', ctx);
|
|
105
|
-
if (!decision.allowed)
|
|
106
|
-
|
|
116
|
+
if (!decision.allowed) {
|
|
117
|
+
const refused = await refuse(resource, 'list', ctx, decision, null);
|
|
118
|
+
return { ok: false, kind: 'denied', decision, audit: refused.audit };
|
|
119
|
+
}
|
|
120
|
+
const page = await fetchPage(resource, req);
|
|
121
|
+
return {
|
|
122
|
+
ok: true,
|
|
123
|
+
page,
|
|
124
|
+
audit: await ctx.audit.append({
|
|
125
|
+
requestId: ctx.requestId,
|
|
126
|
+
actor: ctx.actor,
|
|
127
|
+
operation: 'list',
|
|
128
|
+
kind: 'operation',
|
|
129
|
+
entity: resource.name,
|
|
130
|
+
entityId: null,
|
|
131
|
+
permission: decision.permission,
|
|
132
|
+
outcome: 'allowed',
|
|
133
|
+
reason: decision.reason,
|
|
134
|
+
}),
|
|
135
|
+
};
|
|
107
136
|
}
|
|
108
137
|
|
|
109
138
|
export async function adminDetail<Row extends AdminRow>(
|
package/src/detail.tsx
CHANGED
|
@@ -49,7 +49,10 @@ export function AdminDetail<Row extends AdminRow>(props: AdminDetailProps<Row>):
|
|
|
49
49
|
<ErrorState
|
|
50
50
|
error={adminErrorFrom({
|
|
51
51
|
code: 'X_ADMIN_ENTITY_UNKNOWN',
|
|
52
|
-
cause
|
|
52
|
+
// `.cause`, not a bare `admin.detail.not-found`: a catalog is authored nested and
|
|
53
|
+
// `parseNestedCatalog` refuses a dot inside a key, so a name is a leaf or a branch and
|
|
54
|
+
// never both — `admin.detail.not-found` + `.fix` could not have coexisted in en.json.
|
|
55
|
+
cause: t('admin.detail.not-found.cause', { entity: props.resource.name }),
|
|
53
56
|
fix: t('admin.detail.not-found.fix'),
|
|
54
57
|
})}
|
|
55
58
|
/>
|
package/src/dev/data.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { JobState, StepStatus } from '@ultimat3/jobs';
|
|
|
11
11
|
import type { AdminActor, AdminAuthz } from '../authz';
|
|
12
12
|
import { DevSourceUnavailableError } from '../errors';
|
|
13
13
|
import type {
|
|
14
|
+
BackfillFact,
|
|
14
15
|
CacheEdgeFact,
|
|
15
16
|
DevSources,
|
|
16
17
|
DriftFact,
|
|
@@ -27,6 +28,7 @@ import type {
|
|
|
27
28
|
RequestTrace,
|
|
28
29
|
RouteFact,
|
|
29
30
|
SqlResult,
|
|
31
|
+
StatementLoopFact,
|
|
30
32
|
TableFact,
|
|
31
33
|
TaskFact,
|
|
32
34
|
} from './facts';
|
|
@@ -38,11 +40,14 @@ export function staticDevSources(facts: Partial<DevSources> = {}): DevSources {
|
|
|
38
40
|
return {
|
|
39
41
|
routes: facts.routes ?? ((): Promise<readonly RouteFact[]> => empty([])),
|
|
40
42
|
traces: facts.traces ?? ((): Promise<readonly RequestTrace[]> => empty([])),
|
|
43
|
+
statementLoops:
|
|
44
|
+
facts.statementLoops ?? ((): Promise<readonly StatementLoopFact[]> => empty([])),
|
|
41
45
|
liveQueries: facts.liveQueries ?? ((): Promise<readonly LiveQueryFact[]> => empty([])),
|
|
42
46
|
subscribers: facts.subscribers ?? ((): Promise<readonly LiveSubscriberFact[]> => empty([])),
|
|
43
47
|
jobDefs: facts.jobDefs ?? ((): Promise<readonly JobDefFact[]> => empty([])),
|
|
44
48
|
queues: facts.queues ?? ((): Promise<readonly QueueFact[]> => empty([])),
|
|
45
49
|
jobRuns: facts.jobRuns ?? ((): Promise<readonly JobRunFact[]> => empty([])),
|
|
50
|
+
backfills: facts.backfills ?? ((): Promise<readonly BackfillFact[]> => empty([])),
|
|
46
51
|
tasks: facts.tasks ?? ((): Promise<readonly TaskFact[]> => empty([])),
|
|
47
52
|
tables: facts.tables ?? ((): Promise<readonly TableFact[]> => empty([])),
|
|
48
53
|
drift: facts.drift ?? ((): Promise<readonly DriftFact[]> => empty([])),
|
|
@@ -80,6 +85,13 @@ export interface DevSourceOptions {
|
|
|
80
85
|
* wiring line, rather than rendering an empty panel that reads as "nothing happened".
|
|
81
86
|
*/
|
|
82
87
|
readonly hooks?: Partial<DevSources>;
|
|
88
|
+
/**
|
|
89
|
+
* Sample input per query name, so the live panel can show the SQL a query actually compiles
|
|
90
|
+
* to. `@ultimat3/query`'s `QueryDescriptor` carries no SQL text — it depends on the input —
|
|
91
|
+
* so a query with no sample here stays `sql: null` rather than an invented guess. `x dev
|
|
92
|
+
* --actor` supplies these the same way it supplies `actors` for the policy matrix.
|
|
93
|
+
*/
|
|
94
|
+
readonly sqlSamples?: Readonly<Record<string, unknown>>;
|
|
83
95
|
}
|
|
84
96
|
|
|
85
97
|
const unavailable = (source: string, panel: string): DevSourceUnavailableError =>
|
|
@@ -96,13 +108,22 @@ const unwired = <T>(source: string, panel: string): (() => Promise<T>) => {
|
|
|
96
108
|
/** How many recent runs the jobs panel traces. A dev panel reads, it does not page. */
|
|
97
109
|
const RUN_WINDOW = 50;
|
|
98
110
|
|
|
99
|
-
/**
|
|
111
|
+
/**
|
|
112
|
+
* `JobState` and `StepStatus` are the queue's vocabulary; the panel renders its own.
|
|
113
|
+
*
|
|
114
|
+
* `cancelled` is `ok` and not `dead`, because the panel's four words split on "did this need
|
|
115
|
+
* attention", not on "did the handler run". `panel-jobs.ts` reads `dead` as the dead-letter list
|
|
116
|
+
* and `failed | dead` as the needs-attention list — a job an operator stopped on purpose belongs
|
|
117
|
+
* in neither, and filing it under `dead` would put `x jobs retry` in front of a reader as the
|
|
118
|
+
* remedy for a cancellation nobody wants resurrected.
|
|
119
|
+
*/
|
|
100
120
|
const RUN_STATUS: Readonly<Record<JobState, JobRunFact['status']>> = {
|
|
101
121
|
ready: 'running',
|
|
102
122
|
delayed: 'running',
|
|
103
123
|
running: 'running',
|
|
104
124
|
suspended: 'running',
|
|
105
125
|
done: 'ok',
|
|
126
|
+
cancelled: 'ok',
|
|
106
127
|
failed: 'failed',
|
|
107
128
|
dead: 'dead',
|
|
108
129
|
};
|
|
@@ -146,19 +167,35 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
146
167
|
|
|
147
168
|
traces: unwired<readonly RequestTrace[]>('traces', 'timeline'),
|
|
148
169
|
|
|
170
|
+
// The verdicts belong to the one statement ledger `x dev` installs, so a host without it
|
|
171
|
+
// refuses here rather than answering `[]`: an empty list claims "no N+1 in this request",
|
|
172
|
+
// which is a different and unearned answer — the same argument `subscribers` and `mail` make.
|
|
173
|
+
statementLoops: unwired<readonly StatementLoopFact[]>('statementLoops', 'timeline'),
|
|
174
|
+
|
|
149
175
|
async liveQueries(): Promise<readonly LiveQueryFact[]> {
|
|
150
|
-
const { describeQueries } = await import('@ultimat3/query');
|
|
151
|
-
|
|
176
|
+
const { describeQueries, describeSql, listQueries } = await import('@ultimat3/query');
|
|
177
|
+
const queries = describeQueries();
|
|
178
|
+
// `describeSql` is the one place that actually compiles a query to text — it needs a
|
|
179
|
+
// sample input to do it, so a name with no entry in `sqlSamples` comes back `sql: null`
|
|
180
|
+
// rather than the permanently-empty string this source used to answer for every query.
|
|
181
|
+
// It takes the live `AnyQuery[]` targets (`listQueries()`), never the already-projected
|
|
182
|
+
// `QueryDescriptor[]` this method also needs for `name`/`live`/`capability`.
|
|
183
|
+
const sqlByName = new Map(
|
|
184
|
+
(await describeSql(opts.sqlSamples ?? {}, listQueries())).map((entry) => [
|
|
185
|
+
entry.query,
|
|
186
|
+
entry.sql,
|
|
187
|
+
]),
|
|
188
|
+
);
|
|
189
|
+
return listOf(queries).map((raw) => {
|
|
152
190
|
const query = bagOf(raw);
|
|
191
|
+
const name = str(query['name']);
|
|
153
192
|
return {
|
|
154
|
-
name
|
|
193
|
+
name,
|
|
155
194
|
live: query['live'] === true,
|
|
156
195
|
// `QueryDescriptor`'s permission field is named `capability`, not `policy` — this
|
|
157
196
|
// fact keeps its own field named `policy` (that is the /_x rendering, not the registry).
|
|
158
197
|
policy: str(query['capability']),
|
|
159
|
-
|
|
160
|
-
// registry actually describes one — never invent a value here.
|
|
161
|
-
sql: str(query['sql']),
|
|
198
|
+
sql: sqlByName.get(name) ?? null,
|
|
162
199
|
};
|
|
163
200
|
});
|
|
164
201
|
},
|
|
@@ -227,6 +264,31 @@ export function defaultDevSources(opts: DevSourceOptions = {}): DevSources {
|
|
|
227
264
|
}));
|
|
228
265
|
},
|
|
229
266
|
|
|
267
|
+
/**
|
|
268
|
+
* The whole ledger, newest first — not just the passes in flight. `x jobs ls` reports the live
|
|
269
|
+
* queue and says so; a panel is read to answer "has this backfill ever run here, and what did
|
|
270
|
+
* it sweep", and the completed rows ARE that answer.
|
|
271
|
+
*
|
|
272
|
+
* `inspectBackfills` returns `[]` for a driver that ships no ledger, so only a process with no
|
|
273
|
+
* queue at all refuses here — which is the same line `queues` and `jobRuns` draw.
|
|
274
|
+
*/
|
|
275
|
+
async backfills(): Promise<readonly BackfillFact[]> {
|
|
276
|
+
const { inspectBackfills, jobDriver } = await import('@ultimat3/jobs');
|
|
277
|
+
const driver = jobDriver();
|
|
278
|
+
if (driver === undefined) throw unavailable('backfills', 'jobs');
|
|
279
|
+
return (await inspectBackfills(driver)).map((pass) => ({
|
|
280
|
+
runId: pass.runId,
|
|
281
|
+
name: pass.name,
|
|
282
|
+
status: pass.status,
|
|
283
|
+
rows: pass.rows,
|
|
284
|
+
cursor: pass.cursor,
|
|
285
|
+
startedAt: pass.startedAt,
|
|
286
|
+
completedAt: pass.completedAt,
|
|
287
|
+
durationMs: pass.durationMs,
|
|
288
|
+
appVersion: pass.appVersion,
|
|
289
|
+
}));
|
|
290
|
+
},
|
|
291
|
+
|
|
230
292
|
async tasks(): Promise<readonly TaskFact[]> {
|
|
231
293
|
const { inspectManifest } = await import('@ultimat3/jobs');
|
|
232
294
|
return inspectManifest().tasks.map((task) => ({
|
package/src/dev/facts.ts
CHANGED
|
@@ -39,6 +39,28 @@ export interface RequestTrace {
|
|
|
39
39
|
readonly spans: readonly TimelineSpan[];
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
/**
|
|
43
|
+
* One statement shape repeated inside one request past the detector's threshold — a verdict,
|
|
44
|
+
* already carrying the error a host renders. The count, the attribution and the suppression rule
|
|
45
|
+
* are the detector's (`x dev`'s statement ledger); nothing here re-derives them from the spans,
|
|
46
|
+
* because a second count blind to `expectedQueryLoop` would disagree with the one that warns.
|
|
47
|
+
*/
|
|
48
|
+
export interface StatementLoopFact {
|
|
49
|
+
/** The request the loop happened in — how a trace and its verdicts are matched up. */
|
|
50
|
+
readonly requestId: string;
|
|
51
|
+
/** `X_N_PLUS_ONE_QUERY` or `X_N_PLUS_ONE_WRITE`. */
|
|
52
|
+
readonly code: string;
|
|
53
|
+
readonly cause: string;
|
|
54
|
+
/** Runnable, and the whole point: the `preload`/`insertAll` line that ends the loop. */
|
|
55
|
+
readonly fix: string;
|
|
56
|
+
readonly docs: string | null;
|
|
57
|
+
/** What repeated: `members.findById` when a repository sent it, else the statement's own text. */
|
|
58
|
+
readonly subject: string;
|
|
59
|
+
readonly count: number;
|
|
60
|
+
/** One of the statements, verbatim. */
|
|
61
|
+
readonly sample: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
42
64
|
export interface LiveSubscriberFact {
|
|
43
65
|
readonly id: string;
|
|
44
66
|
readonly query: string;
|
|
@@ -54,7 +76,8 @@ export interface LiveQueryFact {
|
|
|
54
76
|
readonly name: string;
|
|
55
77
|
readonly live: boolean;
|
|
56
78
|
readonly policy: string;
|
|
57
|
-
|
|
79
|
+
/** `null` when no sample input was supplied for this query — SQL depends on arguments. */
|
|
80
|
+
readonly sql: string | null;
|
|
58
81
|
}
|
|
59
82
|
|
|
60
83
|
export interface JobDefFact {
|
|
@@ -97,6 +120,28 @@ export interface TaskFact {
|
|
|
97
120
|
readonly nextRunAt: string | null;
|
|
98
121
|
}
|
|
99
122
|
|
|
123
|
+
/**
|
|
124
|
+
* One `x_backfills` pass. The queue's own facts answer "is this job moving"; only the ledger
|
|
125
|
+
* answers "how much of the table is behind it", which is the one question a sweep is watched for.
|
|
126
|
+
* `cursor` is where the pass had got to and `null` before its first batch — never a percentage:
|
|
127
|
+
* nothing counted the rows ahead of it, and a made-up denominator is the fact this panel exists
|
|
128
|
+
* not to invent.
|
|
129
|
+
*/
|
|
130
|
+
export interface BackfillFact {
|
|
131
|
+
/** The pass, and the run id of the job sweeping it — the join back to `runs` on this panel. */
|
|
132
|
+
readonly runId: string;
|
|
133
|
+
readonly name: string;
|
|
134
|
+
readonly status: 'running' | 'completed' | 'failed';
|
|
135
|
+
readonly rows: number;
|
|
136
|
+
readonly cursor: string | null;
|
|
137
|
+
readonly startedAt: string;
|
|
138
|
+
readonly completedAt: string | null;
|
|
139
|
+
/** How long the pass took. `null` while it is still running. */
|
|
140
|
+
readonly durationMs: number | null;
|
|
141
|
+
/** The build that STARTED the pass — a redeploy mid-sweep does not rewrite it. */
|
|
142
|
+
readonly appVersion: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
100
145
|
export interface ColumnFact {
|
|
101
146
|
readonly name: string;
|
|
102
147
|
readonly type: string;
|
|
@@ -163,11 +208,13 @@ export interface ManifestFact {
|
|
|
163
208
|
export interface DevSources {
|
|
164
209
|
routes(): Promise<readonly RouteFact[]>;
|
|
165
210
|
traces(): Promise<readonly RequestTrace[]>;
|
|
211
|
+
statementLoops(): Promise<readonly StatementLoopFact[]>;
|
|
166
212
|
liveQueries(): Promise<readonly LiveQueryFact[]>;
|
|
167
213
|
subscribers(): Promise<readonly LiveSubscriberFact[]>;
|
|
168
214
|
jobDefs(): Promise<readonly JobDefFact[]>;
|
|
169
215
|
queues(): Promise<readonly QueueFact[]>;
|
|
170
216
|
jobRuns(): Promise<readonly JobRunFact[]>;
|
|
217
|
+
backfills(): Promise<readonly BackfillFact[]>;
|
|
171
218
|
tasks(): Promise<readonly TaskFact[]>;
|
|
172
219
|
tables(): Promise<readonly TableFact[]>;
|
|
173
220
|
drift(): Promise<readonly DriftFact[]>;
|
package/src/dev/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
export { type DevSourceOptions, defaultDevSources, staticDevSources } from './data';
|
|
7
7
|
export type {
|
|
8
|
+
BackfillFact,
|
|
8
9
|
CacheEdgeFact,
|
|
9
10
|
ColumnFact,
|
|
10
11
|
DevSources,
|
|
@@ -23,6 +24,7 @@ export type {
|
|
|
23
24
|
RouteFact,
|
|
24
25
|
SpanKind,
|
|
25
26
|
SqlResult,
|
|
27
|
+
StatementLoopFact,
|
|
26
28
|
TableFact,
|
|
27
29
|
TaskFact,
|
|
28
30
|
TimelineSpan,
|
|
@@ -44,4 +46,5 @@ export {
|
|
|
44
46
|
type DevDashboard,
|
|
45
47
|
type DevDashboardOptions,
|
|
46
48
|
devDashboard,
|
|
49
|
+
devShellStyle,
|
|
47
50
|
} from './server';
|