@ultimat3/manifest 22.15.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 +11 -1
- package/README.md +6 -5
- package/package.json +8 -7
- package/src/build.ts +22 -7
- package/src/diff-admin.ts +199 -0
- package/src/diff-entities.ts +80 -0
- package/src/diff-fixtures.ts +1 -1
- package/src/diff-operations.ts +7 -1
- package/src/diff-work.ts +16 -0
- package/src/diff.ts +3 -0
- package/src/emit.ts +1 -0
- package/src/errors.ts +22 -0
- package/src/finite-facts.ts +28 -0
- package/src/index.ts +5 -0
- package/src/schema.ts +104 -2
- package/src/sources-admin.ts +158 -0
- package/src/sources.ts +67 -4
package/CLAUDE.md
CHANGED
|
@@ -13,6 +13,7 @@ by the CLI, not imported.
|
|
|
13
13
|
| `schema.ts` | the manifest's typed shape + `MANIFEST_VERSION` |
|
|
14
14
|
| `build.ts` | `buildManifest` — pure, deterministic, stably sorted |
|
|
15
15
|
| `sources.ts` | wires `describe*` from entity/action/query/jobs into `ManifestSources` |
|
|
16
|
+
| `sources-admin.ts` | the declared admins, read off `Symbol.for('ultimate.admin.mounts')` through each one's own `describe()` — never an import of `@ultimat3/admin`. `unknown` in, field by field: a description this build does not understand is skipped whole |
|
|
16
17
|
| `diff.ts` | `diffManifest` — the orchestrator: one classifier per section, nothing else |
|
|
17
18
|
| `diff-change.ts` | the shared vocabulary: `ManifestChange`, `index`, `diffNamedSet`, `diffScalar` |
|
|
18
19
|
| `diff-operations.ts` | actions, queries and the permissions they require |
|
|
@@ -20,6 +21,7 @@ by the CLI, not imported.
|
|
|
20
21
|
| `diff-entities.ts` | tables, columns, keys, invariants |
|
|
21
22
|
| `diff-work.ts` | jobs and tasks — the two things that fail by silently not happening |
|
|
22
23
|
| `diff-routes.ts` | a URL's surface and its delivery facts |
|
|
24
|
+
| `diff-admin.ts` | the `admin` section: a filter, sort, scope, resource or mounted route removed is breaking; a default scope moving or a row scope appearing is breaking; a route's permissions are judged like an operation's |
|
|
23
25
|
| `diff-registries.ts` | policies and error codes |
|
|
24
26
|
| `diff-fixtures.ts` | TEST-ONLY: one fully-populated `ManifestSources`. Never in `index.ts` |
|
|
25
27
|
| `verify.ts` | `verifyContract` — the major-bump gate |
|
|
@@ -60,7 +62,10 @@ by the CLI, not imported.
|
|
|
60
62
|
they already carried — so the swap is observable only where the old form folded. **The published
|
|
61
63
|
document is `manifestJson` and is still `JSON.stringify` with a fixed key order**: an injective
|
|
62
64
|
form emits tokens JSON cannot parse, which is why `@ultimat3/action`'s `stableStringify` exists
|
|
63
|
-
as a separate function and why this one may not be written to disk.
|
|
65
|
+
as a separate function and why this one may not be written to disk. **So a fact the document
|
|
66
|
+
cannot hold is refused at build** (`As of 2026-10-02`, `finite-facts.ts`, `X_MANIFEST_FACT_INVALID`
|
|
67
|
+
with `meta.path`): `NaN`, `±Infinity` and `-0` hashed apart from what the file wrote, so a manifest
|
|
68
|
+
carrying one failed its own `verifyBuildId` and read as drift on every build.
|
|
64
69
|
- Job `steps` keep declared order. Everything else sorts.
|
|
65
70
|
- `permissions` is derived, never a second declared list — and derived from each operation's own
|
|
66
71
|
`permissions`, **never from `policy`**. `policy` is a DISPLAY label: a composite renders as
|
|
@@ -134,6 +139,11 @@ by the CLI, not imported.
|
|
|
134
139
|
"no key", so a dropped one is classified. `surface`, `offline`, `hydrate`, `budget` and
|
|
135
140
|
`revalidateTags` absent means "this file predates the field", so `diffScalar` skips a side that
|
|
136
141
|
carries nothing rather than reporting every route in an upgraded app as re-surfaced.
|
|
142
|
+
`QueryFact.input` follows the same rule (`As of 2026-10-02`): `sources.ts` now projects the
|
|
143
|
+
handle's schema through `jsonSchemaOf` — it wrote none before, so the README's promise was false
|
|
144
|
+
and the input compare never fired — and a committed manifest from before carries none.
|
|
145
|
+
`hasDefault` is different: absent IS "no default", so a NOT NULL column losing one is breaking,
|
|
146
|
+
a nullable one `internal`, gaining one additive.
|
|
137
147
|
- **`MANIFEST_VERSION` bumps only when a reader built for the old version would be WRONG** — a
|
|
138
148
|
field removed, retyped, or given a new meaning — never for one that is merely added. Two costs
|
|
139
149
|
make the reflex expensive: `isCompatible` is an equality check, so a bump rejects every
|
package/README.md
CHANGED
|
@@ -20,7 +20,8 @@ verifyContract({ before: committed, after: manifest });
|
|
|
20
20
|
| `entities` | table, columns (type, nullability, PK, FK), named invariants |
|
|
21
21
|
| `actions` | input + output schema, policy label, **required permissions**, cache invalidations, declared rate limit, MCP exposure, `mutator` when it is one |
|
|
22
22
|
| `queries` | input schema, policy label, **required permissions**, live, cache tags |
|
|
23
|
-
| `
|
|
23
|
+
| `admin` | one entry per `defineAdmin()`: `basePath`; each resource's `filters`, `sorts`, `scopes` (`name`, `default`, `count`) and `rowScoped`; each mounted route's `url`, `view`, `entity` and `permissions`. `[]` for an app with no admin. Read off the process's own declarations — the one section the CLI does not inject |
|
|
24
|
+
| `jobs` | input schema, queue, retry policy, step names, and — only when the job declares a cap — `concurrency: { limit, keyed, whenBusy }` |
|
|
24
25
|
| `tasks` | cron, tz, jobs enqueued |
|
|
25
26
|
| `policies` | permission, where enforced |
|
|
26
27
|
| `permissions` | **derived** from policies + each operation's own list, never declared twice |
|
|
@@ -59,9 +60,9 @@ whole mechanism.
|
|
|
59
60
|
|
|
60
61
|
| Class | Examples |
|
|
61
62
|
|---|---|
|
|
62
|
-
| **breaking** | action/query/route/job/**task**/entity/**policy**/**error code** removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a policy gained an enforcement site**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; **a column's `primaryKey` or `references` changed in either direction**; **an entity's `table` renamed**; **an invariant added**; **a job's `queue` moved or its `retry.attempts` lowered**; **a route's `surface` changed**; live query became non-live |
|
|
63
|
-
| **additive** | primitive added; nullable column added; **a column that lost NOT NULL**; a required permission dropped; an enforcement site dropped; **an invariant dropped**; a rate limit loosened or removed; **more retry attempts**; MCP exposure granted; locale added |
|
|
64
|
-
| **internal** | cache tags changed (actions **and queries**); render mode changed; **a route's `offline`/`hydrate`/`budget`/`revalidateTags`**; **a task's `cron`/`tz`/`enqueues`**; **a job's `retry.backoff`**; job steps reordered; **an error code's owning package**; `buildId` |
|
|
63
|
+
| **breaking** | action/query/route/job/**task**/entity/**policy**/**error code** removed; input or output schema changed; policy changed; **an operation gained a required permission**; **a policy gained an enforcement site**; **a rate limit was tightened or introduced**; MCP exposure withdrawn; column removed, retyped, or made NOT NULL; **a column sealed, unsealed, or moved from `lookup` to opaque** (sealing removes the field from every output; unsealing leaves stored values ciphertext); **a column's `primaryKey` or `references` changed in either direction**; **an entity's `table` renamed**; **an invariant added**; **a job's `queue` moved or its `retry.attempts` lowered**; **a route's `surface` changed**; live query became non-live; **an admin, an admin resource or an admin route removed; an admin list's filter, sort or scope removed; its default scope moved; a row scope introduced** |
|
|
64
|
+
| **additive** | primitive added; nullable column added; **a column that lost NOT NULL**; **a sealed column moved from opaque to `lookup`** (reported: equal values now store equal strings); a required permission dropped; an enforcement site dropped; **an invariant dropped**; a rate limit loosened or removed; **more retry attempts**; MCP exposure granted; locale added; an admin, resource, route, filter, sort or scope added; a row scope removed |
|
|
65
|
+
| **internal** | cache tags changed (actions **and queries**); render mode changed; **a route's `offline`/`hydrate`/`budget`/`revalidateTags`**; **a task's `cron`/`tz`/`enqueues`**; **a job's `retry.backoff`**; **a job's `concurrency`, declared, edited or removed**; job steps reordered; **an error code's owning package**; `buildId` |
|
|
65
66
|
|
|
66
67
|
Every top-level section is classified, and that is checked rather than promised:
|
|
67
68
|
`diff.test.ts` walks `ARRAY_SECTIONS` from `schema.ts` and fails on a section nothing reads. Two
|
|
@@ -70,7 +71,7 @@ every scheduled task reported `internal buildId: content changed` and passed the
|
|
|
70
71
|
|
|
71
72
|
One classifier per section, each in its own file: `diff-operations.ts` (actions, queries,
|
|
72
73
|
permissions), `diff-rate-limit.ts`, `diff-entities.ts`, `diff-work.ts` (jobs, tasks),
|
|
73
|
-
`diff-routes.ts`, `diff-registries.ts` (policies, error codes), over the shared vocabulary in
|
|
74
|
+
`diff-routes.ts`, `diff-admin.ts`, `diff-registries.ts` (policies, error codes), over the shared vocabulary in
|
|
74
75
|
`diff-change.ts`. `diff.ts` is the orchestrator and nothing else.
|
|
75
76
|
|
|
76
77
|
`verifyContract()` is the gate: a breaking change fails unless the app's **major** version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/manifest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
21
|
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*-fixture.ts",
|
|
22
23
|
"CLAUDE.md",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
|
@@ -31,11 +32,11 @@
|
|
|
31
32
|
"test": "bun test"
|
|
32
33
|
},
|
|
33
34
|
"dependencies": {
|
|
34
|
-
"@ultimat3/action": "
|
|
35
|
-
"@ultimat3/core": "
|
|
36
|
-
"@ultimat3/entity": "
|
|
37
|
-
"@ultimat3/jobs": "
|
|
38
|
-
"@ultimat3/query": "
|
|
39
|
-
"@ultimat3/realtime": "
|
|
35
|
+
"@ultimat3/action": "24.0.0",
|
|
36
|
+
"@ultimat3/core": "24.0.0",
|
|
37
|
+
"@ultimat3/entity": "24.0.0",
|
|
38
|
+
"@ultimat3/jobs": "24.0.0",
|
|
39
|
+
"@ultimat3/query": "24.0.0",
|
|
40
|
+
"@ultimat3/realtime": "24.0.0"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/build.ts
CHANGED
|
@@ -16,9 +16,11 @@
|
|
|
16
16
|
// assembled per app — both outside what this tier may import — so the CLI supplies them and
|
|
17
17
|
// this function stays pure and unit-testable.
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { fingerprint } from '@ultimat3/core';
|
|
20
|
+
import { assertFiniteFacts } from './finite-facts';
|
|
20
21
|
import type {
|
|
21
22
|
ActionFact,
|
|
23
|
+
AdminFact,
|
|
22
24
|
ChannelFact,
|
|
23
25
|
EntityFact,
|
|
24
26
|
ErrorCodeFact,
|
|
@@ -38,6 +40,7 @@ export interface ManifestSources {
|
|
|
38
40
|
readonly actions?: readonly ActionFact[];
|
|
39
41
|
readonly queries?: readonly QueryFact[];
|
|
40
42
|
readonly channels?: readonly ChannelFact[];
|
|
43
|
+
readonly admin?: readonly AdminFact[];
|
|
41
44
|
readonly jobs?: readonly JobFact[];
|
|
42
45
|
readonly tasks?: readonly TaskFact[];
|
|
43
46
|
readonly policies?: readonly PolicyFact[];
|
|
@@ -51,6 +54,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
51
54
|
const actions = sortBy(sources.actions ?? [], (a) => a.name).map(normalizeAction);
|
|
52
55
|
const queries = sortBy(sources.queries ?? [], (q) => q.name).map(normalizeQuery);
|
|
53
56
|
const channels = sortBy(sources.channels ?? [], (c) => c.name).map(normalizeChannel);
|
|
57
|
+
const admin = sortBy(sources.admin ?? [], (a) => a.basePath).map(normalizeAdmin);
|
|
54
58
|
const jobs = sortBy(sources.jobs ?? [], (j) => j.name).map(normalizeJob);
|
|
55
59
|
const tasks = sortBy(sources.tasks ?? [], (t) => t.name).map((t) => ({
|
|
56
60
|
...t,
|
|
@@ -84,6 +88,7 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
84
88
|
actions,
|
|
85
89
|
queries,
|
|
86
90
|
channels,
|
|
91
|
+
admin,
|
|
87
92
|
jobs,
|
|
88
93
|
tasks,
|
|
89
94
|
policies,
|
|
@@ -92,21 +97,22 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
92
97
|
errorCodes,
|
|
93
98
|
};
|
|
94
99
|
|
|
100
|
+
// Before the hash: a fact the written file cannot hold must never get a buildId at all.
|
|
101
|
+
assertFiniteFacts(body, '');
|
|
95
102
|
return { ...body, buildId: contentHash(body) };
|
|
96
103
|
}
|
|
97
104
|
|
|
98
105
|
/**
|
|
99
|
-
* Content hash of the manifest body
|
|
100
|
-
*
|
|
101
|
-
* that
|
|
106
|
+
* Content hash of the manifest body: `@ultimat3/core`'s `fingerprint`, SHA-256/16 over the same
|
|
107
|
+
* INJECTIVE `canonicalJson` the diff compares on, so a fact that changed cannot hash the same as
|
|
108
|
+
* the fact it replaced. It was a hand-written copy of that function, byte for byte — the value is
|
|
109
|
+
* unchanged, the second implementation is gone. Deliberately excludes `buildId` itself.
|
|
102
110
|
*
|
|
103
111
|
* This is a HASH, never the published document: `manifestJson` in `emit.ts` is what reaches disk,
|
|
104
112
|
* and it is `JSON.stringify` with a fixed key order for exactly that reason.
|
|
105
113
|
*/
|
|
106
114
|
export function contentHash(body: Omit<Manifest, 'buildId'>): string {
|
|
107
|
-
|
|
108
|
-
hasher.update(canonicalJson(body));
|
|
109
|
-
return hasher.digest('hex').slice(0, 16);
|
|
115
|
+
return fingerprint(body);
|
|
110
116
|
}
|
|
111
117
|
|
|
112
118
|
function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
|
|
@@ -134,6 +140,15 @@ const normalizeRoute = (route: RouteFact): RouteFact =>
|
|
|
134
140
|
// different answer from an absent key, and `emit.ts` writes what it is given.
|
|
135
141
|
{ ...route, revalidateTags: [...route.revalidateTags].sort() };
|
|
136
142
|
|
|
143
|
+
// Resources and routes are sets, keyed by entity and by URL. What is INSIDE them is not: filters
|
|
144
|
+
// and scopes are drawn in declaration order, and a route's permissions are a pair with the coarse
|
|
145
|
+
// gate first — sorting either would publish an order the admin does not have.
|
|
146
|
+
const normalizeAdmin = (admin: AdminFact): AdminFact => ({
|
|
147
|
+
...admin,
|
|
148
|
+
resources: sortBy(admin.resources, (r) => r.entity),
|
|
149
|
+
routes: sortBy(admin.routes, (r) => r.url),
|
|
150
|
+
});
|
|
151
|
+
|
|
137
152
|
const normalizeEntity = (entity: EntityFact): EntityFact => ({
|
|
138
153
|
...entity,
|
|
139
154
|
columns: sortBy(entity.columns, (c) => c.name),
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// The admin in a contract diff. An admin's list URLs are bookmarked and its MCP tools are called
|
|
2
|
+
// by agents, so what a list ANSWERS is contract: a filter, a sort or a scope that goes away
|
|
3
|
+
// refuses a caller it served, a default scope that moves changes what a bare URL lists, and a row
|
|
4
|
+
// scope that appears hides rows from actors who saw them. An action is an MCP tool: one that goes
|
|
5
|
+
// away, stops taking `ids`, or starts excluding rows (`when`) refuses a call it answered. A
|
|
6
|
+
// route's permissions are judged like any operation's; layout and related lists are internal.
|
|
7
|
+
|
|
8
|
+
import type { ManifestChange } from './diff-change';
|
|
9
|
+
import { diffNamedSet, index } from './diff-change';
|
|
10
|
+
import { diffPermissions } from './diff-operations';
|
|
11
|
+
import type { AdminActionFact, AdminFact, AdminResourceFact } from './schema';
|
|
12
|
+
|
|
13
|
+
/** A change of one boolean fact, breaking in one direction and additive in the other. */
|
|
14
|
+
const flag = (
|
|
15
|
+
path: string,
|
|
16
|
+
before: boolean,
|
|
17
|
+
after: boolean,
|
|
18
|
+
breaksWhen: boolean,
|
|
19
|
+
detail: string,
|
|
20
|
+
): readonly ManifestChange[] =>
|
|
21
|
+
before === after
|
|
22
|
+
? []
|
|
23
|
+
: [
|
|
24
|
+
{
|
|
25
|
+
kind: after === breaksWhen ? 'breaking' : 'additive',
|
|
26
|
+
path,
|
|
27
|
+
detail: `${detail} ${String(before)} -> ${String(after)}`,
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
function diffActions(
|
|
32
|
+
path: string,
|
|
33
|
+
before: readonly AdminActionFact[],
|
|
34
|
+
after: readonly AdminActionFact[],
|
|
35
|
+
): readonly ManifestChange[] {
|
|
36
|
+
const changes: ManifestChange[] = [
|
|
37
|
+
...diffNamedSet(
|
|
38
|
+
path,
|
|
39
|
+
before.map((action) => action.name),
|
|
40
|
+
after.map((action) => action.name),
|
|
41
|
+
),
|
|
42
|
+
];
|
|
43
|
+
const next = index(after, (action) => action.name);
|
|
44
|
+
for (const action of before) {
|
|
45
|
+
const now = next.get(action.name);
|
|
46
|
+
if (now === undefined) continue;
|
|
47
|
+
const at = `${path}.${action.name}`;
|
|
48
|
+
changes.push(
|
|
49
|
+
...diffPermissions(
|
|
50
|
+
at,
|
|
51
|
+
{ permissions: [action.permission] },
|
|
52
|
+
{ permissions: [now.permission] },
|
|
53
|
+
),
|
|
54
|
+
// `when` appearing refuses rows the tool ran on; the batch path going away refuses `ids`;
|
|
55
|
+
// a schema appearing refuses a call that sent nothing; destructive starts asking for a token.
|
|
56
|
+
...flag(`${at}.when`, action.when, now.when, true, 'when'),
|
|
57
|
+
...flag(`${at}.batch`, action.batch, now.batch, false, 'batch'),
|
|
58
|
+
...flag(`${at}.input`, action.input, now.input, true, 'input schema'),
|
|
59
|
+
...flag(`${at}.destructive`, action.destructive, now.destructive, true, 'destructive'),
|
|
60
|
+
);
|
|
61
|
+
if (action.threshold !== now.threshold) {
|
|
62
|
+
changes.push({
|
|
63
|
+
kind: 'internal',
|
|
64
|
+
path: `${at}.threshold`,
|
|
65
|
+
detail: `threshold ${String(action.threshold)} -> ${String(now.threshold)}`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return changes;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const layout = (sections: AdminResourceFact['sections']): string =>
|
|
73
|
+
JSON.stringify(sections.map((section) => [section.title, section.fields]));
|
|
74
|
+
|
|
75
|
+
function diffResource(
|
|
76
|
+
path: string,
|
|
77
|
+
before: AdminResourceFact,
|
|
78
|
+
after: AdminResourceFact,
|
|
79
|
+
): readonly ManifestChange[] {
|
|
80
|
+
const changes: ManifestChange[] = [
|
|
81
|
+
...diffNamedSet(`${path}.filters`, before.filters, after.filters),
|
|
82
|
+
...diffNamedSet(`${path}.sorts`, before.sorts, after.sorts),
|
|
83
|
+
...diffNamedSet(
|
|
84
|
+
`${path}.scopes`,
|
|
85
|
+
before.scopes.map((scope) => scope.name),
|
|
86
|
+
after.scopes.map((scope) => scope.name),
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
const next = index(after.scopes, (scope) => scope.name);
|
|
90
|
+
for (const scope of before.scopes) {
|
|
91
|
+
const now = next.get(scope.name);
|
|
92
|
+
if (now === undefined) continue;
|
|
93
|
+
if (scope.default !== now.default) {
|
|
94
|
+
changes.push({
|
|
95
|
+
kind: 'breaking',
|
|
96
|
+
path: `${path}.scopes.${scope.name}.default`,
|
|
97
|
+
detail: `default ${String(scope.default)} -> ${String(now.default)}; a bare list URL reads other rows`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (scope.count !== now.count) {
|
|
101
|
+
changes.push({
|
|
102
|
+
kind: 'internal',
|
|
103
|
+
path: `${path}.scopes.${scope.name}.count`,
|
|
104
|
+
detail: `count ${String(scope.count)} -> ${String(now.count)}`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
changes.push(...diffActions(`${path}.actions`, before.actions, after.actions));
|
|
109
|
+
// A related card is the related resource's own list, reachable on its own: neither direction
|
|
110
|
+
// refuses a caller.
|
|
111
|
+
changes.push(...diffNamedSet(`${path}.related`, before.related, after.related, 'internal'));
|
|
112
|
+
for (const key of ['sections', 'formGroups'] as const) {
|
|
113
|
+
if (layout(before[key]) !== layout(after[key])) {
|
|
114
|
+
changes.push({ kind: 'internal', path: `${path}.${key}`, detail: `${key} rearranged` });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (before.rowScoped !== after.rowScoped) {
|
|
118
|
+
changes.push({
|
|
119
|
+
// Appearing narrows what every actor sees; going away widens it — which breaks nobody, and
|
|
120
|
+
// is still a change a reviewer has to see.
|
|
121
|
+
kind: after.rowScoped ? 'breaking' : 'additive',
|
|
122
|
+
path: `${path}.rowScoped`,
|
|
123
|
+
detail: after.rowScoped ? 'rows are now scoped per actor' : 'rows are no longer scoped',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return changes;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function diffAdmin(before: AdminFact, after: AdminFact): readonly ManifestChange[] {
|
|
130
|
+
const root = `admin.${before.basePath}`;
|
|
131
|
+
const changes: ManifestChange[] = [];
|
|
132
|
+
if (before.audit !== after.audit) {
|
|
133
|
+
changes.push({
|
|
134
|
+
kind: 'internal',
|
|
135
|
+
path: `${root}.audit`,
|
|
136
|
+
detail: `audit log ${before.audit} -> ${after.audit}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const resources = index(after.resources, (resource) => resource.entity);
|
|
141
|
+
for (const resource of before.resources) {
|
|
142
|
+
const path = `${root}.resources.${resource.entity}`;
|
|
143
|
+
const next = resources.get(resource.entity);
|
|
144
|
+
if (next === undefined) changes.push({ kind: 'breaking', path, detail: 'resource removed' });
|
|
145
|
+
else changes.push(...diffResource(path, resource, next));
|
|
146
|
+
}
|
|
147
|
+
const had = index(before.resources, (resource) => resource.entity);
|
|
148
|
+
for (const resource of after.resources) {
|
|
149
|
+
if (!had.has(resource.entity)) {
|
|
150
|
+
changes.push({
|
|
151
|
+
kind: 'additive',
|
|
152
|
+
path: `${root}.resources.${resource.entity}`,
|
|
153
|
+
detail: 'resource added',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const routes = index(after.routes, (route) => route.url);
|
|
159
|
+
for (const route of before.routes) {
|
|
160
|
+
const path = `${root}.routes.${route.url}`;
|
|
161
|
+
const next = routes.get(route.url);
|
|
162
|
+
if (next === undefined) changes.push({ kind: 'breaking', path, detail: 'route removed' });
|
|
163
|
+
else changes.push(...diffPermissions(path, route, next));
|
|
164
|
+
}
|
|
165
|
+
const served = index(before.routes, (route) => route.url);
|
|
166
|
+
for (const route of after.routes) {
|
|
167
|
+
if (!served.has(route.url)) {
|
|
168
|
+
changes.push({
|
|
169
|
+
kind: 'additive',
|
|
170
|
+
path: `${root}.routes.${route.url}`,
|
|
171
|
+
detail: 'route added',
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return changes;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function diffAdmins(
|
|
179
|
+
before: readonly AdminFact[],
|
|
180
|
+
after: readonly AdminFact[],
|
|
181
|
+
): readonly ManifestChange[] {
|
|
182
|
+
const changes: ManifestChange[] = [];
|
|
183
|
+
const next = index(after, (admin) => admin.basePath);
|
|
184
|
+
const had = index(before, (admin) => admin.basePath);
|
|
185
|
+
for (const admin of before) {
|
|
186
|
+
const now = next.get(admin.basePath);
|
|
187
|
+
if (now === undefined) {
|
|
188
|
+
changes.push({ kind: 'breaking', path: `admin.${admin.basePath}`, detail: 'admin removed' });
|
|
189
|
+
} else {
|
|
190
|
+
changes.push(...diffAdmin(admin, now));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const admin of after) {
|
|
194
|
+
if (!had.has(admin.basePath)) {
|
|
195
|
+
changes.push({ kind: 'additive', path: `admin.${admin.basePath}`, detail: 'admin added' });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return changes;
|
|
199
|
+
}
|
package/src/diff-entities.ts
CHANGED
|
@@ -108,8 +108,10 @@ function diffColumns(
|
|
|
108
108
|
: { kind: 'breaking', path: `${at}.nullable`, detail: 'became NOT NULL' },
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
|
+
changes.push(...diffDefault(`${at}.hasDefault`, column.hasDefault === true, next));
|
|
111
112
|
changes.push(...diffKey(at, 'primaryKey', keyOf(column), keyOf(next)));
|
|
112
113
|
changes.push(...diffKey(at, 'references', column.references, next.references));
|
|
114
|
+
changes.push(...diffSealed(`${at}.sealed`, column.sealed, next.sealed));
|
|
113
115
|
}
|
|
114
116
|
|
|
115
117
|
const beforeColumns = index(before.columns, (c) => c.name);
|
|
@@ -132,6 +134,84 @@ function diffColumns(
|
|
|
132
134
|
return changes;
|
|
133
135
|
}
|
|
134
136
|
|
|
137
|
+
/**
|
|
138
|
+
* A declared default on a column that was already there. It was read for an ADDED column only, so
|
|
139
|
+
* a NOT NULL column losing its default reported nothing but `buildId` — while every writer that
|
|
140
|
+
* omits the column is refused from then on. Dropped from a nullable column, an omitted value
|
|
141
|
+
* becomes NULL instead: nobody is refused, but the stored meaning moved. Gaining one only widens.
|
|
142
|
+
*/
|
|
143
|
+
function diffDefault(
|
|
144
|
+
path: string,
|
|
145
|
+
had: boolean,
|
|
146
|
+
next: EntityFact['columns'][number],
|
|
147
|
+
): readonly ManifestChange[] {
|
|
148
|
+
const has = next.hasDefault === true;
|
|
149
|
+
if (had === has) return [];
|
|
150
|
+
if (has) return [{ kind: 'additive', path, detail: 'default added' }];
|
|
151
|
+
return [
|
|
152
|
+
next.nullable
|
|
153
|
+
? { kind: 'internal', path, detail: 'default dropped; an omitted value is now NULL' }
|
|
154
|
+
: { kind: 'breaking', path, detail: 'default dropped; a writer that omits it is refused' },
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* How a column is sealed is a WIRE fact as much as a storage one: `.sealed()` emits no DDL and
|
|
160
|
+
* leaves the column's type alone, so nothing else in the file moves — while the field leaves every
|
|
161
|
+
* action output, query row, record and live frame the app sends. Absence is a statement here, not
|
|
162
|
+
* missing evidence: the field is written only for a sealed column, and a manifest older than the
|
|
163
|
+
* feature had none.
|
|
164
|
+
*
|
|
165
|
+
* Three of the four moves break a consumer. Sealing removes a field every client read. Unsealing
|
|
166
|
+
* puts it back AND leaves every stored value a ciphertext no read opens any more. `lookup` to
|
|
167
|
+
* opaque refuses the equality filters and the unique rule that were legal. Opaque to `lookup` is
|
|
168
|
+
* the one that only widens — reported all the same, because equal values now store equal strings,
|
|
169
|
+
* and rows written before are found only once they are re-sealed.
|
|
170
|
+
*/
|
|
171
|
+
function diffSealed(
|
|
172
|
+
path: string,
|
|
173
|
+
before: ColumnFact['sealed'],
|
|
174
|
+
after: ColumnFact['sealed'],
|
|
175
|
+
): readonly ManifestChange[] {
|
|
176
|
+
if (before === after) return [];
|
|
177
|
+
if (before === undefined) {
|
|
178
|
+
return [
|
|
179
|
+
{
|
|
180
|
+
kind: 'breaking',
|
|
181
|
+
path,
|
|
182
|
+
detail: `became sealed (${after}); the field is removed from every output`,
|
|
183
|
+
},
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
if (after === undefined) {
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
kind: 'breaking',
|
|
190
|
+
path,
|
|
191
|
+
detail:
|
|
192
|
+
'no longer sealed; stored values stay ciphertext until rewritten, and the field joins every output',
|
|
193
|
+
},
|
|
194
|
+
];
|
|
195
|
+
}
|
|
196
|
+
return after === 'lookup'
|
|
197
|
+
? [
|
|
198
|
+
{
|
|
199
|
+
kind: 'additive',
|
|
200
|
+
path,
|
|
201
|
+
detail:
|
|
202
|
+
'sealed opaque -> lookup; equal values now store equal strings, and existing rows match only after a re-seal',
|
|
203
|
+
},
|
|
204
|
+
]
|
|
205
|
+
: [
|
|
206
|
+
{
|
|
207
|
+
kind: 'breaking',
|
|
208
|
+
path,
|
|
209
|
+
detail:
|
|
210
|
+
'sealed lookup -> opaque; an equality filter or a unique rule on it is now refused',
|
|
211
|
+
},
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
|
|
135
215
|
/** `primaryKey` is optional in the file, so absence is the same statement as `false`. */
|
|
136
216
|
const keyOf = (column: ColumnFact): string => (column.primaryKey === true ? 'yes' : 'no');
|
|
137
217
|
|
package/src/diff-fixtures.ts
CHANGED
package/src/diff-operations.ts
CHANGED
|
@@ -117,7 +117,13 @@ export function diffQueries(
|
|
|
117
117
|
changes.push({ kind: 'breaking', path, detail: 'query removed' });
|
|
118
118
|
continue;
|
|
119
119
|
}
|
|
120
|
-
|
|
120
|
+
// Both sides or neither: a manifest committed before queries published their input carries
|
|
121
|
+
// none, and a side with nothing on it is no evidence of a change (`diff-routes.ts`'s rule).
|
|
122
|
+
if (
|
|
123
|
+
query.input !== undefined &&
|
|
124
|
+
next.input !== undefined &&
|
|
125
|
+
canonicalJson(query.input) !== canonicalJson(next.input)
|
|
126
|
+
) {
|
|
121
127
|
changes.push({ kind: 'breaking', path: `${path}.input`, detail: 'input schema changed' });
|
|
122
128
|
}
|
|
123
129
|
if (query.policy !== next.policy) {
|
package/src/diff-work.ts
CHANGED
|
@@ -42,6 +42,22 @@ export function diffJobs(
|
|
|
42
42
|
),
|
|
43
43
|
);
|
|
44
44
|
changes.push(...diffRetry(path, job, next));
|
|
45
|
+
// A cap decides how much runs at once and what a claim over it does; neither is the shape an
|
|
46
|
+
// enqueuer or a queued payload depends on. Declared, removed and edited alike.
|
|
47
|
+
if (canonicalJson(job.concurrency ?? null) !== canonicalJson(next.concurrency ?? null)) {
|
|
48
|
+
changes.push({
|
|
49
|
+
kind: 'internal',
|
|
50
|
+
path: `${path}.concurrency`,
|
|
51
|
+
detail: 'concurrency changed; runs already in flight keep the slots they hold',
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
if ((job.onSettled ?? false) !== (next.onSettled ?? false)) {
|
|
55
|
+
changes.push({
|
|
56
|
+
kind: 'internal',
|
|
57
|
+
path: `${path}.onSettled`,
|
|
58
|
+
detail: next.onSettled === true ? 'onSettled hook declared' : 'onSettled hook removed',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
45
61
|
if (canonicalJson(job.steps) !== canonicalJson(next.steps)) {
|
|
46
62
|
changes.push({
|
|
47
63
|
kind: 'internal',
|
package/src/diff.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// `[{ kind: 'internal', path: 'buildId' }]` and passed. `diff.test.ts` walks `ARRAY_SECTIONS` and
|
|
12
12
|
// fails on a section nothing here classifies.
|
|
13
13
|
|
|
14
|
+
import { diffAdmins } from './diff-admin';
|
|
14
15
|
import type { ManifestChange } from './diff-change';
|
|
15
16
|
import { diffNamedSet } from './diff-change';
|
|
16
17
|
import { diffChannels } from './diff-channels';
|
|
@@ -49,6 +50,8 @@ export function diffManifest(before: Manifest, after: Manifest): ManifestDiff {
|
|
|
49
50
|
changes.push(...diffQueries(before.queries, after.queries));
|
|
50
51
|
// `?? []`: a file written before channels were projected carries none, which is not a removal.
|
|
51
52
|
changes.push(...diffChannels(before.channels ?? [], after.channels ?? []));
|
|
53
|
+
// The same `?? []`, for the same reason: no `admin` key is a file older than the section.
|
|
54
|
+
changes.push(...diffAdmins(before.admin ?? [], after.admin ?? []));
|
|
52
55
|
changes.push(...diffRoutes(before.routes, after.routes));
|
|
53
56
|
changes.push(...diffJobs(before.jobs, after.jobs));
|
|
54
57
|
changes.push(...diffTasks(before.tasks, after.tasks));
|
package/src/emit.ts
CHANGED
package/src/errors.ts
CHANGED
|
@@ -8,6 +8,7 @@ export const MANIFEST_ERROR_CODES = [
|
|
|
8
8
|
'X_MANIFEST_BREAKING',
|
|
9
9
|
'X_AGENTS_MD_MISSING',
|
|
10
10
|
'X_AGENTS_MD_TOO_LARGE',
|
|
11
|
+
'X_MANIFEST_FACT_INVALID',
|
|
11
12
|
] as const;
|
|
12
13
|
|
|
13
14
|
export type ManifestErrorCode = (typeof MANIFEST_ERROR_CODES)[number];
|
|
@@ -17,6 +18,7 @@ export const MANIFEST_ERROR_TITLES: Readonly<Record<ManifestErrorCode, string>>
|
|
|
17
18
|
X_MANIFEST_BREAKING: 'a published contract was removed or narrowed',
|
|
18
19
|
X_AGENTS_MD_MISSING: 'no AGENTS.md',
|
|
19
20
|
X_AGENTS_MD_TOO_LARGE: 'AGENTS.md grew past its cap',
|
|
21
|
+
X_MANIFEST_FACT_INVALID: 'a manifest fact is a number JSON cannot write back',
|
|
20
22
|
};
|
|
21
23
|
|
|
22
24
|
// Titles must be registered for format() to render the contract's first line. Every code above is
|
|
@@ -116,6 +118,26 @@ export class AgentsMdTooLargeError extends UltimateError {
|
|
|
116
118
|
}
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
/**
|
|
122
|
+
* A number in the manifest body that JSON cannot write back as itself. `canonicalJson` hashes `NaN`,
|
|
123
|
+
* `Infinity` and `-0` as those tokens and `JSON.stringify` writes them as `null` and `0`, so a
|
|
124
|
+
* manifest built from one fails its own `verifyBuildId` the moment it is read back, and the
|
|
125
|
+
* committed file reads as drift on every build. `path` is where in the body it sits.
|
|
126
|
+
*/
|
|
127
|
+
export class ManifestFactInvalidError extends UltimateError {
|
|
128
|
+
constructor(input: { path: string; value: string }) {
|
|
129
|
+
super({
|
|
130
|
+
code: 'X_MANIFEST_FACT_INVALID',
|
|
131
|
+
cause: `the manifest fact at ${input.path} is ${input.value}, which x.manifest.json would write as ${input.value === '-0' ? '0' : 'null'} — the file could never match its own buildId`,
|
|
132
|
+
fix:
|
|
133
|
+
input.value === '-0'
|
|
134
|
+
? `set the declaration that publishes ${input.path} to 0, then run x manifest`
|
|
135
|
+
: `set the declaration that publishes ${input.path} to a finite number, then run x manifest`,
|
|
136
|
+
meta: { path: input.path, value: input.value },
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
119
141
|
/** First three items plus a count — a message with 400 entries is a message nobody reads. */
|
|
120
142
|
function summarize(items: readonly string[]): string {
|
|
121
143
|
if (items.length <= 3) return items.join('; ');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Single responsibility: refuse a manifest number JSON cannot write back as itself. The body is
|
|
2
|
+
// hashed by `canonicalJson`, which keeps `NaN`, `Infinity` and `-0` distinct, and written by
|
|
3
|
+
// `JSON.stringify`, which does not — so one such fact made a manifest that failed its own buildId.
|
|
4
|
+
|
|
5
|
+
import { ManifestFactInvalidError } from './errors';
|
|
6
|
+
|
|
7
|
+
/** Depth-first, in key order, so the refusal names the FIRST offending fact the file would hold. */
|
|
8
|
+
export function assertFiniteFacts(value: unknown, path: string): void {
|
|
9
|
+
if (typeof value === 'number') {
|
|
10
|
+
if (!Number.isFinite(value) || Object.is(value, -0)) {
|
|
11
|
+
throw new ManifestFactInvalidError({
|
|
12
|
+
path,
|
|
13
|
+
value: Object.is(value, -0) ? '-0' : String(value),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (Array.isArray(value)) {
|
|
19
|
+
value.forEach((item: unknown, index) => {
|
|
20
|
+
assertFiniteFacts(item, `${path}[${String(index)}]`);
|
|
21
|
+
});
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (value === null || typeof value !== 'object') return;
|
|
25
|
+
for (const [key, item] of Object.entries(value)) {
|
|
26
|
+
assertFiniteFacts(item, path === '' ? key : `${path}.${key}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -39,9 +39,14 @@ export {
|
|
|
39
39
|
MANIFEST_ERROR_TITLES,
|
|
40
40
|
ManifestBreakingError,
|
|
41
41
|
ManifestDriftError,
|
|
42
|
+
ManifestFactInvalidError,
|
|
42
43
|
} from './errors';
|
|
43
44
|
export type {
|
|
44
45
|
ActionFact,
|
|
46
|
+
AdminFact,
|
|
47
|
+
AdminResourceFact,
|
|
48
|
+
AdminRouteFact,
|
|
49
|
+
AdminScopeFact,
|
|
45
50
|
ChannelFact,
|
|
46
51
|
ColumnFact,
|
|
47
52
|
EntityFact,
|
package/src/schema.ts
CHANGED
|
@@ -36,11 +36,82 @@ export interface RouteFact {
|
|
|
36
36
|
readonly offline?: OfflineStrategy;
|
|
37
37
|
readonly hydrate?: HydrateStrategy;
|
|
38
38
|
readonly revalidateTags?: readonly string[];
|
|
39
|
-
readonly budget?: { readonly js?: string
|
|
39
|
+
readonly budget?: { readonly js?: string };
|
|
40
40
|
/** Which surface the route lives in — `site` may never import from `app`. */
|
|
41
41
|
readonly surface?: 'site' | 'app' | 'api';
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** One scope of an admin list: a tab, by name. */
|
|
45
|
+
export interface AdminScopeFact {
|
|
46
|
+
readonly name: string;
|
|
47
|
+
/** What a bare list URL reads. At most one per resource. */
|
|
48
|
+
readonly default: boolean;
|
|
49
|
+
/** The tab shows a row count — one extra query per list page. */
|
|
50
|
+
readonly count: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One titled group of an admin detail page or form. `title` is an i18n key; `null` untitled. */
|
|
54
|
+
export interface AdminSectionFact {
|
|
55
|
+
readonly title: string | null;
|
|
56
|
+
readonly fields: readonly string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* One admin action: its button, its batch bar entry and its ONE MCP tool. `when` narrows which rows
|
|
61
|
+
* it applies to; `batch` puts it in the bar and gives the tool `ids`; past `threshold` rows a batch
|
|
62
|
+
* is queued as `admin.batch` jobs instead of run in the request.
|
|
63
|
+
*/
|
|
64
|
+
export interface AdminActionFact {
|
|
65
|
+
readonly name: string;
|
|
66
|
+
readonly permission: string;
|
|
67
|
+
readonly destructive: boolean;
|
|
68
|
+
readonly input: boolean;
|
|
69
|
+
readonly when: boolean;
|
|
70
|
+
readonly batch: boolean;
|
|
71
|
+
readonly threshold: number | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** One resource of a generated admin: what its list answers, and whether a row scope narrows it. */
|
|
75
|
+
export interface AdminResourceFact {
|
|
76
|
+
readonly entity: string;
|
|
77
|
+
/** Mount-relative: `/posts`. */
|
|
78
|
+
readonly path: string;
|
|
79
|
+
/** The fields a list URL's `f.<field>` and the MCP list tool's `where` may name, in bar order. */
|
|
80
|
+
readonly filters: readonly string[];
|
|
81
|
+
/** The fields `?sort=` may name. */
|
|
82
|
+
readonly sorts: readonly string[];
|
|
83
|
+
/** Tab order. */
|
|
84
|
+
readonly scopes: readonly AdminScopeFact[];
|
|
85
|
+
/** `rows` is declared: every read of the resource is narrowed per actor. */
|
|
86
|
+
readonly rowScoped: boolean;
|
|
87
|
+
/** The detail page's groups, drawing order; undeclared fields are the last, default one. */
|
|
88
|
+
readonly sections: readonly AdminSectionFact[];
|
|
89
|
+
/** The create and edit form's groups, the same way. */
|
|
90
|
+
readonly formGroups: readonly AdminSectionFact[];
|
|
91
|
+
/** `hasMany` relations drawn on the detail page as the related resource's own list. */
|
|
92
|
+
readonly related: readonly string[];
|
|
93
|
+
/** Declaration order — the order the buttons are drawn in. */
|
|
94
|
+
readonly actions: readonly AdminActionFact[];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One route `defineAdmin()` mounts — no page file declares it. */
|
|
98
|
+
export interface AdminRouteFact {
|
|
99
|
+
readonly url: string;
|
|
100
|
+
readonly view: string;
|
|
101
|
+
readonly entity: string | null;
|
|
102
|
+
/** Every permission the screen decides on, coarse gate first. A pair, not a set. */
|
|
103
|
+
readonly permissions: readonly string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One generated admin, as `defineAdmin()` derived it. */
|
|
107
|
+
export interface AdminFact {
|
|
108
|
+
readonly basePath: string;
|
|
109
|
+
/** The audit log the admin writes: `memory` forgets at every restart; `postgres` is the record. */
|
|
110
|
+
readonly audit: string;
|
|
111
|
+
readonly resources: readonly AdminResourceFact[];
|
|
112
|
+
readonly routes: readonly AdminRouteFact[];
|
|
113
|
+
}
|
|
114
|
+
|
|
44
115
|
export interface ColumnFact {
|
|
45
116
|
readonly name: string;
|
|
46
117
|
readonly type: string;
|
|
@@ -53,6 +124,13 @@ export interface ColumnFact {
|
|
|
53
124
|
* is additive — every existing row takes the default — and was classed breaking without this.
|
|
54
125
|
*/
|
|
55
126
|
readonly hasDefault?: boolean;
|
|
127
|
+
/**
|
|
128
|
+
* The column is `.sealed()`: stored as ciphertext, absent from every output. `'lookup'` is the
|
|
129
|
+
* deterministic form, matchable by equality. Written only when sealed, so a column that is not
|
|
130
|
+
* reads as it always did — and an agent reading the manifest can tell a field it will never be
|
|
131
|
+
* sent from one that is merely missing.
|
|
132
|
+
*/
|
|
133
|
+
readonly sealed?: 'opaque' | 'lookup';
|
|
56
134
|
}
|
|
57
135
|
|
|
58
136
|
export interface EntityFact {
|
|
@@ -95,7 +173,11 @@ export interface ActionFact {
|
|
|
95
173
|
|
|
96
174
|
export interface QueryFact {
|
|
97
175
|
readonly name: string;
|
|
98
|
-
/**
|
|
176
|
+
/**
|
|
177
|
+
* The input's JSON Schema, projected from the query handle as an action's is. Optional only for
|
|
178
|
+
* a manifest committed before queries published it; `diffQueries` reads an absent side as no
|
|
179
|
+
* evidence rather than as a changed schema.
|
|
180
|
+
*/
|
|
99
181
|
readonly input?: JsonValue;
|
|
100
182
|
/** The policy's DISPLAY label — see `ActionFact.policy`, and read `permissions` to match on. */
|
|
101
183
|
readonly policy: string | null;
|
|
@@ -140,6 +222,19 @@ export interface JobFact {
|
|
|
140
222
|
readonly queue: string;
|
|
141
223
|
readonly retry: { readonly attempts: number; readonly backoff: string };
|
|
142
224
|
readonly steps: readonly string[];
|
|
225
|
+
/**
|
|
226
|
+
* The job's fleet-wide cap, present only when it declares one. `keyed: true` says `limit` holds
|
|
227
|
+
* per `concurrency.key(input)` — "one run per account" — rather than for the whole job, and
|
|
228
|
+
* `whenBusy` is what a claim over it does (`null` for a plain number, which always waits).
|
|
229
|
+
* Optional for `channels`' reason: a manifest written before this existed is "no cap known".
|
|
230
|
+
*/
|
|
231
|
+
readonly concurrency?: {
|
|
232
|
+
readonly limit: number;
|
|
233
|
+
readonly keyed: boolean;
|
|
234
|
+
readonly whenBusy: string | null;
|
|
235
|
+
};
|
|
236
|
+
/** Present, and `true`, only when the job declares an `onSettled` hook. */
|
|
237
|
+
readonly onSettled?: true;
|
|
143
238
|
}
|
|
144
239
|
|
|
145
240
|
export interface TaskFact {
|
|
@@ -185,6 +280,12 @@ export interface Manifest {
|
|
|
185
280
|
* is "no channels", never an unreadable manifest. `buildManifest` always writes it.
|
|
186
281
|
*/
|
|
187
282
|
readonly channels?: readonly ChannelFact[];
|
|
283
|
+
/**
|
|
284
|
+
* The generated admins the app declares, each with its resources and mounted routes. Optional to
|
|
285
|
+
* a READER only, exactly as `channels` is: a file written before admins were projected has none.
|
|
286
|
+
* `buildManifest` always writes it — `[]` for an app with no admin.
|
|
287
|
+
*/
|
|
288
|
+
readonly admin?: readonly AdminFact[];
|
|
188
289
|
readonly jobs: readonly JobFact[];
|
|
189
290
|
readonly tasks: readonly TaskFact[];
|
|
190
291
|
readonly policies: readonly PolicyFact[];
|
|
@@ -243,6 +344,7 @@ export function isManifest(value: unknown): value is Manifest {
|
|
|
243
344
|
for (const section of ARRAY_SECTIONS) if (!Array.isArray(m[section])) return false;
|
|
244
345
|
// Absent is a pre-channel file and readable; present and not an array is a damaged one.
|
|
245
346
|
if (m['channels'] !== undefined && !Array.isArray(m['channels'])) return false;
|
|
347
|
+
if (m['admin'] !== undefined && !Array.isArray(m['admin'])) return false;
|
|
246
348
|
return isAppIdentity(m['app']);
|
|
247
349
|
}
|
|
248
350
|
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// The admins this process declared, as manifest facts. `@ultimat3/admin` is a tier above this
|
|
2
|
+
// package and may not be imported, so its mount registry is read off the global symbol registry —
|
|
3
|
+
// the same seam `@ultimat3/cli` mounts the screens through — and each admin is asked to describe
|
|
4
|
+
// ITSELF. What comes back is `unknown` and is read field by field: a description this build does
|
|
5
|
+
// not understand is skipped whole, never half-published.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AdminActionFact,
|
|
9
|
+
AdminFact,
|
|
10
|
+
AdminResourceFact,
|
|
11
|
+
AdminRouteFact,
|
|
12
|
+
AdminScopeFact,
|
|
13
|
+
AdminSectionFact,
|
|
14
|
+
} from './schema';
|
|
15
|
+
|
|
16
|
+
/** `@ultimat3/admin`'s `ADMIN_MOUNTS`. Restated — the package cannot be imported from here. */
|
|
17
|
+
export const ADMIN_MOUNTS_KEY: symbol = Symbol.for('ultimate.admin.mounts');
|
|
18
|
+
|
|
19
|
+
type Bag = Readonly<Record<string, unknown>>;
|
|
20
|
+
|
|
21
|
+
const bagOf = (value: unknown): Bag | undefined =>
|
|
22
|
+
typeof value === 'object' && value !== null ? (value as Bag) : undefined;
|
|
23
|
+
|
|
24
|
+
const text = (value: unknown): string | undefined =>
|
|
25
|
+
typeof value === 'string' ? value : undefined;
|
|
26
|
+
|
|
27
|
+
const texts = (value: unknown): readonly string[] | undefined =>
|
|
28
|
+
Array.isArray(value) && value.every((one) => typeof one === 'string')
|
|
29
|
+
? (value as readonly string[])
|
|
30
|
+
: undefined;
|
|
31
|
+
|
|
32
|
+
/** Every element read, or the whole list refused: a half-read list is a manifest that lies. */
|
|
33
|
+
const each = <T>(
|
|
34
|
+
value: unknown,
|
|
35
|
+
read: (one: unknown) => T | undefined,
|
|
36
|
+
): readonly T[] | undefined => {
|
|
37
|
+
if (!Array.isArray(value)) return undefined;
|
|
38
|
+
const out: T[] = [];
|
|
39
|
+
for (const one of value) {
|
|
40
|
+
const fact = read(one);
|
|
41
|
+
if (fact === undefined) return undefined;
|
|
42
|
+
out.push(fact);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const scopeOf = (value: unknown): AdminScopeFact | undefined => {
|
|
48
|
+
const bag = bagOf(value);
|
|
49
|
+
const name = text(bag?.['name']);
|
|
50
|
+
if (bag === undefined || name === undefined) return undefined;
|
|
51
|
+
return { name, default: bag['default'] === true, count: bag['count'] === true };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const sectionOf = (value: unknown): AdminSectionFact | undefined => {
|
|
55
|
+
const bag = bagOf(value);
|
|
56
|
+
const fields = texts(bag?.['fields']);
|
|
57
|
+
const title = bag?.['title'];
|
|
58
|
+
if (bag === undefined || fields === undefined) return undefined;
|
|
59
|
+
if (title !== null && typeof title !== 'string') return undefined;
|
|
60
|
+
return { title, fields };
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const actionOf = (value: unknown): AdminActionFact | undefined => {
|
|
64
|
+
const bag = bagOf(value);
|
|
65
|
+
const name = text(bag?.['name']);
|
|
66
|
+
const permission = text(bag?.['permission']);
|
|
67
|
+
const threshold = bag?.['threshold'];
|
|
68
|
+
if (bag === undefined || name === undefined || permission === undefined) return undefined;
|
|
69
|
+
if (threshold !== null && typeof threshold !== 'number') return undefined;
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
permission,
|
|
73
|
+
destructive: bag['destructive'] === true,
|
|
74
|
+
input: bag['input'] === true,
|
|
75
|
+
when: bag['when'] === true,
|
|
76
|
+
batch: bag['batch'] === true,
|
|
77
|
+
threshold,
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const resourceOf = (value: unknown): AdminResourceFact | undefined => {
|
|
82
|
+
const bag = bagOf(value);
|
|
83
|
+
const entity = text(bag?.['entity']);
|
|
84
|
+
const path = text(bag?.['path']);
|
|
85
|
+
const filters = texts(bag?.['filters']);
|
|
86
|
+
const sorts = texts(bag?.['sorts']);
|
|
87
|
+
const scopes = each(bag?.['scopes'], scopeOf);
|
|
88
|
+
const sections = each(bag?.['sections'], sectionOf);
|
|
89
|
+
const formGroups = each(bag?.['formGroups'], sectionOf);
|
|
90
|
+
const related = texts(bag?.['related']);
|
|
91
|
+
const actions = each(bag?.['actions'], actionOf);
|
|
92
|
+
if (
|
|
93
|
+
bag === undefined ||
|
|
94
|
+
entity === undefined ||
|
|
95
|
+
path === undefined ||
|
|
96
|
+
filters === undefined ||
|
|
97
|
+
sorts === undefined ||
|
|
98
|
+
scopes === undefined ||
|
|
99
|
+
sections === undefined ||
|
|
100
|
+
formGroups === undefined ||
|
|
101
|
+
related === undefined ||
|
|
102
|
+
actions === undefined
|
|
103
|
+
) {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
entity,
|
|
108
|
+
path,
|
|
109
|
+
filters,
|
|
110
|
+
sorts,
|
|
111
|
+
scopes,
|
|
112
|
+
rowScoped: bag['rowScoped'] === true,
|
|
113
|
+
sections,
|
|
114
|
+
formGroups,
|
|
115
|
+
related,
|
|
116
|
+
actions,
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const routeOf = (value: unknown): AdminRouteFact | undefined => {
|
|
121
|
+
const bag = bagOf(value);
|
|
122
|
+
const url = text(bag?.['url']);
|
|
123
|
+
const view = text(bag?.['view']);
|
|
124
|
+
const permissions = texts(bag?.['permissions']);
|
|
125
|
+
if (bag === undefined || url === undefined || view === undefined || permissions === undefined) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
return { url, view, entity: text(bag['entity']) ?? null, permissions };
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const adminOf = (mount: unknown): AdminFact | undefined => {
|
|
132
|
+
const describe = bagOf(mount)?.['describe'];
|
|
133
|
+
if (typeof describe !== 'function') return undefined;
|
|
134
|
+
const described = bagOf((describe as () => unknown).call(mount));
|
|
135
|
+
const basePath = text(described?.['basePath']);
|
|
136
|
+
const audit = text(described?.['audit']);
|
|
137
|
+
const resources = each(described?.['resources'], resourceOf);
|
|
138
|
+
const routes = each(described?.['routes'], routeOf);
|
|
139
|
+
if (
|
|
140
|
+
basePath === undefined ||
|
|
141
|
+
audit === undefined ||
|
|
142
|
+
resources === undefined ||
|
|
143
|
+
routes === undefined
|
|
144
|
+
) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return { basePath, audit, resources, routes };
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Every admin `defineAdmin()` declared in this process. Empty for an app that declares none. */
|
|
151
|
+
export function declaredAdmins(): readonly AdminFact[] {
|
|
152
|
+
const held = (globalThis as { [key: symbol]: unknown })[ADMIN_MOUNTS_KEY];
|
|
153
|
+
if (!(held instanceof Map)) return [];
|
|
154
|
+
return [...held.values()].flatMap((mount) => {
|
|
155
|
+
const fact = adminOf(mount);
|
|
156
|
+
return fact === undefined ? [] : [fact];
|
|
157
|
+
});
|
|
158
|
+
}
|
package/src/sources.ts
CHANGED
|
@@ -5,13 +5,21 @@
|
|
|
5
5
|
// a global registry. Routes and policies are supplied by the caller: the route table lives
|
|
6
6
|
// in `@ultimat3/render`, which is this same tier.
|
|
7
7
|
|
|
8
|
-
import { describeActions } from '@ultimat3/action';
|
|
9
|
-
import { describeEntities } from '@ultimat3/entity';
|
|
8
|
+
import { describeActions, jsonSchemaOf } from '@ultimat3/action';
|
|
9
|
+
import { describeEntities, registeredEntities, sealedFields } from '@ultimat3/entity';
|
|
10
10
|
import { describeJobs } from '@ultimat3/jobs';
|
|
11
|
-
import { describeQueries } from '@ultimat3/query';
|
|
11
|
+
import { describeQueries, getQuery } from '@ultimat3/query';
|
|
12
12
|
import { describeChannels } from '@ultimat3/realtime/server';
|
|
13
13
|
import type { ManifestSources } from './build';
|
|
14
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
AdminFact,
|
|
16
|
+
ErrorCodeFact,
|
|
17
|
+
JsonValue,
|
|
18
|
+
PolicyFact,
|
|
19
|
+
RouteFact,
|
|
20
|
+
TaskFact,
|
|
21
|
+
} from './schema';
|
|
22
|
+
import { declaredAdmins } from './sources-admin';
|
|
15
23
|
|
|
16
24
|
export interface FrameworkSourcesInput {
|
|
17
25
|
readonly app: { readonly name: string; readonly version: string };
|
|
@@ -23,6 +31,11 @@ export interface FrameworkSourcesInput {
|
|
|
23
31
|
readonly locales?: readonly string[];
|
|
24
32
|
/** Each package's `*_ERROR_CODES`, flattened by the CLI. */
|
|
25
33
|
readonly errorCodes?: readonly ErrorCodeFact[];
|
|
34
|
+
/**
|
|
35
|
+
* The app's generated admins. Omitted, they are read off the process's own declarations
|
|
36
|
+
* (`sources-admin.ts`) — the one section a caller does not have to inject.
|
|
37
|
+
*/
|
|
38
|
+
readonly admin?: readonly AdminFact[];
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
/**
|
|
@@ -36,7 +49,39 @@ export interface FrameworkSourcesInput {
|
|
|
36
49
|
*/
|
|
37
50
|
const asJson = (value: object): JsonValue => value as JsonValue;
|
|
38
51
|
|
|
52
|
+
/** `describeQueries` and `getQuery` read one registry, so the handle is always there. */
|
|
53
|
+
const queryInput = (name: string): { readonly input?: JsonValue } => {
|
|
54
|
+
const handle = getQuery(name);
|
|
55
|
+
return handle === undefined ? {} : { input: asJson(jsonSchemaOf(handle.input)) };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Absent stays absent: only a sealed column carries the field. */
|
|
59
|
+
const sealedFact = (
|
|
60
|
+
how: 'opaque' | 'lookup' | undefined,
|
|
61
|
+
): { readonly sealed?: 'opaque' | 'lookup' } => (how === undefined ? {} : { sealed: how });
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `<entity name>` -> `<physical column>` -> how it is sealed. Read off the declarations rather than
|
|
65
|
+
* `$describe()`, which is the DDL's projection: sealing changes no DDL, so it is not on it, and a
|
|
66
|
+
* column becoming sealed must not move the schema hash a migration is checked against.
|
|
67
|
+
*/
|
|
68
|
+
function sealedColumns(): ReadonlyMap<string, ReadonlyMap<string, 'opaque' | 'lookup'>> {
|
|
69
|
+
return new Map(
|
|
70
|
+
registeredEntities().map((entry) => [
|
|
71
|
+
entry.name,
|
|
72
|
+
new Map(
|
|
73
|
+
// `core` is absent on a hand-registered entry, which declares no columns to seal.
|
|
74
|
+
(entry.core === undefined ? [] : sealedFields(entry.core)).map((field) => [
|
|
75
|
+
field.column,
|
|
76
|
+
field.lookup ? ('lookup' as const) : ('opaque' as const),
|
|
77
|
+
]),
|
|
78
|
+
),
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
39
83
|
export function frameworkSources(input: FrameworkSourcesInput): ManifestSources {
|
|
84
|
+
const sealed = sealedColumns();
|
|
40
85
|
return {
|
|
41
86
|
app: input.app,
|
|
42
87
|
routes: input.routes ?? [],
|
|
@@ -44,6 +89,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
44
89
|
tasks: input.tasks ?? [],
|
|
45
90
|
locales: input.locales ?? [],
|
|
46
91
|
errorCodes: input.errorCodes ?? [],
|
|
92
|
+
admin: input.admin ?? declaredAdmins(),
|
|
47
93
|
// Projected field by field, never cast: the primitive registries own richer shapes
|
|
48
94
|
// than the manifest publishes, and a cast would silently rot when either side moves.
|
|
49
95
|
entities: describeEntities().map((entity) => ({
|
|
@@ -56,6 +102,7 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
56
102
|
primaryKey: column.primaryKey,
|
|
57
103
|
...(column.hasDefault ? { hasDefault: true } : {}),
|
|
58
104
|
...(column.references === null ? {} : { references: column.references }),
|
|
105
|
+
...sealedFact(sealed.get(entity.name)?.get(column.column)),
|
|
59
106
|
})),
|
|
60
107
|
invariants: entity.invariants.map((invariant) => invariant.name),
|
|
61
108
|
})),
|
|
@@ -80,6 +127,9 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
80
127
|
})),
|
|
81
128
|
queries: describeQueries().map((query) => ({
|
|
82
129
|
name: query.name,
|
|
130
|
+
// The handle's own schema, projected exactly as an action's is: the descriptor is
|
|
131
|
+
// schema-erased, and without this `diffQueries`' input compare never fired on a real app.
|
|
132
|
+
...queryInput(query.name),
|
|
83
133
|
policy: query.capability,
|
|
84
134
|
permissions: query.permissions,
|
|
85
135
|
live: query.live,
|
|
@@ -108,6 +158,19 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
108
158
|
// `run()` at execution time, so no static reader can know it. `x jobs show` reports the
|
|
109
159
|
// steps an actual run recorded.
|
|
110
160
|
steps: job.steps,
|
|
161
|
+
// Absent, never `null`, for a job with no cap: a row gains the key the day its job declares
|
|
162
|
+
// one, so adding this fact moved no committed manifest but the ones it is a fact about.
|
|
163
|
+
...(job.concurrency === null
|
|
164
|
+
? {}
|
|
165
|
+
: {
|
|
166
|
+
concurrency: {
|
|
167
|
+
limit: job.concurrency.limit,
|
|
168
|
+
keyed: job.concurrency.keyed,
|
|
169
|
+
whenBusy: job.concurrency.whenBusy,
|
|
170
|
+
},
|
|
171
|
+
}),
|
|
172
|
+
// The same rule: only a job that declares the hook carries the key.
|
|
173
|
+
...(job.onSettled ? { onSettled: true as const } : {}),
|
|
111
174
|
})),
|
|
112
175
|
};
|
|
113
176
|
}
|