@ultimat3/manifest 23.0.0 → 25.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 -3
- package/package.json +8 -8
- package/src/build.ts +10 -16
- package/src/diff-admin.ts +30 -0
- package/src/diff-entities.ts +22 -0
- package/src/diff-operations.ts +7 -1
- package/src/docs-scan.ts +8 -5
- package/src/emit.ts +3 -4
- package/src/errors.ts +22 -0
- package/src/finite-facts.ts +28 -0
- package/src/index.ts +2 -1
- package/src/schema.ts +13 -2
- package/src/sources-admin.ts +2 -0
- package/src/sources.ts +12 -3
- package/src/diff-fixtures.ts +0 -96
package/CLAUDE.md
CHANGED
|
@@ -44,7 +44,7 @@ by the CLI, not imported.
|
|
|
44
44
|
- Top-level key order in the file is fixed by `KEY_ORDER` in `emit.ts` — `as const satisfies
|
|
45
45
|
readonly (keyof Manifest)[]` AND walked by `emit.test.ts`, the treatment `ARRAY_SECTIONS` has,
|
|
46
46
|
because the annotation catches a key that is not on `Manifest` and only the walk catches one that
|
|
47
|
-
is MISSING. `manifestJson` writes those keys and no others while `
|
|
47
|
+
is MISSING. `manifestJson` writes those keys and no others while `fingerprint` hashes the whole
|
|
48
48
|
body, so a 14th field would go into the hash and be dropped from the file — after which
|
|
49
49
|
`assertNoDrift` convicts the committed manifest as HAND_EDITED, a correct refusal with the wrong
|
|
50
50
|
diagnosis, about a file nobody touched.
|
|
@@ -62,7 +62,10 @@ by the CLI, not imported.
|
|
|
62
62
|
they already carried — so the swap is observable only where the old form folded. **The published
|
|
63
63
|
document is `manifestJson` and is still `JSON.stringify` with a fixed key order**: an injective
|
|
64
64
|
form emits tokens JSON cannot parse, which is why `@ultimat3/action`'s `stableStringify` exists
|
|
65
|
-
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.
|
|
66
69
|
- Job `steps` keep declared order. Everything else sorts.
|
|
67
70
|
- `permissions` is derived, never a second declared list — and derived from each operation's own
|
|
68
71
|
`permissions`, **never from `policy`**. `policy` is a DISPLAY label: a composite renders as
|
|
@@ -136,6 +139,11 @@ by the CLI, not imported.
|
|
|
136
139
|
"no key", so a dropped one is classified. `surface`, `offline`, `hydrate`, `budget` and
|
|
137
140
|
`revalidateTags` absent means "this file predates the field", so `diffScalar` skips a side that
|
|
138
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.
|
|
139
147
|
- **`MANIFEST_VERSION` bumps only when a reader built for the old version would be WRONG** — a
|
|
140
148
|
field removed, retyped, or given a new meaning — never for one that is merely added. Two costs
|
|
141
149
|
make the reflex expensive: `isCompatible` is an equality check, so a bump rejects every
|
|
@@ -154,7 +162,7 @@ by the CLI, not imported.
|
|
|
154
162
|
- `diff.ts` reads `mcp.expose` through `isMcpExposed` from `@ultimat3/core`, on **both** sides.
|
|
155
163
|
`before` is a file parsed off disk, so an older or hand-trimmed manifest can carry an absent or
|
|
156
164
|
non-boolean value that `!==` would classify from; and the fact `sources.ts` publishes has to be
|
|
157
|
-
the answer `
|
|
165
|
+
the answer `@ultimat3/mcp`'s catalog gives, or the gate demands a major bump for a tool that never existed.
|
|
158
166
|
|
|
159
167
|
## Commands
|
|
160
168
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/manifest",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "25.0.0",
|
|
4
4
|
"description": "x.manifest.json: deterministic generated facts, contract diff, AGENTS.md budget",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -25,18 +25,18 @@
|
|
|
25
25
|
"LICENSE"
|
|
26
26
|
],
|
|
27
27
|
"engines": {
|
|
28
|
-
"bun": ">=1.4.
|
|
28
|
+
"bun": ">=1.4.2"
|
|
29
29
|
},
|
|
30
30
|
"scripts": {
|
|
31
31
|
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
32
32
|
"test": "bun test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@ultimat3/action": "
|
|
36
|
-
"@ultimat3/core": "
|
|
37
|
-
"@ultimat3/entity": "
|
|
38
|
-
"@ultimat3/jobs": "
|
|
39
|
-
"@ultimat3/query": "
|
|
40
|
-
"@ultimat3/realtime": "
|
|
35
|
+
"@ultimat3/action": "25.0.0",
|
|
36
|
+
"@ultimat3/core": "25.0.0",
|
|
37
|
+
"@ultimat3/entity": "25.0.0",
|
|
38
|
+
"@ultimat3/jobs": "25.0.0",
|
|
39
|
+
"@ultimat3/query": "25.0.0",
|
|
40
|
+
"@ultimat3/realtime": "25.0.0"
|
|
41
41
|
}
|
|
42
42
|
}
|
package/src/build.ts
CHANGED
|
@@ -16,7 +16,8 @@
|
|
|
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,
|
|
22
23
|
AdminFact,
|
|
@@ -96,21 +97,14 @@ export function buildManifest(sources: ManifestSources): Manifest {
|
|
|
96
97
|
errorCodes,
|
|
97
98
|
};
|
|
98
99
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
* This is a HASH, never the published document: `manifestJson` in `emit.ts` is what reaches disk,
|
|
108
|
-
* and it is `JSON.stringify` with a fixed key order for exactly that reason.
|
|
109
|
-
*/
|
|
110
|
-
export function contentHash(body: Omit<Manifest, 'buildId'>): string {
|
|
111
|
-
const hasher = new Bun.CryptoHasher('sha256');
|
|
112
|
-
hasher.update(canonicalJson(body));
|
|
113
|
-
return hasher.digest('hex').slice(0, 16);
|
|
100
|
+
// Before the hash: a fact the written file cannot hold must never get a buildId at all.
|
|
101
|
+
assertFiniteFacts(body, '');
|
|
102
|
+
// The build id is `@ultimat3/core`'s `fingerprint` of the body — SHA-256/16 over the same
|
|
103
|
+
// INJECTIVE `canonicalJson` the diff compares on, so a fact that changed cannot hash the same as
|
|
104
|
+
// the fact it replaced — and deliberately excludes `buildId` itself. A HASH, never the published
|
|
105
|
+
// document: `manifestJson` in `emit.ts` is what reaches disk, with a fixed key order. The
|
|
106
|
+
// `contentHash` alias this package exported for it left in 25.0.0 (plan 101, M3).
|
|
107
|
+
return { ...body, buildId: fingerprint(body) };
|
|
114
108
|
}
|
|
115
109
|
|
|
116
110
|
function sortBy<T>(items: readonly T[], key: (item: T) => string): readonly T[] {
|
package/src/diff-admin.ts
CHANGED
|
@@ -28,6 +28,35 @@ const flag = (
|
|
|
28
28
|
},
|
|
29
29
|
];
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The admin-level gate, as `adminPermissionForAction` (`@ultimat3/admin`) decides it: `destructive`
|
|
33
|
+
* and `matching` hold the write gate, and only then does `readonly` lower it. Restated, not
|
|
34
|
+
* imported: the admin is a tier above this package. `destructive` reports its own change (and its
|
|
35
|
+
* own `admin:destroy` gate), so this only says read or write.
|
|
36
|
+
*/
|
|
37
|
+
const gateOf = (action: AdminActionFact): 'admin:read' | 'admin:write' =>
|
|
38
|
+
action.readonly && !action.destructive && !action.matching ? 'admin:read' : 'admin:write';
|
|
39
|
+
|
|
40
|
+
/** `readonly` / `matching` flipping: a caller sees it only when the gate moves. */
|
|
41
|
+
function diffGate(at: string, before: AdminActionFact, after: AdminActionFact): ManifestChange[] {
|
|
42
|
+
const changes: ManifestChange[] = [];
|
|
43
|
+
const [was, is] = [gateOf(before), gateOf(after)];
|
|
44
|
+
if (was !== is) {
|
|
45
|
+
// Write → read lets an `admin:read` caller in; read → write refuses one it served.
|
|
46
|
+
const kind = is === 'admin:write' ? 'breaking' : 'additive';
|
|
47
|
+
changes.push({ kind, path: `${at}.gate`, detail: `admin gate ${was} -> ${is}` });
|
|
48
|
+
}
|
|
49
|
+
for (const key of ['readonly', 'matching'] as const) {
|
|
50
|
+
// An absent flag reads as `false` (`schema.ts`): a baseline written before the field existed
|
|
51
|
+
// is not a change against a build that states `false`.
|
|
52
|
+
const [had, has] = [before[key] === true, after[key] === true];
|
|
53
|
+
if (had === has) continue;
|
|
54
|
+
const detail = `${key} ${String(had)} -> ${String(has)}`;
|
|
55
|
+
changes.push({ kind: 'internal', path: `${at}.${key}`, detail });
|
|
56
|
+
}
|
|
57
|
+
return changes;
|
|
58
|
+
}
|
|
59
|
+
|
|
31
60
|
function diffActions(
|
|
32
61
|
path: string,
|
|
33
62
|
before: readonly AdminActionFact[],
|
|
@@ -58,6 +87,7 @@ function diffActions(
|
|
|
58
87
|
...flag(`${at}.input`, action.input, now.input, true, 'input schema'),
|
|
59
88
|
...flag(`${at}.destructive`, action.destructive, now.destructive, true, 'destructive'),
|
|
60
89
|
);
|
|
90
|
+
changes.push(...diffGate(at, action, now));
|
|
61
91
|
if (action.threshold !== now.threshold) {
|
|
62
92
|
changes.push({
|
|
63
93
|
kind: 'internal',
|
package/src/diff-entities.ts
CHANGED
|
@@ -108,6 +108,7 @@ 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));
|
|
113
114
|
changes.push(...diffSealed(`${at}.sealed`, column.sealed, next.sealed));
|
|
@@ -133,6 +134,27 @@ function diffColumns(
|
|
|
133
134
|
return changes;
|
|
134
135
|
}
|
|
135
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
|
+
|
|
136
158
|
/**
|
|
137
159
|
* How a column is sealed is a WIRE fact as much as a storage one: `.sealed()` emits no DDL and
|
|
138
160
|
* leaves the column's type alone, so nothing else in the file moves — while the field leaves every
|
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/docs-scan.ts
CHANGED
|
@@ -158,18 +158,21 @@ async function guideEntries(
|
|
|
158
158
|
// id — silently, which is the failure mode the search's own coverage floor exists to prevent,
|
|
159
159
|
// and a direct contradiction of `DocEntry.topic`'s promise to be unique within a package. No
|
|
160
160
|
// shipped guide collides today; an app's own package is one heading away from it. The suffix
|
|
161
|
-
// follows document order, so the id is still derived and still reproducible.
|
|
162
|
-
|
|
161
|
+
// follows document order, so the id is still derived and still reproducible. EVERY emitted id is
|
|
162
|
+
// recorded and a suffix climbs past a taken one: `## Retry`, `## Retry`, `## Retry 2` otherwise
|
|
163
|
+
// gave the second `Retry` and the real `Retry 2` the same `retry-2`.
|
|
164
|
+
const emitted = new Set<string>();
|
|
163
165
|
for (const file of GUIDE_FILES) {
|
|
164
166
|
const markdown = await read(join(dir, file));
|
|
165
167
|
if (markdown === undefined) continue;
|
|
166
168
|
const stem = basename(file, '.md');
|
|
167
169
|
for (const section of parseGuideSections(markdown)) {
|
|
168
170
|
const base = `${shortName(name)}.${stem}#${slug(section.heading)}`;
|
|
169
|
-
|
|
170
|
-
|
|
171
|
+
let topic = base;
|
|
172
|
+
for (let suffix = 2; emitted.has(topic); suffix += 1) topic = `${base}-${suffix}`;
|
|
173
|
+
emitted.add(topic);
|
|
171
174
|
entries.push({
|
|
172
|
-
topic
|
|
175
|
+
topic,
|
|
173
176
|
package: name,
|
|
174
177
|
version,
|
|
175
178
|
kind: 'guide',
|
package/src/emit.ts
CHANGED
|
@@ -4,8 +4,7 @@
|
|
|
4
4
|
// so a refactor that reorders a struct literal does not produce a diff. Two-space indent and
|
|
5
5
|
// a trailing newline: the file is reviewed by humans and diffed by git.
|
|
6
6
|
|
|
7
|
-
import { canonicalJson } from '@ultimat3/core';
|
|
8
|
-
import { contentHash } from './build';
|
|
7
|
+
import { canonicalJson, fingerprint } from '@ultimat3/core';
|
|
9
8
|
import { ManifestDriftError } from './errors';
|
|
10
9
|
import type { Manifest } from './schema';
|
|
11
10
|
import { isManifest } from './schema';
|
|
@@ -18,7 +17,7 @@ export const MANIFEST_FILENAME = 'x.manifest.json';
|
|
|
18
17
|
* `as const satisfies` and a test that WALKS it, the treatment `ARRAY_SECTIONS` already has
|
|
19
18
|
* (`schema.ts`) — the annotation catches a key that is not on `Manifest`, only a walk catches one
|
|
20
19
|
* that is missing. It was a bare annotation, and `manifestJson` writes these keys and no others
|
|
21
|
-
* while `
|
|
20
|
+
* while `fingerprint` hashes the whole body: a 14th field added to `Manifest` would have gone into
|
|
22
21
|
* the hash and been dropped from the file, after which `assertNoDrift` convicts the committed
|
|
23
22
|
* manifest as HAND_EDITED — a correct refusal carrying the wrong diagnosis, about a file nobody
|
|
24
23
|
* touched. Exported for that test alone; deliberately NOT re-exported by `src/index.ts`, because
|
|
@@ -150,7 +149,7 @@ function describeDrift(onDisk: Manifest, fresh: Manifest): readonly string[] {
|
|
|
150
149
|
/** Verify a file's `buildId` against its own contents — catches a hand-edited manifest. */
|
|
151
150
|
export function verifyBuildId(manifest: Manifest): boolean {
|
|
152
151
|
const { buildId, ...body } = manifest;
|
|
153
|
-
return
|
|
152
|
+
return fingerprint(body) === buildId;
|
|
154
153
|
}
|
|
155
154
|
|
|
156
155
|
async function readIfExists(path: string): Promise<string | undefined> {
|
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
|
@@ -10,7 +10,7 @@ export {
|
|
|
10
10
|
checkAgentsMd,
|
|
11
11
|
} from './agents-md';
|
|
12
12
|
export type { ManifestSources } from './build';
|
|
13
|
-
export { buildManifest
|
|
13
|
+
export { buildManifest } from './build';
|
|
14
14
|
export type { ChangeKind, ManifestChange, ManifestDiff } from './diff';
|
|
15
15
|
export { diffManifest, formatDiff } from './diff';
|
|
16
16
|
export type { DocEntry, DocEntryKind } from './docs-scan';
|
|
@@ -39,6 +39,7 @@ 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,
|
package/src/schema.ts
CHANGED
|
@@ -36,7 +36,7 @@ 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
|
}
|
|
@@ -69,6 +69,13 @@ export interface AdminActionFact {
|
|
|
69
69
|
readonly when: boolean;
|
|
70
70
|
readonly batch: boolean;
|
|
71
71
|
readonly threshold: number | null;
|
|
72
|
+
/**
|
|
73
|
+
* The two inputs of the admin-level gate besides `destructive`: `readonly` lowers it to
|
|
74
|
+
* `admin:read`, `matching` (a set-based write) holds it at `admin:write`. Absent from a file
|
|
75
|
+
* written before they were recorded, and read there as `false`.
|
|
76
|
+
*/
|
|
77
|
+
readonly readonly: boolean;
|
|
78
|
+
readonly matching: boolean;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
/** One resource of a generated admin: what its list answers, and whether a row scope narrows it. */
|
|
@@ -173,7 +180,11 @@ export interface ActionFact {
|
|
|
173
180
|
|
|
174
181
|
export interface QueryFact {
|
|
175
182
|
readonly name: string;
|
|
176
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* The input's JSON Schema, projected from the query handle as an action's is. Optional only for
|
|
185
|
+
* a manifest committed before queries published it; `diffQueries` reads an absent side as no
|
|
186
|
+
* evidence rather than as a changed schema.
|
|
187
|
+
*/
|
|
177
188
|
readonly input?: JsonValue;
|
|
178
189
|
/** The policy's DISPLAY label — see `ActionFact.policy`, and read `permissions` to match on. */
|
|
179
190
|
readonly policy: string | null;
|
package/src/sources-admin.ts
CHANGED
package/src/sources.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
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';
|
|
8
|
+
import { describeActions, jsonSchemaOf } from '@ultimat3/action';
|
|
9
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
14
|
import type {
|
|
@@ -23,7 +23,7 @@ import { declaredAdmins } from './sources-admin';
|
|
|
23
23
|
|
|
24
24
|
export interface FrameworkSourcesInput {
|
|
25
25
|
readonly app: { readonly name: string; readonly version: string };
|
|
26
|
-
/** From `@ultimat3/render`'s `
|
|
26
|
+
/** From `@ultimat3/render`'s `describePages()`. */
|
|
27
27
|
readonly routes?: readonly RouteFact[];
|
|
28
28
|
/** Assembled per app from its policy modules. */
|
|
29
29
|
readonly policies?: readonly PolicyFact[];
|
|
@@ -49,6 +49,12 @@ export interface FrameworkSourcesInput {
|
|
|
49
49
|
*/
|
|
50
50
|
const asJson = (value: object): JsonValue => value as JsonValue;
|
|
51
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
|
+
|
|
52
58
|
/** Absent stays absent: only a sealed column carries the field. */
|
|
53
59
|
const sealedFact = (
|
|
54
60
|
how: 'opaque' | 'lookup' | undefined,
|
|
@@ -121,6 +127,9 @@ export function frameworkSources(input: FrameworkSourcesInput): ManifestSources
|
|
|
121
127
|
})),
|
|
122
128
|
queries: describeQueries().map((query) => ({
|
|
123
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),
|
|
124
133
|
policy: query.capability,
|
|
125
134
|
permissions: query.permissions,
|
|
126
135
|
live: query.live,
|
package/src/diff-fixtures.ts
DELETED
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
// TEST-ONLY. One fully-populated `ManifestSources` for the diff suites, so every section carries a
|
|
2
|
-
// fact and every fact carries every field its type declares — a fixture with an empty `tasks` is
|
|
3
|
-
// how a section nothing classifies stays green. Never exported from `index.ts`.
|
|
4
|
-
|
|
5
|
-
import type { ManifestSources } from './build';
|
|
6
|
-
import { buildManifest } from './build';
|
|
7
|
-
import type { Manifest } from './schema';
|
|
8
|
-
|
|
9
|
-
export const fixtureAction = (
|
|
10
|
-
name: string,
|
|
11
|
-
policy: string,
|
|
12
|
-
expose = true,
|
|
13
|
-
permissions = [policy],
|
|
14
|
-
) => ({
|
|
15
|
-
name,
|
|
16
|
-
input: { id: 'uuid' },
|
|
17
|
-
output: { ok: 'boolean' },
|
|
18
|
-
policy,
|
|
19
|
-
permissions,
|
|
20
|
-
cacheInvalidates: ['post'],
|
|
21
|
-
mcp: { expose },
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
export const fixtureQuery = (name: string, policy: string, permissions = [policy]) => ({
|
|
25
|
-
name,
|
|
26
|
-
input: {},
|
|
27
|
-
policy,
|
|
28
|
-
permissions,
|
|
29
|
-
live: true,
|
|
30
|
-
subscribes: ['posts'],
|
|
31
|
-
cacheTags: ['post'],
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Built through a helper rather than written as `{ code: 'X_…' }`: `x verify`'s `errors` step
|
|
36
|
-
* reads a `code:` key with an `X_*` literal as a DECLARATION, and this file is not a test file, so
|
|
37
|
-
* the literal would have published a fixture's code into `framework.manifest.json` as one this
|
|
38
|
-
* package owns.
|
|
39
|
-
*/
|
|
40
|
-
export const fixtureErrorCode = (code: string, owner: string) => ({ code, package: owner });
|
|
41
|
-
|
|
42
|
-
/** Every section non-empty, every optional field present. */
|
|
43
|
-
export const FIXTURE: ManifestSources = {
|
|
44
|
-
app: { name: 'acme', version: '1.4.2' },
|
|
45
|
-
routes: [
|
|
46
|
-
{
|
|
47
|
-
url: '/posts',
|
|
48
|
-
render: 'isr',
|
|
49
|
-
offline: 'precache',
|
|
50
|
-
hydrate: 'idle',
|
|
51
|
-
revalidateTags: ['post'],
|
|
52
|
-
budget: { js: '40kb', lcp: 2000 },
|
|
53
|
-
surface: 'site',
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
entities: [
|
|
57
|
-
{
|
|
58
|
-
name: 'post',
|
|
59
|
-
table: 'posts',
|
|
60
|
-
columns: [
|
|
61
|
-
{ name: 'id', type: 'uuid', nullable: false, primaryKey: true },
|
|
62
|
-
{ name: 'authorId', type: 'uuid', nullable: false, references: 'users.id' },
|
|
63
|
-
{ name: 'note', type: 'text', nullable: true },
|
|
64
|
-
],
|
|
65
|
-
invariants: ['post_title_present'],
|
|
66
|
-
},
|
|
67
|
-
],
|
|
68
|
-
actions: [fixtureAction('publishPost', 'post:publish')],
|
|
69
|
-
queries: [fixtureQuery('feed', 'feed:read')],
|
|
70
|
-
jobs: [
|
|
71
|
-
{
|
|
72
|
-
name: 'sendMail',
|
|
73
|
-
input: { orgId: 'uuid' },
|
|
74
|
-
queue: 'critical',
|
|
75
|
-
retry: { attempts: 5, backoff: 'exponential' },
|
|
76
|
-
steps: ['a'],
|
|
77
|
-
},
|
|
78
|
-
],
|
|
79
|
-
tasks: [
|
|
80
|
-
{ name: 'nightlyDigest', cron: '0 3 * * *', tz: 'Europe/Berlin', enqueues: ['sendMail'] },
|
|
81
|
-
],
|
|
82
|
-
policies: [
|
|
83
|
-
{ permission: 'post:publish', description: 'publish a draft', enforcedIn: ['actions.publish'] },
|
|
84
|
-
],
|
|
85
|
-
locales: ['en'],
|
|
86
|
-
// A code that is genuinely REGISTERED, not an invented one. `error-catalog.test.ts` scans every
|
|
87
|
-
// non-test file under `packages/*/src` for an `X_*` literal and treats an unregistered one as a
|
|
88
|
-
// code handed to a reader that no gate can see. A fixture is not shipped source in spirit, but
|
|
89
|
-
// it is in fact — and widening that scanner to excuse a filename is a worse trade than picking a
|
|
90
|
-
// real code here, since the diff classifier only ever compares the string.
|
|
91
|
-
errorCodes: [fixtureErrorCode('X_NOT_FOUND', 'app')],
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
/** The fixture, built. */
|
|
95
|
-
export const fixtureManifest = (overrides: Partial<ManifestSources> = {}): Manifest =>
|
|
96
|
-
buildManifest({ ...FIXTURE, ...overrides });
|